feat(challenges): 接入真实接口并完善挑战列表与详情状态管理
- 新增 challengesApi 服务层,支持列表/详情/加入/退出/打卡接口 - 重构 challengesSlice,使用 createAsyncThunk 管理异步状态 - 列表页支持加载、空态、错误重试及状态标签 - 详情页支持进度展示、打卡、退出及错误提示 - 统一卡片与详情数据模型,支持动态状态更新
This commit is contained in:
@@ -1,29 +1,107 @@
|
|||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { selectChallengeCards, type ChallengeViewModel } from '@/store/challengesSlice';
|
import {
|
||||||
|
fetchChallenges,
|
||||||
|
selectChallengeCards,
|
||||||
|
selectChallengesListError,
|
||||||
|
selectChallengesListStatus,
|
||||||
|
type ChallengeCardViewModel,
|
||||||
|
} from '@/store/challengesSlice';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
StatusBar,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
const AVATAR_SIZE = 36;
|
const AVATAR_SIZE = 36;
|
||||||
const CARD_IMAGE_WIDTH = 132;
|
const CARD_IMAGE_WIDTH = 132;
|
||||||
const CARD_IMAGE_HEIGHT = 96;
|
const CARD_IMAGE_HEIGHT = 96;
|
||||||
|
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
|
||||||
|
upcoming: '即将开始',
|
||||||
|
ongoing: '进行中',
|
||||||
|
expired: '已结束',
|
||||||
|
};
|
||||||
|
|
||||||
export default function ChallengesScreen() {
|
export default function ChallengesScreen() {
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const challenges = useAppSelector(selectChallengeCards);
|
const challenges = useAppSelector(selectChallengeCards);
|
||||||
|
const listStatus = useAppSelector(selectChallengesListStatus);
|
||||||
|
const listError = useAppSelector(selectChallengesListError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (listStatus === 'idle') {
|
||||||
|
dispatch(fetchChallenges());
|
||||||
|
}
|
||||||
|
}, [dispatch, listStatus]);
|
||||||
|
|
||||||
const gradientColors: [string, string] =
|
const gradientColors: [string, string] =
|
||||||
theme === 'dark'
|
theme === 'dark'
|
||||||
? ['#1f2230', '#10131e']
|
? ['#1f2230', '#10131e']
|
||||||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||||
|
|
||||||
|
const renderChallenges = () => {
|
||||||
|
if (listStatus === 'loading' && challenges.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stateContainer}>
|
||||||
|
<ActivityIndicator color={colorTokens.primary} />
|
||||||
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>加载挑战中…</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listStatus === 'failed' && challenges.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stateContainer}>
|
||||||
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{listError ?? '加载挑战失败,请稍后重试'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={() => dispatch(fetchChallenges())}
|
||||||
|
>
|
||||||
|
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challenges.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stateContainer}>
|
||||||
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>暂无挑战,稍后再来探索。</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return challenges.map((challenge) => (
|
||||||
|
<ChallengeCard
|
||||||
|
key={challenge.id}
|
||||||
|
challenge={challenge}
|
||||||
|
surfaceColor={colorTokens.surface}
|
||||||
|
textColor={colorTokens.text}
|
||||||
|
mutedColor={colorTokens.textSecondary}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
|
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
|
||||||
@@ -51,20 +129,7 @@ export default function ChallengesScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.cardsContainer}>
|
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
||||||
{challenges.map((challenge) => (
|
|
||||||
<ChallengeCard
|
|
||||||
key={challenge.id}
|
|
||||||
challenge={challenge}
|
|
||||||
surfaceColor={colorTokens.surface}
|
|
||||||
textColor={colorTokens.text}
|
|
||||||
mutedColor={colorTokens.textSecondary}
|
|
||||||
onPress={() =>
|
|
||||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
@@ -72,7 +137,7 @@ export default function ChallengesScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChallengeCardProps = {
|
type ChallengeCardProps = {
|
||||||
challenge: ChallengeViewModel;
|
challenge: ChallengeCardViewModel;
|
||||||
surfaceColor: string;
|
surfaceColor: string;
|
||||||
textColor: string;
|
textColor: string;
|
||||||
mutedColor: string;
|
mutedColor: string;
|
||||||
@@ -80,6 +145,8 @@ type ChallengeCardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||||||
|
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.92}
|
activeOpacity={0.92}
|
||||||
@@ -103,8 +170,20 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
|
|||||||
{challenge.title}
|
{challenge.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
|
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
|
||||||
<Text style={[styles.cardParticipants, { color: mutedColor }]}>{challenge.participantsLabel}</Text>
|
<Text style={[styles.cardParticipants, { color: mutedColor }]}>
|
||||||
|
{challenge.participantsLabel}
|
||||||
|
{' · '}
|
||||||
|
{statusLabel}
|
||||||
|
{challenge.isJoined ? ' · 已加入' : ''}
|
||||||
|
</Text>
|
||||||
|
{challenge.progress?.badge ? (
|
||||||
|
<Text style={[styles.cardProgress, { color: textColor }]} numberOfLines={1}>
|
||||||
|
{challenge.progress.badge}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{challenge.avatars.length ? (
|
||||||
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
@@ -118,7 +197,9 @@ type AvatarStackProps = {
|
|||||||
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
|
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.avatarRow}>
|
<View style={styles.avatarRow}>
|
||||||
{avatars.map((avatar, index) => (
|
{avatars
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((avatar, index) => (
|
||||||
<Image
|
<Image
|
||||||
key={`${avatar}-${index}`}
|
key={`${avatar}-${index}`}
|
||||||
source={{ uri: avatar }}
|
source={{ uri: avatar }}
|
||||||
@@ -180,6 +261,29 @@ const styles = StyleSheet.create({
|
|||||||
cardsContainer: {
|
cardsContainer: {
|
||||||
gap: 18,
|
gap: 18,
|
||||||
},
|
},
|
||||||
|
stateContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 40,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
stateText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
retryText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
card: {
|
card: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
borderRadius: 28,
|
borderRadius: 28,
|
||||||
@@ -213,6 +317,11 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
cardProgress: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
avatarRow: {
|
avatarRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { selectChallengeViewById } from '@/store/challengesSlice';
|
import {
|
||||||
|
fetchChallengeDetail,
|
||||||
|
joinChallenge,
|
||||||
|
leaveChallenge,
|
||||||
|
reportChallengeProgress,
|
||||||
|
selectChallengeById,
|
||||||
|
selectChallengeDetailError,
|
||||||
|
selectChallengeDetailStatus,
|
||||||
|
selectJoinError,
|
||||||
|
selectJoinStatus,
|
||||||
|
selectLeaveError,
|
||||||
|
selectLeaveStatus,
|
||||||
|
selectProgressError,
|
||||||
|
selectProgressStatus,
|
||||||
|
} from '@/store/challengesSlice';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
@@ -10,6 +24,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
|
|||||||
import LottieView from 'lottie-react-native';
|
import LottieView from 'lottie-react-native';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Image,
|
Image,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -25,183 +40,134 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const HERO_HEIGHT = width * 0.86;
|
const HERO_HEIGHT = width * 0.86;
|
||||||
|
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
|
||||||
|
|
||||||
type ChallengeProgress = {
|
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
|
||||||
completedDays: number;
|
|
||||||
totalDays: number;
|
const formatMonthDay = (value?: string): string | undefined => {
|
||||||
remainingDays: number;
|
if (!value) return undefined;
|
||||||
badge: string;
|
const date = new Date(value);
|
||||||
subtitle?: string;
|
if (Number.isNaN(date.getTime())) return undefined;
|
||||||
|
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChallengeDetail = {
|
const buildDateRangeLabel = (challenge?: {
|
||||||
image: string;
|
startAt?: string;
|
||||||
periodLabel: string;
|
endAt?: string;
|
||||||
durationLabel: string;
|
periodLabel?: string;
|
||||||
requirementLabel: string;
|
durationLabel?: string;
|
||||||
summary?: string;
|
}): string => {
|
||||||
participantsCount: number;
|
if (!challenge) return '';
|
||||||
rankingDescription?: string;
|
const startLabel = formatMonthDay(challenge.startAt);
|
||||||
rankings: RankingItem[];
|
const endLabel = formatMonthDay(challenge.endAt);
|
||||||
highlightTitle: string;
|
if (startLabel && endLabel) {
|
||||||
highlightSubtitle: string;
|
return `${startLabel} - ${endLabel}`;
|
||||||
ctaLabel: string;
|
}
|
||||||
progress?: ChallengeProgress;
|
return challenge.periodLabel ?? challenge.durationLabel ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
type RankingItem = {
|
const formatParticipantsLabel = (count?: number): string => {
|
||||||
id: string;
|
if (typeof count !== 'number') return '持续更新中';
|
||||||
name: string;
|
return `${count.toLocaleString('zh-CN')} 人正在参与`;
|
||||||
avatar: string;
|
|
||||||
metric: string;
|
|
||||||
badge?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DETAIL_PRESETS: Record<string, ChallengeDetail> = {
|
|
||||||
'hydration-hippo': {
|
|
||||||
image:
|
|
||||||
'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80',
|
|
||||||
periodLabel: '9月01日 - 9月30日 · 剩余 4 天',
|
|
||||||
durationLabel: '30 天',
|
|
||||||
requirementLabel: '喝水 1500ml 15 天以上',
|
|
||||||
summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。',
|
|
||||||
participantsCount: 9009,
|
|
||||||
rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。',
|
|
||||||
rankings: [
|
|
||||||
{
|
|
||||||
id: 'all-1',
|
|
||||||
name: '湖光暮色',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=140&q=80',
|
|
||||||
metric: '平均 3,200 ml',
|
|
||||||
badge: '金冠冠军',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'all-2',
|
|
||||||
name: '温柔潮汐',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
|
|
||||||
metric: '平均 2,980 ml',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'all-3',
|
|
||||||
name: '晨雾河岸',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=140&q=80',
|
|
||||||
metric: '平均 2,860 ml',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'male-1',
|
|
||||||
name: '北岸微风',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?auto=format&fit=crop&w=140&q=80',
|
|
||||||
metric: '平均 3,120 ml',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'male-2',
|
|
||||||
name: '静水晚霞',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
|
|
||||||
metric: '平均 2,940 ml',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'female-1',
|
|
||||||
name: '露珠初晓',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=140&q=80',
|
|
||||||
metric: '平均 3,060 ml',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'female-2',
|
|
||||||
name: '桔梗水语',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=140&q=80',
|
|
||||||
metric: '平均 2,880 ml',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
highlightTitle: '加入挑战',
|
|
||||||
highlightSubtitle: '畅饮打卡越多,专属奖励越丰厚',
|
|
||||||
ctaLabel: '立即加入挑战',
|
|
||||||
progress: {
|
|
||||||
completedDays: 12,
|
|
||||||
totalDays: 15,
|
|
||||||
remainingDays: 3,
|
|
||||||
badge: 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?auto=format&fit=crop&w=160&q=80',
|
|
||||||
subtitle: '学河马饮,做补水人',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_DETAIL: ChallengeDetail = {
|
|
||||||
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=240&q=80',
|
|
||||||
periodLabel: '本周进行中',
|
|
||||||
durationLabel: '30 天',
|
|
||||||
requirementLabel: '保持专注完成每日任务',
|
|
||||||
participantsCount: 3200,
|
|
||||||
highlightTitle: '立即参加,点燃动力',
|
|
||||||
highlightSubtitle: '邀请好友一起坚持,更容易收获成果',
|
|
||||||
ctaLabel: '立即加入挑战',
|
|
||||||
rankings: [],
|
|
||||||
progress: {
|
|
||||||
completedDays: 4,
|
|
||||||
totalDays: 21,
|
|
||||||
remainingDays: 5,
|
|
||||||
badge: 'https://images.unsplash.com/photo-1529257414771-1960d69cc2b3?auto=format&fit=crop&w=160&q=80',
|
|
||||||
subtitle: '坚持让好习惯生根发芽',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ChallengeDetailScreen() {
|
export default function ChallengeDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id?: string }>();
|
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const challengeSelector = useMemo(() => (id ? selectChallengeViewById(id) : undefined), [id]);
|
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
|
||||||
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
|
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
|
||||||
|
|
||||||
const detail = useMemo<ChallengeDetail>(() => {
|
const detailStatusSelector = useMemo(() => (id ? selectChallengeDetailStatus(id) : undefined), [id]);
|
||||||
if (!id) return DEFAULT_DETAIL;
|
const detailStatus = useAppSelector((state) => (detailStatusSelector ? detailStatusSelector(state) : 'idle'));
|
||||||
return DETAIL_PRESETS[id] ?? {
|
const detailErrorSelector = useMemo(() => (id ? selectChallengeDetailError(id) : undefined), [id]);
|
||||||
...DEFAULT_DETAIL,
|
const detailError = useAppSelector((state) => (detailErrorSelector ? detailErrorSelector(state) : undefined));
|
||||||
periodLabel: challenge?.dateRange ?? DEFAULT_DETAIL.periodLabel,
|
|
||||||
highlightTitle: `加入 ${challenge?.title ?? '挑战'}`,
|
|
||||||
};
|
|
||||||
}, [challenge?.dateRange, challenge?.title, id]);
|
|
||||||
|
|
||||||
const [hasJoined, setHasJoined] = useState(false);
|
const joinStatusSelector = useMemo(() => (id ? selectJoinStatus(id) : undefined), [id]);
|
||||||
const [progress, setProgress] = useState<ChallengeProgress | undefined>(undefined);
|
const joinStatus = useAppSelector((state) => (joinStatusSelector ? joinStatusSelector(state) : 'idle'));
|
||||||
const [showCelebration, setShowCelebration] = useState(false);
|
const joinErrorSelector = useMemo(() => (id ? selectJoinError(id) : undefined), [id]);
|
||||||
const rankingData = detail.rankings ?? [];
|
const joinError = useAppSelector((state) => (joinErrorSelector ? joinErrorSelector(state) : undefined));
|
||||||
const ctaGradientColors: [string, string] = ['#5E8BFF', '#6B6CFF'];
|
|
||||||
const progressSegments = useMemo(() => {
|
const leaveStatusSelector = useMemo(() => (id ? selectLeaveStatus(id) : undefined), [id]);
|
||||||
if (!progress) return undefined;
|
const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle'));
|
||||||
const segmentsCount = Math.max(1, Math.min(progress.totalDays, 18));
|
const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]);
|
||||||
const completedSegments = Math.min(
|
const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined));
|
||||||
segmentsCount,
|
|
||||||
Math.round((progress.completedDays / Math.max(progress.totalDays, 1)) * segmentsCount),
|
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
|
||||||
);
|
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
|
||||||
return { segmentsCount, completedSegments };
|
const progressErrorSelector = useMemo(() => (id ? selectProgressError(id) : undefined), [id]);
|
||||||
}, [progress]);
|
const progressError = useAppSelector((state) => (progressErrorSelector ? progressErrorSelector(state) : undefined));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasJoined(false);
|
if (id) {
|
||||||
setProgress(undefined);
|
dispatch(fetchChallengeDetail(id));
|
||||||
|
}
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
const [showCelebration, setShowCelebration] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowCelebration(false);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (joinStatus === 'succeeded') {
|
||||||
|
setShowCelebration(true);
|
||||||
|
}
|
||||||
|
}, [joinStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCelebration) {
|
if (!showCelebration) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setShowCelebration(false);
|
setShowCelebration(false);
|
||||||
}, 2400);
|
}, 2400);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [showCelebration]);
|
}, [showCelebration]);
|
||||||
|
|
||||||
|
const progress = challenge?.progress;
|
||||||
|
const hasProgress = Boolean(progress);
|
||||||
|
const progressTarget = progress?.target ?? 0;
|
||||||
|
const progressCompleted = progress?.completed ?? 0;
|
||||||
|
|
||||||
|
const progressSegments = useMemo(() => {
|
||||||
|
if (!hasProgress || progressTarget <= 0) return undefined;
|
||||||
|
const segmentsCount = Math.max(1, Math.min(progressTarget, 18));
|
||||||
|
const completedSegments = Math.min(
|
||||||
|
segmentsCount,
|
||||||
|
Math.round((progressCompleted / Math.max(progressTarget, 1)) * segmentsCount),
|
||||||
|
);
|
||||||
|
return { segmentsCount, completedSegments };
|
||||||
|
}, [hasProgress, progressCompleted, progressTarget]);
|
||||||
|
|
||||||
|
const rankingData = useMemo(() => challenge?.rankings ?? [], [challenge?.rankings]);
|
||||||
|
const participantAvatars = useMemo(
|
||||||
|
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
||||||
|
[rankingData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateRangeLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
buildDateRangeLabel({
|
||||||
|
startAt: challenge?.startAt,
|
||||||
|
endAt: challenge?.endAt,
|
||||||
|
periodLabel: challenge?.periodLabel,
|
||||||
|
durationLabel: challenge?.durationLabel,
|
||||||
|
}),
|
||||||
|
[challenge?.startAt, challenge?.endAt, challenge?.periodLabel, challenge?.durationLabel],
|
||||||
|
);
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Share.share({
|
await Share.share({
|
||||||
title: challenge.title,
|
title: challenge.title,
|
||||||
@@ -214,16 +180,30 @@ export default function ChallengeDetailScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleJoin = () => {
|
const handleJoin = () => {
|
||||||
if (hasJoined) {
|
if (!id || joinStatus === 'loading') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
dispatch(joinChallenge(id));
|
||||||
setHasJoined(true);
|
|
||||||
setProgress(detail.progress);
|
|
||||||
setShowCelebration(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!challenge) {
|
const handleLeave = () => {
|
||||||
|
if (!id || leaveStatus === 'loading') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch(leaveChallenge(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressReport = () => {
|
||||||
|
if (!id || progressStatus === 'loading') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch(reportChallengeProgress({ id }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isJoined = challenge?.isJoined ?? false;
|
||||||
|
const isLoadingInitial = detailStatus === 'loading' && !challenge;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||||
@@ -234,14 +214,52 @@ export default function ChallengeDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoadingInitial) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||||
|
<View style={styles.missingContainer}>
|
||||||
|
<ActivityIndicator color={colorTokens.primary} />
|
||||||
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}>加载挑战详情中…</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!challenge) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||||
|
<View style={styles.missingContainer}>
|
||||||
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{detailError ?? '未找到该挑战,稍后再试试吧。'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={() => dispatch(fetchChallengeDetail(id))}
|
||||||
|
>
|
||||||
|
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
|
||||||
|
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
|
||||||
|
const ctaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
|
||||||
|
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
|
||||||
|
|
||||||
|
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
|
||||||
|
const progressActionError =
|
||||||
|
(progressStatus !== 'loading' && progressError) || (leaveStatus !== 'loading' && leaveError) || undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.safeArea}>
|
<View style={styles.safeArea}>
|
||||||
<StatusBar barStyle="light-content" />
|
<StatusBar barStyle="light-content" />
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View
|
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
|
||||||
pointerEvents="box-none"
|
|
||||||
style={[styles.headerOverlay, { paddingTop: insets.top }]}
|
|
||||||
>
|
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title=""
|
title=""
|
||||||
backColor="white"
|
backColor="white"
|
||||||
@@ -274,9 +292,15 @@ export default function ChallengeDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.headerTextBlock}>
|
<View style={styles.headerTextBlock}>
|
||||||
<Text style={styles.periodLabel}>{detail.periodLabel}</Text>
|
<Text style={styles.periodLabel}>{challenge.periodLabel ?? dateRangeLabel}</Text>
|
||||||
<Text style={styles.title}>{challenge.title}</Text>
|
<Text style={styles.title}>{challenge.title}</Text>
|
||||||
{detail.summary ? <Text style={styles.summary}>{detail.summary}</Text> : null}
|
{challenge.summary ? <Text style={styles.summary}>{challenge.summary}</Text> : null}
|
||||||
|
{inlineErrorMessage ? (
|
||||||
|
<View style={styles.inlineError}>
|
||||||
|
<Ionicons name="warning-outline" size={14} color="#FF6B6B" />
|
||||||
|
<Text style={styles.inlineErrorText}>{inlineErrorMessage}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{progress && progressSegments ? (
|
{progress && progressSegments ? (
|
||||||
@@ -290,20 +314,30 @@ export default function ChallengeDetailScreen() {
|
|||||||
>
|
>
|
||||||
<View style={styles.progressHeaderRow}>
|
<View style={styles.progressHeaderRow}>
|
||||||
<View style={styles.progressBadgeRing}>
|
<View style={styles.progressBadgeRing}>
|
||||||
|
{progress.badge ? (
|
||||||
|
isHttpUrl(progress.badge) ? (
|
||||||
<Image source={{ uri: progress.badge }} style={styles.progressBadge} />
|
<Image source={{ uri: progress.badge }} style={styles.progressBadge} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.progressBadgeFallback}>
|
||||||
|
<Text style={styles.progressBadgeText}>{progress.badge}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={styles.progressBadgeFallback}>
|
||||||
|
<Text style={styles.progressBadgeText}>打卡中</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.progressHeadline}>
|
<View style={styles.progressHeadline}>
|
||||||
<Text style={styles.progressTitle}>{challenge.title}</Text>
|
<Text style={styles.progressTitle}>{challenge.title}</Text>
|
||||||
{progress.subtitle ? (
|
{progress.subtitle ? <Text style={styles.progressSubtitle}>{progress.subtitle}</Text> : null}
|
||||||
<Text style={styles.progressSubtitle}>{progress.subtitle}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.progressRemaining}>剩余 {progress.remainingDays} 天</Text>
|
<Text style={styles.progressRemaining}>剩余 {progress.remaining} 天</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.progressMetaRow}>
|
<View style={styles.progressMetaRow}>
|
||||||
<Text style={styles.progressMetaValue}>
|
<Text style={styles.progressMetaValue}>
|
||||||
{progress.completedDays} / {progress.totalDays}
|
{progress.completed} / {progress.target}
|
||||||
<Text style={styles.progressMetaSuffix}> 天</Text>
|
<Text style={styles.progressMetaSuffix}> 天</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -326,6 +360,42 @@ export default function ChallengeDetailScreen() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{isJoined ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.progressActionsRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.progressPrimaryAction,
|
||||||
|
progressStatus === 'loading' && styles.progressActionDisabled,
|
||||||
|
]}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={handleProgressReport}
|
||||||
|
disabled={progressStatus === 'loading'}
|
||||||
|
>
|
||||||
|
<Text style={styles.progressPrimaryActionText}>
|
||||||
|
{progressStatus === 'loading' ? '打卡中…' : '打卡 +1'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.progressSecondaryAction,
|
||||||
|
leaveStatus === 'loading' && styles.progressActionDisabled,
|
||||||
|
]}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={handleLeave}
|
||||||
|
disabled={leaveStatus === 'loading'}
|
||||||
|
>
|
||||||
|
<Text style={styles.progressSecondaryActionText}>
|
||||||
|
{leaveStatus === 'loading' ? '处理中…' : '退出挑战'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{progressActionError ? (
|
||||||
|
<Text style={styles.progressErrorText}>{progressActionError}</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -337,8 +407,8 @@ export default function ChallengeDetailScreen() {
|
|||||||
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" />
|
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.detailTextWrapper}>
|
<View style={styles.detailTextWrapper}>
|
||||||
<Text style={styles.detailLabel}>{challenge.dateRange}</Text>
|
<Text style={styles.detailLabel}>{dateRangeLabel}</Text>
|
||||||
<Text style={styles.detailMeta}>{detail.durationLabel}</Text>
|
<Text style={styles.detailMeta}>{challenge.durationLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -347,7 +417,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
|
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.detailTextWrapper}>
|
<View style={styles.detailTextWrapper}>
|
||||||
<Text style={styles.detailLabel}>{detail.requirementLabel}</Text>
|
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
|
||||||
<Text style={styles.detailMeta}>按日打卡自动累计</Text>
|
<Text style={styles.detailMeta}>按日打卡自动累计</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -356,21 +426,24 @@ export default function ChallengeDetailScreen() {
|
|||||||
<View style={styles.detailIconWrapper}>
|
<View style={styles.detailIconWrapper}>
|
||||||
<Ionicons name="people-outline" size={20} color="#4F5BD5" />
|
<Ionicons name="people-outline" size={20} color="#4F5BD5" />
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.detailTextWrapper, { flex: 1 }]}
|
<View style={[styles.detailTextWrapper, { flex: 1 }]}>
|
||||||
>
|
<Text style={styles.detailLabel}>{participantsLabel}</Text>
|
||||||
<Text style={styles.detailLabel}>{detail.participantsCount.toLocaleString('zh-CN')} 人正在参与</Text>
|
{participantAvatars.length ? (
|
||||||
<View style={styles.avatarRow}>
|
<View style={styles.avatarRow}>
|
||||||
{challenge.avatars.slice(0, 6).map((avatar, index) => (
|
{participantAvatars.map((avatar, index) => (
|
||||||
<Image
|
<Image
|
||||||
key={`${avatar}-${index}`}
|
key={`${avatar}-${index}`}
|
||||||
source={{ uri: avatar }}
|
source={{ uri: avatar }}
|
||||||
style={[styles.avatar, index > 0 && styles.avatarOffset]}
|
style={[styles.avatar, index > 0 && styles.avatarOffset]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
|
||||||
<TouchableOpacity style={styles.moreAvatarButton}>
|
<TouchableOpacity style={styles.moreAvatarButton}>
|
||||||
<Text style={styles.moreAvatarText}>更多</Text>
|
<Text style={styles.moreAvatarText}>更多</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -382,8 +455,8 @@ export default function ChallengeDetailScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{detail.rankingDescription ? (
|
{challenge.rankingDescription ? (
|
||||||
<Text style={styles.sectionSubtitle}>{detail.rankingDescription}</Text>
|
<Text style={styles.sectionSubtitle}>{challenge.rankingDescription}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.rankingCard}>
|
<View style={styles.rankingCard}>
|
||||||
@@ -393,7 +466,13 @@ export default function ChallengeDetailScreen() {
|
|||||||
<View style={styles.rankingOrderCircle}>
|
<View style={styles.rankingOrderCircle}>
|
||||||
<Text style={styles.rankingOrder}>{index + 1}</Text>
|
<Text style={styles.rankingOrder}>{index + 1}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{item.avatar ? (
|
||||||
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
|
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.rankingAvatarPlaceholder}>
|
||||||
|
<Ionicons name="person-outline" size={20} color="#6f7ba7" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<View style={styles.rankingInfo}>
|
<View style={styles.rankingInfo}>
|
||||||
<Text style={styles.rankingName}>{item.name}</Text>
|
<Text style={styles.rankingName}>{item.name}</Text>
|
||||||
<Text style={styles.rankingMetric}>{item.metric}</Text>
|
<Text style={styles.rankingMetric}>{item.metric}</Text>
|
||||||
@@ -407,31 +486,30 @@ export default function ChallengeDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
{!hasJoined && (
|
|
||||||
<View
|
{!isJoined && (
|
||||||
pointerEvents="box-none"
|
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
||||||
style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}
|
|
||||||
>
|
|
||||||
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
||||||
<View style={styles.floatingCTAContent}>
|
<View style={styles.floatingCTAContent}>
|
||||||
<View style={styles.highlightCopy}>
|
<View style={styles.highlightCopy}>
|
||||||
<Text style={styles.highlightTitle}>{detail.highlightTitle}</Text>
|
<Text style={styles.highlightTitle}>{highlightTitle}</Text>
|
||||||
<Text style={styles.highlightSubtitle}>{detail.highlightSubtitle}</Text>
|
<Text style={styles.highlightSubtitle}>{highlightSubtitle}</Text>
|
||||||
|
{joinError ? <Text style={styles.ctaErrorText}>{joinError}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.highlightButton}
|
style={styles.highlightButton}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
onPress={handleJoin}
|
onPress={handleJoin}
|
||||||
|
disabled={joinStatus === 'loading'}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={ctaGradientColors}
|
colors={CTA_GRADIENT}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
style={styles.highlightButtonBackground}
|
style={styles.highlightButtonBackground}
|
||||||
>
|
>
|
||||||
<Text style={styles.highlightButtonLabel}>{detail.ctaLabel}</Text>
|
<Text style={styles.highlightButtonLabel}>{ctaLabel}</Text>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -523,6 +601,21 @@ const styles = StyleSheet.create({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 28,
|
borderRadius: 28,
|
||||||
},
|
},
|
||||||
|
progressBadgeFallback: {
|
||||||
|
flex: 1,
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#EEF0FF',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
progressBadgeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#4F5BD5',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
progressHeadline: {
|
progressHeadline: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
@@ -581,6 +674,47 @@ const styles = StyleSheet.create({
|
|||||||
progressBarSegmentLast: {
|
progressBarSegmentLast: {
|
||||||
marginRight: 0,
|
marginRight: 0,
|
||||||
},
|
},
|
||||||
|
progressActionsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
progressPrimaryAction: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#5E8BFF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
progressSecondaryAction: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d6dcff',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
progressActionDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
progressPrimaryActionText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
progressSecondaryActionText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#4F5BD5',
|
||||||
|
},
|
||||||
|
progressErrorText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#FF6B6B',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
floatingCTAContainer: {
|
floatingCTAContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -629,6 +763,21 @@ const styles = StyleSheet.create({
|
|||||||
color: '#7080b4',
|
color: '#7080b4',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
inlineError: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(255, 107, 107, 0.12)',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
inlineErrorText: {
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#FF6B6B',
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
detailCard: {
|
detailCard: {
|
||||||
marginTop: 28,
|
marginTop: 28,
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 20,
|
||||||
@@ -760,6 +909,15 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
marginRight: 14,
|
marginRight: 14,
|
||||||
},
|
},
|
||||||
|
rankingAvatarPlaceholder: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
marginRight: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#EEF0FF',
|
||||||
|
},
|
||||||
rankingInfo: {
|
rankingInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
@@ -797,6 +955,11 @@ const styles = StyleSheet.create({
|
|||||||
color: '#5f6a97',
|
color: '#5f6a97',
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
|
ctaErrorText: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#FF6B6B',
|
||||||
|
},
|
||||||
highlightButton: {
|
highlightButton: {
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -838,6 +1001,17 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: 18,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 22,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
retryText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
celebrationOverlay: {
|
celebrationOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
65
services/challengesApi.ts
Normal file
65
services/challengesApi.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export type ChallengeStatus = 'upcoming' | 'ongoing' | 'expired';
|
||||||
|
|
||||||
|
export type ChallengeProgressDto = {
|
||||||
|
completed: number;
|
||||||
|
target: number;
|
||||||
|
remaining: number;
|
||||||
|
badge: string;
|
||||||
|
subtitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RankingItemDto = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string | null;
|
||||||
|
metric: string;
|
||||||
|
badge?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChallengeListItemDto = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
periodLabel?: string;
|
||||||
|
durationLabel: string;
|
||||||
|
requirementLabel: string;
|
||||||
|
status: ChallengeStatus;
|
||||||
|
participantsCount: number;
|
||||||
|
rankingDescription?: string;
|
||||||
|
highlightTitle: string;
|
||||||
|
highlightSubtitle: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
progress?: ChallengeProgressDto;
|
||||||
|
isJoined: boolean;
|
||||||
|
startAt?: string;
|
||||||
|
endAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChallengeDetailDto = ChallengeListItemDto & {
|
||||||
|
summary?: string;
|
||||||
|
rankings: RankingItemDto[];
|
||||||
|
userRank?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listChallenges(): Promise<ChallengeListItemDto[]> {
|
||||||
|
return api.get<ChallengeListItemDto[]>('/challenges');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChallengeDetail(id: string): Promise<ChallengeDetailDto> {
|
||||||
|
return api.get<ChallengeDetailDto>(`/challenges/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinChallenge(id: string): Promise<ChallengeProgressDto> {
|
||||||
|
return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/join`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveChallenge(id: string): Promise<boolean> {
|
||||||
|
return api.post<boolean>(`/challenges/${encodeURIComponent(id)}/leave`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportChallengeProgress(id: string, increment?: number): Promise<ChallengeProgressDto> {
|
||||||
|
const body = increment != null ? { increment } : undefined;
|
||||||
|
return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/progress`, body);
|
||||||
|
}
|
||||||
@@ -1,169 +1,364 @@
|
|||||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
|
||||||
|
import {
|
||||||
|
type ChallengeDetailDto,
|
||||||
|
type ChallengeListItemDto,
|
||||||
|
type ChallengeProgressDto,
|
||||||
|
type ChallengeStatus,
|
||||||
|
type RankingItemDto,
|
||||||
|
getChallengeDetail,
|
||||||
|
joinChallenge as joinChallengeApi,
|
||||||
|
leaveChallenge as leaveChallengeApi,
|
||||||
|
listChallenges,
|
||||||
|
reportChallengeProgress as reportChallengeProgressApi,
|
||||||
|
} from '@/services/challengesApi';
|
||||||
import type { RootState } from './index';
|
import type { RootState } from './index';
|
||||||
|
|
||||||
export type ChallengeDefinition = {
|
type AsyncStatus = 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
startDate: string; // YYYY-MM-DD
|
|
||||||
endDate: string; // YYYY-MM-DD
|
|
||||||
participantsCount: number;
|
|
||||||
participantsUnit: string;
|
|
||||||
image: string;
|
|
||||||
avatars: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChallengeViewModel = ChallengeDefinition & {
|
export type ChallengeProgress = ChallengeProgressDto;
|
||||||
dateRange: string;
|
export type RankingItem = RankingItemDto;
|
||||||
participantsLabel: string;
|
export type ChallengeSummary = ChallengeListItemDto;
|
||||||
|
export type ChallengeDetail = ChallengeDetailDto;
|
||||||
|
export type { ChallengeStatus };
|
||||||
|
export type ChallengeEntity = ChallengeSummary & {
|
||||||
|
summary?: string;
|
||||||
|
rankings?: RankingItem[];
|
||||||
|
userRank?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChallengesState = {
|
type ChallengesState = {
|
||||||
entities: Record<string, ChallengeDefinition>;
|
entities: Record<string, ChallengeEntity>;
|
||||||
order: string[];
|
order: string[];
|
||||||
|
listStatus: AsyncStatus;
|
||||||
|
listError?: string;
|
||||||
|
detailStatus: Record<string, AsyncStatus>;
|
||||||
|
detailError: Record<string, string | undefined>;
|
||||||
|
joinStatus: Record<string, AsyncStatus>;
|
||||||
|
joinError: Record<string, string | undefined>;
|
||||||
|
leaveStatus: Record<string, AsyncStatus>;
|
||||||
|
leaveError: Record<string, string | undefined>;
|
||||||
|
progressStatus: Record<string, AsyncStatus>;
|
||||||
|
progressError: Record<string, string | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialChallenges: ChallengeDefinition[] = [
|
|
||||||
{
|
|
||||||
id: 'joyful-dog-run',
|
|
||||||
title: '遛狗跑步,欢乐一路',
|
|
||||||
startDate: '2024-09-01',
|
|
||||||
endDate: '2024-09-30',
|
|
||||||
participantsCount: 6364,
|
|
||||||
participantsUnit: '跑者',
|
|
||||||
image:
|
|
||||||
'https://images.unsplash.com/photo-1525253086316-d0c936c814f8?auto=format&fit=crop&w=1200&q=80',
|
|
||||||
avatars: [
|
|
||||||
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1544723795-3fbce826f51f?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1502823403499-6ccfcf4fb453?auto=format&fit=crop&w=200&q=80',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'penguin-swim',
|
|
||||||
title: '企鹅宝宝的游泳预备班',
|
|
||||||
startDate: '2024-09-01',
|
|
||||||
endDate: '2024-09-30',
|
|
||||||
participantsCount: 3334,
|
|
||||||
participantsUnit: '游泳者',
|
|
||||||
image:
|
|
||||||
'https://images.unsplash.com/photo-1531297484001-80022131f5a1?auto=format&fit=crop&w=1200&q=80',
|
|
||||||
avatars: [
|
|
||||||
'https://images.unsplash.com/photo-1525134479668-1bee5c7c6845?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1530268729831-4b0b9e170218?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1463453091185-61582044d556?auto=format&fit=crop&w=200&q=80',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hydration-hippo',
|
|
||||||
title: '学河马饮,做补水人',
|
|
||||||
startDate: '2024-09-01',
|
|
||||||
endDate: '2024-09-30',
|
|
||||||
participantsCount: 9009,
|
|
||||||
participantsUnit: '饮水者',
|
|
||||||
image:
|
|
||||||
'https://images.unsplash.com/photo-1481931098730-318b6f776db0?auto=format&fit=crop&w=1200&q=80',
|
|
||||||
avatars: [
|
|
||||||
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1544723660-4bfa6584218e?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1544723795-3fbfb7c6a9f1?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=200&q=80',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'autumn-cycling',
|
|
||||||
title: '炎夏渐散,踏板骑秋',
|
|
||||||
startDate: '2024-09-01',
|
|
||||||
endDate: '2024-09-30',
|
|
||||||
participantsCount: 4617,
|
|
||||||
participantsUnit: '骑行者',
|
|
||||||
image:
|
|
||||||
'https://images.unsplash.com/photo-1509395176047-4a66953fd231?auto=format&fit=crop&w=1200&q=80',
|
|
||||||
avatars: [
|
|
||||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'falcon-core',
|
|
||||||
title: '燃卡加练甄秋腰',
|
|
||||||
startDate: '2024-09-01',
|
|
||||||
endDate: '2024-09-30',
|
|
||||||
participantsCount: 11995,
|
|
||||||
participantsUnit: '健身爱好者',
|
|
||||||
image:
|
|
||||||
'https://images.unsplash.com/photo-1494871262121-6adf66e90adf?auto=format&fit=crop&w=1200&q=80',
|
|
||||||
avatars: [
|
|
||||||
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1502685104226-ee32379fefbe?auto=format&fit=crop&w=200&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialState: ChallengesState = {
|
const initialState: ChallengesState = {
|
||||||
entities: initialChallenges.reduce<Record<string, ChallengeDefinition>>((acc, challenge) => {
|
entities: {},
|
||||||
acc[challenge.id] = challenge;
|
order: [],
|
||||||
return acc;
|
listStatus: 'idle',
|
||||||
}, {}),
|
listError: undefined,
|
||||||
order: initialChallenges.map((challenge) => challenge.id),
|
detailStatus: {},
|
||||||
|
detailError: {},
|
||||||
|
joinStatus: {},
|
||||||
|
joinError: {},
|
||||||
|
leaveStatus: {},
|
||||||
|
leaveError: {},
|
||||||
|
progressStatus: {},
|
||||||
|
progressError: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toErrorMessage = (error: unknown): string => {
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return '请求失败,请稍后再试';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchChallenges = createAsyncThunk<ChallengeSummary[], void, { rejectValue: string }>(
|
||||||
|
'challenges/fetchAll',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
return await listChallenges();
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(toErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchChallengeDetail = createAsyncThunk<ChallengeDetail, string, { rejectValue: string }>(
|
||||||
|
'challenges/fetchDetail',
|
||||||
|
async (id, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
return await getChallengeDetail(id);
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(toErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const joinChallenge = createAsyncThunk<{ id: string; progress: ChallengeProgress }, string, { rejectValue: string }>(
|
||||||
|
'challenges/join',
|
||||||
|
async (id, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const progress = await joinChallengeApi(id);
|
||||||
|
return { id, progress };
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(toErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const leaveChallenge = createAsyncThunk<{ id: string }, string, { rejectValue: string }>(
|
||||||
|
'challenges/leave',
|
||||||
|
async (id, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await leaveChallengeApi(id);
|
||||||
|
return { id };
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(toErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const reportChallengeProgress = createAsyncThunk<
|
||||||
|
{ id: string; progress: ChallengeProgress },
|
||||||
|
{ id: string; increment?: number },
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('challenges/reportProgress', async ({ id, increment }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const progress = await reportChallengeProgressApi(id, increment);
|
||||||
|
return { id, progress };
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(toErrorMessage(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const challengesSlice = createSlice({
|
const challengesSlice = createSlice({
|
||||||
name: 'challenges',
|
name: 'challenges',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {},
|
reducers: {},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchChallenges.pending, (state) => {
|
||||||
|
state.listStatus = 'loading';
|
||||||
|
state.listError = undefined;
|
||||||
|
})
|
||||||
|
.addCase(fetchChallenges.fulfilled, (state, action) => {
|
||||||
|
state.listStatus = 'succeeded';
|
||||||
|
state.listError = undefined;
|
||||||
|
const ids = new Set<string>();
|
||||||
|
action.payload.forEach((challenge) => {
|
||||||
|
ids.add(challenge.id);
|
||||||
|
const existing = state.entities[challenge.id];
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, challenge);
|
||||||
|
} else {
|
||||||
|
state.entities[challenge.id] = { ...challenge };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.keys(state.entities).forEach((id) => {
|
||||||
|
if (!ids.has(id)) {
|
||||||
|
delete state.entities[id];
|
||||||
|
delete state.detailStatus[id];
|
||||||
|
delete state.detailError[id];
|
||||||
|
delete state.joinStatus[id];
|
||||||
|
delete state.joinError[id];
|
||||||
|
delete state.leaveStatus[id];
|
||||||
|
delete state.leaveError[id];
|
||||||
|
delete state.progressStatus[id];
|
||||||
|
delete state.progressError[id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.order = action.payload.map((item) => item.id);
|
||||||
|
})
|
||||||
|
.addCase(fetchChallenges.rejected, (state, action) => {
|
||||||
|
state.listStatus = 'failed';
|
||||||
|
state.listError = action.payload ?? toErrorMessage(action.error);
|
||||||
|
})
|
||||||
|
.addCase(fetchChallengeDetail.pending, (state, action) => {
|
||||||
|
const id = action.meta.arg;
|
||||||
|
state.detailStatus[id] = 'loading';
|
||||||
|
state.detailError[id] = undefined;
|
||||||
|
})
|
||||||
|
.addCase(fetchChallengeDetail.fulfilled, (state, action) => {
|
||||||
|
const detail = action.payload;
|
||||||
|
state.detailStatus[detail.id] = 'succeeded';
|
||||||
|
state.detailError[detail.id] = undefined;
|
||||||
|
const existing = state.entities[detail.id];
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, detail);
|
||||||
|
} else {
|
||||||
|
state.entities[detail.id] = { ...detail };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(fetchChallengeDetail.rejected, (state, action) => {
|
||||||
|
const id = action.meta.arg;
|
||||||
|
state.detailStatus[id] = 'failed';
|
||||||
|
state.detailError[id] = action.payload ?? toErrorMessage(action.error);
|
||||||
|
})
|
||||||
|
.addCase(joinChallenge.pending, (state, action) => {
|
||||||
|
const id = action.meta.arg;
|
||||||
|
state.joinStatus[id] = 'loading';
|
||||||
|
state.joinError[id] = undefined;
|
||||||
|
})
|
||||||
|
.addCase(joinChallenge.fulfilled, (state, action) => {
|
||||||
|
const { id, progress } = action.payload;
|
||||||
|
state.joinStatus[id] = 'succeeded';
|
||||||
|
state.joinError[id] = undefined;
|
||||||
|
const entity = state.entities[id];
|
||||||
|
if (entity) {
|
||||||
|
entity.isJoined = true;
|
||||||
|
entity.progress = progress;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(joinChallenge.rejected, (state, action) => {
|
||||||
|
const id = action.meta.arg;
|
||||||
|
state.joinStatus[id] = 'failed';
|
||||||
|
state.joinError[id] = action.payload ?? toErrorMessage(action.error);
|
||||||
|
})
|
||||||
|
.addCase(leaveChallenge.pending, (state, action) => {
|
||||||
|
const id = action.meta.arg;
|
||||||
|
state.leaveStatus[id] = 'loading';
|
||||||
|
state.leaveError[id] = undefined;
|
||||||
|
})
|
||||||
|
.addCase(leaveChallenge.fulfilled, (state, action) => {
|
||||||
|
const { id } = action.payload;
|
||||||
|
state.leaveStatus[id] = 'succeeded';
|
||||||
|
state.leaveError[id] = undefined;
|
||||||
|
const entity = state.entities[id];
|
||||||
|
if (entity) {
|
||||||
|
entity.isJoined = false;
|
||||||
|
delete entity.progress;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(leaveChallenge.rejected, (state, action) => {
|
||||||
|
const id = action.meta.arg;
|
||||||
|
state.leaveStatus[id] = 'failed';
|
||||||
|
state.leaveError[id] = action.payload ?? toErrorMessage(action.error);
|
||||||
|
})
|
||||||
|
.addCase(reportChallengeProgress.pending, (state, action) => {
|
||||||
|
const id = action.meta.arg.id;
|
||||||
|
state.progressStatus[id] = 'loading';
|
||||||
|
state.progressError[id] = undefined;
|
||||||
|
})
|
||||||
|
.addCase(reportChallengeProgress.fulfilled, (state, action) => {
|
||||||
|
const { id, progress } = action.payload;
|
||||||
|
state.progressStatus[id] = 'succeeded';
|
||||||
|
state.progressError[id] = undefined;
|
||||||
|
const entity = state.entities[id];
|
||||||
|
if (entity) {
|
||||||
|
entity.progress = progress;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(reportChallengeProgress.rejected, (state, action) => {
|
||||||
|
const id = action.meta.arg.id;
|
||||||
|
state.progressStatus[id] = 'failed';
|
||||||
|
state.progressError[id] = action.payload ?? toErrorMessage(action.error);
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default challengesSlice.reducer;
|
export default challengesSlice.reducer;
|
||||||
|
|
||||||
const selectChallengesState = (state: RootState) => state.challenges;
|
const selectChallengesState = (state: RootState) => state.challenges;
|
||||||
|
|
||||||
export const selectChallengeEntities = createSelector([selectChallengesState], (state) => state.entities);
|
export const selectChallengesListStatus = createSelector(
|
||||||
|
[selectChallengesState],
|
||||||
|
(state) => state.listStatus
|
||||||
|
);
|
||||||
|
|
||||||
export const selectChallengeOrder = createSelector([selectChallengesState], (state) => state.order);
|
export const selectChallengesListError = createSelector(
|
||||||
|
[selectChallengesState],
|
||||||
|
(state) => state.listError
|
||||||
|
);
|
||||||
|
|
||||||
const formatNumberWithSeparator = (value: number): string => value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
export const selectChallengeEntities = createSelector(
|
||||||
|
[selectChallengesState],
|
||||||
|
(state) => state.entities
|
||||||
|
);
|
||||||
|
|
||||||
const formatDateLabel = (value: string): string => {
|
export const selectChallengeOrder = createSelector(
|
||||||
const [year, month, day] = value.split('-');
|
[selectChallengesState],
|
||||||
if (!month || !day) {
|
(state) => state.order
|
||||||
return value;
|
);
|
||||||
}
|
|
||||||
const monthNumber = parseInt(month, 10);
|
|
||||||
const dayNumber = parseInt(day, 10);
|
|
||||||
const paddedDay = Number.isNaN(dayNumber) ? day : dayNumber.toString().padStart(2, '0');
|
|
||||||
if (Number.isNaN(monthNumber)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return `${monthNumber}月${paddedDay}日`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toViewModel = (challenge: ChallengeDefinition): ChallengeViewModel => ({
|
|
||||||
...challenge,
|
|
||||||
dateRange: `${formatDateLabel(challenge.startDate)} - ${formatDateLabel(challenge.endDate)}`,
|
|
||||||
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} ${challenge.participantsUnit}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const selectChallengeList = createSelector(
|
export const selectChallengeList = createSelector(
|
||||||
[selectChallengeEntities, selectChallengeOrder],
|
[selectChallengeEntities, selectChallengeOrder],
|
||||||
(entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeDefinition[],
|
(entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeEntity[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatNumberWithSeparator = (value: number): string =>
|
||||||
|
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
|
||||||
|
const formatMonthDay = (input: string | undefined): string | undefined => {
|
||||||
|
if (!input) return undefined;
|
||||||
|
const date = new Date(input);
|
||||||
|
if (Number.isNaN(date.getTime())) return undefined;
|
||||||
|
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDateRangeLabel = (challenge: ChallengeEntity): string => {
|
||||||
|
const startLabel = formatMonthDay(challenge.startAt);
|
||||||
|
const endLabel = formatMonthDay(challenge.endAt);
|
||||||
|
if (startLabel && endLabel) {
|
||||||
|
return `${startLabel} - ${endLabel}`;
|
||||||
|
}
|
||||||
|
return challenge.periodLabel ?? challenge.durationLabel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChallengeCardViewModel = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
dateRange: string;
|
||||||
|
participantsLabel: string;
|
||||||
|
status: ChallengeStatus;
|
||||||
|
isJoined: boolean;
|
||||||
|
periodLabel?: string;
|
||||||
|
durationLabel: string;
|
||||||
|
requirementLabel: string;
|
||||||
|
highlightTitle: string;
|
||||||
|
highlightSubtitle: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
progress?: ChallengeProgress;
|
||||||
|
avatars: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export const selectChallengeCards = createSelector([selectChallengeList], (challenges) =>
|
export const selectChallengeCards = createSelector([selectChallengeList], (challenges) =>
|
||||||
challenges.map((challenge) => toViewModel(challenge))
|
challenges.map<ChallengeCardViewModel>((challenge) => ({
|
||||||
|
id: challenge.id,
|
||||||
|
title: challenge.title,
|
||||||
|
image: challenge.image,
|
||||||
|
dateRange: buildDateRangeLabel(challenge),
|
||||||
|
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`,
|
||||||
|
status: challenge.status,
|
||||||
|
isJoined: challenge.isJoined,
|
||||||
|
periodLabel: challenge.periodLabel,
|
||||||
|
durationLabel: challenge.durationLabel,
|
||||||
|
requirementLabel: challenge.requirementLabel,
|
||||||
|
highlightTitle: challenge.highlightTitle,
|
||||||
|
highlightSubtitle: challenge.highlightSubtitle,
|
||||||
|
ctaLabel: challenge.ctaLabel,
|
||||||
|
progress: challenge.progress,
|
||||||
|
avatars: [],
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectChallengeById = (id: string) =>
|
export const selectChallengeById = (id: string) =>
|
||||||
createSelector([selectChallengeEntities], (entities) => entities[id]);
|
createSelector([selectChallengeEntities], (entities) => entities[id]);
|
||||||
|
|
||||||
export const selectChallengeViewById = (id: string) =>
|
export const selectChallengeDetailStatus = (id: string) =>
|
||||||
createSelector([selectChallengeEntities], (entities) => {
|
createSelector([selectChallengesState], (state) => state.detailStatus[id] ?? 'idle');
|
||||||
const challenge = entities[id];
|
|
||||||
return challenge ? toViewModel(challenge) : undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
export const selectChallengeDetailError = (id: string) =>
|
||||||
|
createSelector([selectChallengesState], (state) => state.detailError[id]);
|
||||||
|
|
||||||
|
export const selectJoinStatus = (id: string) =>
|
||||||
|
createSelector([selectChallengesState], (state) => state.joinStatus[id] ?? 'idle');
|
||||||
|
|
||||||
|
export const selectJoinError = (id: string) =>
|
||||||
|
createSelector([selectChallengesState], (state) => state.joinError[id]);
|
||||||
|
|
||||||
|
export const selectLeaveStatus = (id: string) =>
|
||||||
|
createSelector([selectChallengesState], (state) => state.leaveStatus[id] ?? 'idle');
|
||||||
|
|
||||||
|
export const selectLeaveError = (id: string) =>
|
||||||
|
createSelector([selectChallengesState], (state) => state.leaveError[id]);
|
||||||
|
|
||||||
|
export const selectProgressStatus = (id: string) =>
|
||||||
|
createSelector([selectChallengesState], (state) => state.progressStatus[id] ?? 'idle');
|
||||||
|
|
||||||
|
export const selectProgressError = (id: string) =>
|
||||||
|
createSelector([selectChallengesState], (state) => state.progressError[id]);
|
||||||
|
|||||||
Reference in New Issue
Block a user