diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 255932e..a0f94f4 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -981,16 +981,20 @@ const styles = StyleSheet.create({ fontWeight: 'bold', color: '#2C3E50', marginBottom: 4, + fontFamily: 'AliBold', }, userRole: { fontSize: 14, color: '#9370DB', fontWeight: '500', + fontFamily: 'AliBold', + }, userMemberNumber: { fontSize: 10, color: '#6C757D', marginTop: 4, + fontFamily: 'AliRegular', }, aiUsageContainer: { flexDirection: 'row', @@ -1002,6 +1006,7 @@ const styles = StyleSheet.create({ color: '#9370DB', marginLeft: 2, fontWeight: '500', + fontFamily: 'AliRegular', }, editButton: { backgroundColor: '#9370DB', @@ -1020,6 +1025,7 @@ const styles = StyleSheet.create({ color: 'white', fontSize: 14, fontWeight: '600', + fontFamily: 'AliBold', }, editButtonTextGlass: { color: 'rgba(147, 112, 219, 1)', @@ -1041,11 +1047,13 @@ const styles = StyleSheet.create({ fontWeight: 'bold', color: '#9370DB', marginBottom: 4, + fontFamily: 'AliBold', }, statLabel: { fontSize: 12, color: '#6C757D', fontWeight: '500', + fontFamily: 'AliRegular', }, badgesRowCard: { flexDirection: 'row', @@ -1065,6 +1073,7 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '700', color: '#111827', + fontFamily: 'AliBold', }, badgesRowContent: { flexDirection: 'row', @@ -1103,6 +1112,7 @@ const styles = StyleSheet.create({ fontSize: 18, fontWeight: '600', color: '#475467', + fontFamily: 'AliBold', }, badgeCompactOverlay: { ...StyleSheet.absoluteFillObject, @@ -1122,11 +1132,14 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '700', color: '#5B21B6', + fontFamily: 'AliRegular', }, badgesRowEmpty: { fontSize: 13, color: '#6B7280', fontWeight: '500', + fontFamily: 'AliBold', + }, // 菜单项 menuItem: { @@ -1151,6 +1164,7 @@ const styles = StyleSheet.create({ fontSize: 13, color: '#6C757D', marginRight: 6, + fontFamily: 'AliRegular', }, iconContainer: { width: 32, @@ -1179,6 +1193,7 @@ const styles = StyleSheet.create({ fontWeight: 'bold', color: '#2C3E50', marginLeft: 4, + fontFamily: 'AliBold', }, languageModalOverlay: { flex: 1, @@ -1204,11 +1219,13 @@ const styles = StyleSheet.create({ fontSize: 18, fontWeight: 'bold', color: '#2C3E50', + fontFamily: 'AliBold', }, languageModalSubtitle: { fontSize: 13, color: '#6C757D', marginBottom: 4, + fontFamily: 'AliRegular', }, languageOption: { flexDirection: 'row', @@ -1233,11 +1250,13 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', color: '#2C3E50', + fontFamily: 'AliBold', }, languageOptionDescription: { fontSize: 12, color: '#6C757D', marginTop: 4, + fontFamily: 'AliRegular', }, languageModalClose: { marginTop: 4, @@ -1247,5 +1266,6 @@ const styles = StyleSheet.create({ fontSize: 15, fontWeight: '500', color: '#9370DB', + fontFamily: 'AliBold', }, }); diff --git a/app/challenges/[id]/index.tsx b/app/challenges/[id]/index.tsx index 3a762e5..8e07a3e 100644 --- a/app/challenges/[id]/index.tsx +++ b/app/challenges/[id]/index.tsx @@ -7,11 +7,15 @@ import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { ChallengeSource } from '@/services/challengesApi'; import { + archiveCustomChallengeThunk, fetchChallengeDetail, fetchChallengeRankings, + fetchChallenges, joinChallenge, leaveChallenge, reportChallengeProgress, + selectArchiveError, + selectArchiveStatus, selectChallengeById, selectChallengeDetailError, selectChallengeDetailStatus, @@ -117,6 +121,10 @@ export default function ChallengeDetailScreen() { const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle')); const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]); const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined)); + const archiveStatusSelector = useMemo(() => (id ? selectArchiveStatus(id) : undefined), [id]); + const archiveStatus = useAppSelector((state) => (archiveStatusSelector ? archiveStatusSelector(state) : 'idle')); + const archiveErrorSelector = useMemo(() => (id ? selectArchiveError(id) : undefined), [id]); + const archiveError = useAppSelector((state) => (archiveErrorSelector ? archiveErrorSelector(state) : undefined)); const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]); const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle')); @@ -160,9 +168,13 @@ export default function ChallengeDetailScreen() { }; }, [showCelebration]); + const progress = challenge?.progress; const isJoined = challenge?.isJoined ?? false; const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM; + const isCreator = challenge?.isCreator ?? false; + const isCustomCreator = isCustomChallenge && isCreator; + const canEdit = isCustomChallenge && isCreator; const lastProgressAt = useMemo(() => { const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined; return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at; @@ -275,6 +287,20 @@ export default function ChallengeDetailScreen() { } }; + const handleArchive = async () => { + if (!id || archiveStatus === 'loading') { + return; + } + try { + await dispatch(archiveCustomChallengeThunk(id)).unwrap(); + Toast.success(t('challengeDetail.alert.archiveSuccess')); + await dispatch(fetchChallenges()); + router.back(); + } catch (error) { + Toast.error(t('challengeDetail.alert.archiveFailed')); + } + }; + const handleLeaveConfirm = () => { if (!id || leaveStatus === 'loading') { return; @@ -295,6 +321,26 @@ export default function ChallengeDetailScreen() { ); }; + const handleArchiveConfirm = () => { + if (!id || archiveStatus === 'loading') { + return; + } + Alert.alert( + t('challengeDetail.alert.archiveConfirm.title'), + t('challengeDetail.alert.archiveConfirm.message'), + [ + { text: t('challengeDetail.alert.archiveConfirm.cancel'), style: 'cancel' }, + { + text: t('challengeDetail.alert.archiveConfirm.confirm'), + style: 'destructive', + onPress: () => { + void handleArchive(); + }, + }, + ] + ); + }; + const handleProgressReport = async () => { if (!id || progressStatus === 'loading') { return; @@ -391,6 +437,9 @@ export default function ChallengeDetailScreen() { const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join'); const isUpcoming = challenge.status === 'upcoming'; const isExpired = challenge.status === 'expired'; + const deleteCtaLabel = archiveStatus === 'loading' + ? t('challengeDetail.cta.deleting') + : t('challengeDetail.cta.delete'); const upcomingStartLabel = formatMonthDay(challenge.startAt); const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title'); const upcomingHighlightSubtitle = upcomingStartLabel @@ -420,10 +469,17 @@ export default function ChallengeDetailScreen() { ? `分享码 ${challenge?.shareCode ?? ''}` : leaveHighlightTitle; floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle; - floatingCtaLabel = leaveCtaLabel; - floatingOnPress = handleLeaveConfirm; - floatingDisabled = leaveStatus === 'loading'; - floatingError = leaveError; + if (isCustomCreator) { + floatingCtaLabel = deleteCtaLabel; + floatingOnPress = handleArchiveConfirm; + floatingDisabled = archiveStatus === 'loading'; + floatingError = archiveError; + } else { + floatingCtaLabel = leaveCtaLabel; + floatingOnPress = handleLeaveConfirm; + floatingDisabled = leaveStatus === 'loading'; + floatingError = leaveError; + } } if (isUpcoming) { @@ -573,29 +629,63 @@ export default function ChallengeDetailScreen() { transparent withSafeTop={false} right={ - isLiquidGlassAvailable() ? ( - - + {canEdit && ( + isLiquidGlassAvailable() ? ( + router.push({ + pathname: '/challenges/create-custom', + params: { id, mode: 'edit' } + })} + activeOpacity={0.7} + style={styles.editButton} + > + + + + + ) : ( + router.push({ + pathname: '/challenges/create-custom', + params: { id, mode: 'edit' } + })} + activeOpacity={0.7} + style={[styles.editButton, styles.fallbackEditButton]} + > + + + ) + )} + {isLiquidGlassAvailable() ? ( + + + + + + ) : ( + - - - ) : ( - - - - ) + + )} + } /> @@ -746,52 +836,129 @@ export default function ChallengeDetailScreen() { - - - - {showShareCode ? ( - - - {floatingHighlightTitle} - - - - - {floatingHighlightSubtitle ? ( - {floatingHighlightSubtitle} - ) : null} - {floatingError ? {floatingError} : null} - - ) : ( - - {floatingHighlightTitle} - {floatingHighlightSubtitle} - {floatingError ? {floatingError} : null} - - )} - + {isLiquidGlassAvailable() ? ( + + {/* 顶部高光线条 */} + + + {/* 内部微光渐变 */} - - {floatingCtaLabel} - - - + end={{ x: 0, y: 0.6 }} + style={StyleSheet.absoluteFill} + pointerEvents="none" + /> + + {showShareCode ? ( + + + {floatingHighlightTitle} + + + + + {floatingHighlightSubtitle ? ( + {floatingHighlightSubtitle} + ) : null} + {floatingError ? {floatingError} : null} + + ) : ( + + {floatingHighlightTitle} + {floatingHighlightSubtitle} + {floatingError ? {floatingError} : null} + + )} + + + {/* 按钮内部高光 */} + + + {floatingCtaLabel} + + + + + - + ) : ( + + + {showShareCode ? ( + + + {floatingHighlightTitle} + + + + + {floatingHighlightSubtitle ? ( + {floatingHighlightSubtitle} + ) : null} + {floatingError ? {floatingError} : null} + + ) : ( + + {floatingHighlightTitle} + {floatingHighlightSubtitle} + {floatingError ? {floatingError} : null} + + )} + + + + {floatingCtaLabel} + + + + + + )} {showCelebration && ( @@ -850,13 +1017,47 @@ const styles = StyleSheet.create({ right: 0, bottom: 0, paddingHorizontal: 20, + zIndex: 100, }, floatingCTABlur: { borderRadius: 24, overflow: 'hidden', borderWidth: 1, borderColor: 'rgba(255,255,255,0.6)', - backgroundColor: 'rgba(243, 244, 251, 0.85)', + backgroundColor: 'rgba(243, 244, 251, 0.9)', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 5, + }, + glassWrapper: { + borderRadius: 24, + overflow: 'hidden', + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.3)', + shadowColor: '#5E8BFF', + shadowOffset: { + width: 0, + height: 8, + }, + shadowOpacity: 0.18, + shadowRadius: 20, + elevation: 10, + }, + glassHighlight: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 1, + zIndex: 2, + opacity: 0.9, + }, + glassContainer: { + borderRadius: 24, + overflow: 'hidden', }, floatingCTAContent: { flexDirection: 'row', @@ -890,6 +1091,7 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#596095', letterSpacing: 0.2, + fontFamily: 'AliRegular', }, title: { marginTop: 10, @@ -897,6 +1099,7 @@ const styles = StyleSheet.create({ fontWeight: '800', color: '#1c1f3a', textAlign: 'center', + fontFamily: 'AliBold' }, summary: { marginTop: 12, @@ -904,6 +1107,7 @@ const styles = StyleSheet.create({ lineHeight: 20, color: '#7080b4', textAlign: 'center', + fontFamily: 'AliRegular', }, inlineError: { marginTop: 12, @@ -950,11 +1154,13 @@ const styles = StyleSheet.create({ fontSize: 15, fontWeight: '600', color: '#1c1f3a', + fontFamily: 'AliBold', }, detailMeta: { marginTop: 4, fontSize: 12, color: '#6f7ba7', + fontFamily: 'AliRegular', }, avatarRow: { flexDirection: 'row', @@ -982,6 +1188,7 @@ const styles = StyleSheet.create({ fontSize: 12, color: '#4F5BD5', fontWeight: '600', + fontFamily: 'AliRegular', }, checkInCard: { marginTop: 4, @@ -999,12 +1206,14 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '700', color: '#1c1f3a', + fontFamily: 'AliBold', }, checkInSubtitle: { marginTop: 4, fontSize: 12, color: '#6f7ba7', lineHeight: 18, + fontFamily: 'AliRegular', }, checkInButton: { borderRadius: 18, @@ -1022,6 +1231,7 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: '700', color: '#ffffff', + fontFamily: 'AliBold', }, checkInButtonLabelDisabled: { color: '#6f7799', @@ -1037,11 +1247,13 @@ const styles = StyleSheet.create({ fontSize: 18, fontWeight: '700', color: '#1c1f3a', + fontFamily: 'AliBold', }, sectionAction: { fontSize: 13, fontWeight: '600', color: '#5F6BF0', + fontFamily: 'AliBold', }, sectionSubtitle: { marginTop: 8, @@ -1049,6 +1261,7 @@ const styles = StyleSheet.create({ fontSize: 13, color: '#6f7ba7', lineHeight: 18, + fontFamily: 'AliRegular', }, rankingCard: { marginTop: 20, @@ -1069,17 +1282,20 @@ const styles = StyleSheet.create({ emptyRankingText: { fontSize: 14, color: '#6f7ba7', + fontFamily: 'AliRegular', }, highlightTitle: { fontSize: 16, fontWeight: '700', color: '#1c1f3a', + fontFamily: 'AliBold', }, highlightSubtitle: { marginTop: 4, fontSize: 12, color: '#5f6a97', lineHeight: 18, + fontFamily: 'AliRegular', }, shareCodeIconButton: { paddingHorizontal: 4, @@ -1105,10 +1321,38 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '700', color: '#ffffff', + fontFamily: 'AliBold', + }, highlightButtonLabelDisabled: { color: '#6f7799', }, + headerButtons: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + editButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + editButtonGlass: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + fallbackEditButton: { + backgroundColor: 'rgba(255, 255, 255, 0.24)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.45)', + }, shareButton: { width: 40, height: 40, @@ -1131,6 +1375,7 @@ const styles = StyleSheet.create({ missingText: { fontSize: 16, textAlign: 'center', + fontFamily: 'AliRegular', }, retryButton: { marginTop: 18, @@ -1142,6 +1387,7 @@ const styles = StyleSheet.create({ retryText: { fontSize: 14, fontWeight: '600', + fontFamily: 'AliBold', }, celebrationOverlay: { ...StyleSheet.absoluteFillObject, @@ -1185,6 +1431,7 @@ const styles = StyleSheet.create({ textShadowColor: 'rgba(0, 0, 0, 0.3)', textShadowOffset: { width: 0, height: 2 }, textShadowRadius: 4, + fontFamily: 'AliBold', }, shareCardSummary: { fontSize: 15, @@ -1195,6 +1442,7 @@ const styles = StyleSheet.create({ textShadowColor: 'rgba(0, 0, 0, 0.25)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 3, + fontFamily: 'AliRegular', }, shareProgressContainer: { backgroundColor: 'rgba(255, 255, 255, 0.95)', @@ -1229,11 +1477,13 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '600', color: '#1c1f3a', + fontFamily: 'AliBold', }, shareInfoMeta: { fontSize: 12, color: '#707baf', marginTop: 2, + fontFamily: 'AliRegular', }, shareProgressHeader: { flexDirection: 'row', @@ -1245,11 +1495,13 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '600', color: '#1c1f3a', + fontFamily: 'AliBold', }, shareProgressValue: { fontSize: 18, fontWeight: '800', color: '#5E8BFF', + fontFamily: 'AliBold', }, shareProgressTrack: { height: 8, @@ -1268,6 +1520,7 @@ const styles = StyleSheet.create({ marginTop: 12, textAlign: 'center', fontWeight: '500', + fontFamily: 'AliRegular', }, shareCardFooter: { alignItems: 'center', @@ -1278,5 +1531,6 @@ const styles = StyleSheet.create({ color: '#ffffff', opacity: 0.8, fontWeight: '600', + fontFamily: 'AliBold', }, }); diff --git a/app/challenges/create-custom.tsx b/app/challenges/create-custom.tsx index 28326be..9e093af 100644 --- a/app/challenges/create-custom.tsx +++ b/app/challenges/create-custom.tsx @@ -4,7 +4,7 @@ import * as Clipboard from 'expo-clipboard'; import { Image } from 'expo-image'; import * as ImagePicker from 'expo-image-picker'; import { LinearGradient } from 'expo-linear-gradient'; -import { useRouter } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, @@ -27,22 +27,32 @@ import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useCosUpload } from '@/hooks/useCosUpload'; -import { ChallengeType, type CreateCustomChallengePayload } from '@/services/challengesApi'; +import { useI18n } from '@/hooks/useI18n'; +import { + ChallengeType, + type CreateCustomChallengePayload, + type UpdateCustomChallengePayload, +} from '@/services/challengesApi'; +import { store } from '@/store'; import { createCustomChallengeThunk, fetchChallenges, + selectChallengeById, selectCreateChallengeError, selectCreateChallengeStatus, + selectUpdateChallengeError, + selectUpdateChallengeStatus, + updateCustomChallengeThunk } from '@/store/challengesSlice'; import { Toast } from '@/utils/toast.utils'; -const typeOptions: { value: ChallengeType; label: string; accent: string }[] = [ - { value: ChallengeType.WATER, label: '喝水', accent: '#5E8BFF' }, - { value: ChallengeType.EXERCISE, label: '运动', accent: '#6B6CFF' }, - { value: ChallengeType.DIET, label: '饮食', accent: '#38BDF8' }, - { value: ChallengeType.SLEEP, label: '睡眠', accent: '#7C3AED' }, - { value: ChallengeType.MOOD, label: '心情', accent: '#F97316' }, - { value: ChallengeType.WEIGHT, label: '体重', accent: '#22C55E' }, +const getTypeOptions = (t: (key: string) => string): { value: ChallengeType; label: string; accent: string }[] => [ + { value: ChallengeType.WATER, label: t('challenges.createCustom.typeLabels.water'), accent: '#5E8BFF' }, + { value: ChallengeType.EXERCISE, label: t('challenges.createCustom.typeLabels.exercise'), accent: '#6B6CFF' }, + { value: ChallengeType.DIET, label: t('challenges.createCustom.typeLabels.diet'), accent: '#38BDF8' }, + { value: ChallengeType.SLEEP, label: t('challenges.createCustom.typeLabels.sleep'), accent: '#7C3AED' }, + { value: ChallengeType.MOOD, label: t('challenges.createCustom.typeLabels.mood'), accent: '#F97316' }, + { value: ChallengeType.WEIGHT, label: t('challenges.createCustom.typeLabels.weight'), accent: '#22C55E' }, ]; const FALLBACK_IMAGE = @@ -51,6 +61,9 @@ const FALLBACK_IMAGE = type PickerType = 'start' | 'end' | null; export default function CreateCustomChallengeScreen() { + const { id, mode } = useLocalSearchParams<{ id?: string; mode?: 'edit' }>(); + const isEditMode = mode === 'edit' && !!id; + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const dispatch = useAppDispatch(); @@ -58,7 +71,14 @@ export default function CreateCustomChallengeScreen() { const insets = useSafeAreaInsets(); const createStatus = useAppSelector(selectCreateChallengeStatus); const createError = useAppSelector(selectCreateChallengeError); + const updateError = useAppSelector(selectUpdateChallengeError); + const updateStatus = useAppSelector(selectUpdateChallengeStatus); + const inlineError = isEditMode ? updateError : createError; + const isCreating = createStatus === 'loading'; + const isUpdating = updateStatus === 'loading'; + const { t } = useI18n(); + const typeOptions = useMemo(() => getTypeOptions(t), [t]); const today = useMemo(() => dayjs().startOf('day').toDate(), []); const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []); @@ -74,7 +94,7 @@ export default function CreateCustomChallengeScreen() { const [minimumCheckInDays, setMinimumCheckInDays] = useState(''); const [requirementLabel, setRequirementLabel] = useState(''); const [summary, setSummary] = useState(''); - const [progressUnit] = useState('天'); + const [progressUnit, setProgressUnit] = useState(''); const [periodLabel, setPeriodLabel] = useState(''); const [periodEdited, setPeriodEdited] = useState(false); const [rankingDescription] = useState('连续打卡榜'); @@ -88,6 +108,29 @@ export default function CreateCustomChallengeScreen() { const [pickerType, setPickerType] = useState(null); + // 编辑模式下预填充数据 + useEffect(() => { + if (isEditMode && id) { + const challengeSelector = selectChallengeById(id); + const challenge = challengeSelector(store.getState()); + if (challenge) { + setTitle(challenge.title || ''); + setImage(challenge.image); + setType(challenge.type); + setStartDate(new Date(challenge.startAt || Date.now())); + setEndDate(new Date(challenge.endAt || Date.now())); + setTargetValue(String(challenge.progress?.target || '')); + setMinimumCheckInDays(String(challenge.minimumCheckInDays || '')); + setRequirementLabel(challenge.requirementLabel || ''); + setSummary(challenge.summary || ''); + setProgressUnit(challenge.unit || ''); + setPeriodLabel(challenge.periodLabel || ''); + setIsPublic(challenge.isPublic ?? true); + setMaxParticipants(challenge.maxParticipants?.toString() || '100'); + } + } + }, [isEditMode, id]); + const durationDays = useMemo( () => Math.max( @@ -96,16 +139,16 @@ export default function CreateCustomChallengeScreen() { ), [startDate, endDate] ); - const durationLabel = useMemo(() => `持续${durationDays}天`, [durationDays]); + const durationLabel = useMemo(() => t('challenges.createCustom.durationDays', { days: durationDays }), [durationDays, t]); useEffect(() => { if (!periodEdited) { - setPeriodLabel(`${durationDays}天挑战`); + setPeriodLabel(t('challenges.createCustom.durationDaysChallenge', { days: durationDays })); } if (!minimumEdited) { setMinimumCheckInDays(String(durationDays)); } - }, [durationDays, minimumEdited, periodEdited]); + }, [durationDays, minimumEdited, periodEdited, t]); const handleConfirmDate = (date: Date) => { if (!pickerType) return; @@ -128,47 +171,47 @@ export default function CreateCustomChallengeScreen() { }; const handleSubmit = async () => { - if (isCreating) return; + if (isCreating || isUpdating) return; if (!title.trim()) { - Toast.warning('请填写挑战标题'); + Toast.warning(t('challenges.createCustom.alerts.titleRequired')); return; } - if (!requirementLabel.trim()) { - Toast.warning('请填写挑战要求说明'); + if (!requirementLabel.trim()) { + Toast.warning(t('challenges.createCustom.alerts.requirementRequired')); return; } const startTimestamp = dayjs(startDate).valueOf(); const endTimestamp = dayjs(endDate).valueOf(); if (endTimestamp <= startTimestamp) { - Toast.warning('结束时间需要晚于开始时间'); + Toast.warning(t('challenges.createCustom.alerts.endTimeError')); return; } const target = Number(targetValue); if (!Number.isFinite(target) || target < 1 || target > 1000) { - Toast.warning('每日目标值需在 1-1000 之间'); + Toast.warning(t('challenges.createCustom.alerts.targetValueError')); return; } const minDays = Number(minimumCheckInDays) || durationDays; if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) { - Toast.warning('最少打卡天数需在 1-365 之间'); + Toast.warning(t('challenges.createCustom.alerts.minimumDaysError')); return; } if (minDays > durationDays) { - Toast.warning('最少打卡天数不能超过持续天数'); + Toast.warning(t('challenges.createCustom.alerts.minimumDaysExceedError')); return; } const maxP = maxParticipants ? Number(maxParticipants) : null; if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) { - Toast.warning('参与人数需在 2-10000 之间,或留空表示无限制'); + Toast.warning(t('challenges.createCustom.alerts.participantsError')); return; } - const safeTitle = title.trim() || '自定义挑战'; + const safeTitle = title.trim() || t('challenges.createCustom.defaultTitle'); const payload: CreateCustomChallengePayload = { title: safeTitle, type, @@ -178,24 +221,39 @@ export default function CreateCustomChallengeScreen() { targetValue: target, minimumCheckInDays: minDays, durationLabel, - requirementLabel: requirementLabel.trim() || '请填写挑战要求', + requirementLabel: requirementLabel.trim(), summary: summary.trim() || undefined, - progressUnit: progressUnit.trim() || '天', + progressUnit: progressUnit.trim(), periodLabel: periodLabel.trim() || undefined, rankingDescription: rankingDescription.trim() || undefined, isPublic, maxParticipants: maxP, }; + const updatePayload: UpdateCustomChallengePayload = { + title: safeTitle, + image: image?.trim() || undefined, + summary: summary.trim() || undefined, + isPublic, + maxParticipants: maxP ?? undefined, + }; + try { + if (isEditMode && id) { + await dispatch(updateCustomChallengeThunk({ id, payload: updatePayload })).unwrap(); + Toast.success(t('challenges.createCustom.alerts.updateSuccess')); + dispatch(fetchChallenges()); + return; + } + const created = await dispatch(createCustomChallengeThunk(payload)).unwrap(); setShareCode(created.shareCode ?? null); setCreatedChallengeId(created.id); setShareModalVisible(true); - Toast.success('自定义挑战已创建'); + Toast.success(t('challenges.createCustom.alerts.createSuccess')); dispatch(fetchChallenges()); } catch (error) { - const message = typeof error === 'string' ? error : '创建失败,请稍后再试'; + const message = typeof error === 'string' ? error : t('challenges.createCustom.alerts.createFailed'); Toast.error(message); } }; @@ -203,7 +261,7 @@ export default function CreateCustomChallengeScreen() { const handleCopyShareCode = async () => { if (!shareCode) return; await Clipboard.setStringAsync(shareCode); - Toast.success('邀请码已复制'); + Toast.success(t('challenges.createCustom.shareModal.copyCode')); }; const handleTargetInputChange = (value: string) => { @@ -235,16 +293,16 @@ export default function CreateCustomChallengeScreen() { const handlePickImage = useCallback(() => { Alert.alert( - '选择封面图', - '请选择封面来源', + t('challenges.createCustom.imageUpload.selectSource'), + t('challenges.createCustom.imageUpload.selectMessage'), [ { - text: '拍照', + text: t('challenges.createCustom.imageUpload.camera'), onPress: async () => { try { const permission = await ImagePicker.requestCameraPermissionsAsync(); if (permission.status !== 'granted') { - Alert.alert('权限不足', '需要相机权限以拍摄封面'); + Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.cameraPermissionMessage')); return; } const result = await ImagePicker.launchCameraAsync({ @@ -269,21 +327,21 @@ export default function CreateCustomChallengeScreen() { setImagePreview(null); } catch (error) { console.error('[CHALLENGE] 封面上传失败', error); - Alert.alert('上传失败', '封面上传失败,请稍后重试'); + Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage')); } } catch (error) { console.error('[CHALLENGE] 拍照失败', error); - Alert.alert('拍照失败', '无法打开相机,请稍后再试'); + Alert.alert(t('challenges.createCustom.imageUpload.cameraFailed'), t('challenges.createCustom.imageUpload.cameraFailedMessage')); } }, }, { - text: '从相册选择', + text: t('challenges.createCustom.imageUpload.album'), onPress: async () => { try { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permission.status !== 'granted') { - Alert.alert('权限不足', '需要相册权限以选择封面'); + Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.albumPermissionMessage')); return; } const result = await ImagePicker.launchImageLibraryAsync({ @@ -307,19 +365,19 @@ export default function CreateCustomChallengeScreen() { setImagePreview(null); } catch (error) { console.error('[CHALLENGE] 封面上传失败', error); - Alert.alert('上传失败', '封面上传失败,请稍后重试'); + Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage')); } } catch (error) { console.error('[CHALLENGE] 选择封面失败', error); - Alert.alert('选择失败', '无法打开相册,请稍后再试'); + Alert.alert(t('challenges.createCustom.imageUpload.selectFailed'), t('challenges.createCustom.imageUpload.selectFailedMessage')); } }, }, - { text: '取消', style: 'cancel' }, + { text: t('challenges.createCustom.imageUpload.cancel'), style: 'cancel' }, ], { cancelable: true } ); - }, [upload]); + }, [upload, t]); const handleViewChallenge = () => { setShareModalVisible(false); @@ -370,7 +428,7 @@ export default function CreateCustomChallengeScreen() { ); - const progressMeta = `${durationDays} 天 · ${progressUnit || '天'}`; + const progressMeta = `${durationDays} ${t('challenges.createCustom.dayUnit')}${progressUnit ? ` · ${progressUnit}` : ''}`; const heroImageSource = imagePreview || image || FALLBACK_IMAGE; return ( @@ -379,7 +437,7 @@ export default function CreateCustomChallengeScreen() { colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]} style={StyleSheet.absoluteFillObject} /> - + - 自定义挑战 - {title || '你的专属挑战'} + {t('challenges.customChallenges')} + {title || t('challenges.createCustom.yourChallenge')} {progressMeta} - 基础信息 - {createError ? {createError} : null} + {t('challenges.createCustom.basicInfo')} + {inlineError ? {inlineError} : null} - {renderField('标题', title, setTitle, '挑战标题(最多100字)')} + {renderField(t('challenges.createCustom.fields.title'), title, setTitle, t('challenges.createCustom.fields.titlePlaceholder'))} - 封面图 + {t('challenges.createCustom.fields.coverImage')} - {uploading ? '上传中…' : '上传封面'} + {uploading ? t('challenges.createCustom.imageUpload.uploading') : t('challenges.createCustom.fields.uploadCover')} {image || imagePreview ? ( - 清除 + {t('challenges.createCustom.imageUpload.clear')} ) : null} - 建议比例 16:9,清晰展示挑战氛围 + {t('challenges.createCustom.imageUpload.helper')} - {renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')} + {renderTextarea(t('challenges.createCustom.fields.challengeDescription'), summary, setSummary, t('challenges.createCustom.fields.descriptionPlaceholder'))} - 挑战设置 + {t('challenges.createCustom.challengeSettings')} - 挑战类型 + {t('challenges.createCustom.fields.challengeType')} {typeOptions.map((option) => { const active = option.value === type; @@ -476,14 +534,14 @@ export default function CreateCustomChallengeScreen() { - 时间范围 + {t('challenges.createCustom.fields.timeRange')} setPickerType('start')} > - 开始 + {t('challenges.createCustom.fields.start')} {dayjs(startDate).format('YYYY.MM.DD')} setPickerType('end')} > - 结束 + {t('challenges.createCustom.fields.end')} {dayjs(endDate).format('YYYY.MM.DD')} @@ -499,48 +557,60 @@ export default function CreateCustomChallengeScreen() { - 持续时间 + {t('challenges.createCustom.fields.duration')} {durationLabel} - {renderField('周期标签', periodLabel, (v) => { + {renderField(t('challenges.createCustom.fields.periodLabel'), periodLabel, (v) => { setPeriodEdited(true); setPeriodLabel(v); - }, '如:21天挑战')} + }, t('challenges.createCustom.fields.periodLabelPlaceholder'))} - - {renderField('每日目标值', targetValue, handleTargetInputChange, '如:8', 'numeric')} - - 进度单位 - - {progressUnit} - + + {t('challenges.createCustom.fields.dailyTargetAndUnit')} + + + + {t('challenges.createCustom.fields.unitHelper')} - {renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')} + {renderField(t('challenges.createCustom.fields.minimumCheckInDays'), minimumCheckInDays, handleMinimumDaysChange, t('challenges.createCustom.fields.minimumCheckInDaysPlaceholder'), 'numeric')} - {renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')} + {renderField(t('challenges.createCustom.fields.challengeRequirement'), requirementLabel, setRequirementLabel, t('challenges.createCustom.fields.requirementPlaceholder'))} - 展示&互动 + {t('challenges.createCustom.displayInteraction')} - {renderField('参与人数上限', maxParticipants, (v) => { + {renderField(t('challenges.createCustom.fields.maxParticipants'), maxParticipants, (v) => { const digits = v.replace(/\D/g, ''); if (!digits) { setMaxParticipants(''); return; } setMaxParticipants(String(parseInt(digits, 10))); - }, '留空表示无限制', 'numeric')} + }, t('challenges.createCustom.fields.noLimit'), 'numeric')} - 是否公开 - 公开后其他用户可通过邀请码加入 + {t('challenges.createCustom.fields.isPublic')} + {t('challenges.createCustom.fields.publicDescription')} - 生成自定义挑战 - 自动创建分享码,邀请好友一起挑战 + + {isEditMode + ? t('challenges.createCustom.floatingCTA.editTitle') + : t('challenges.createCustom.floatingCTA.title') + } + + + {isEditMode + ? t('challenges.createCustom.floatingCTA.editSubtitle') + : t('challenges.createCustom.floatingCTA.subtitle') + } + - {isCreating ? '创建中…' : '创建并生成邀请码'} + {isCreating + ? t('challenges.createCustom.buttons.creating') + : isUpdating + ? t('challenges.createCustom.buttons.updating') + : isEditMode + ? t('challenges.createCustom.buttons.updateAndSave') + : t('challenges.createCustom.buttons.createAndGenerateCode') + } @@ -598,10 +685,10 @@ export default function CreateCustomChallengeScreen() { > - 邀请码已生成 - 分享给好友即可加入挑战 + {t('challenges.createCustom.shareModal.title')} + {t('challenges.createCustom.shareModal.subtitle')} - {shareCode ?? '获取中…'} + {shareCode ?? t('challenges.createCustom.shareModal.generatingCode')} - 复制邀请码 + {t('challenges.createCustom.shareModal.copyCode')} - 查看挑战 + {t('challenges.createCustom.shareModal.viewChallenge')} @@ -632,7 +719,7 @@ export default function CreateCustomChallengeScreen() { activeOpacity={0.8} onPress={() => setShareModalVisible(false)} > - 稍后再说 + {t('challenges.createCustom.shareModal.later')} @@ -720,6 +807,16 @@ const styles = StyleSheet.create({ fontSize: 15, color: '#111827', }, + targetUnitRow: { + flexDirection: 'row', + gap: 12, + }, + targetInput: { + flex: 1, + }, + unitInput: { + flex: 1, + }, textarea: { minHeight: 90, }, diff --git a/i18n/index.ts b/i18n/index.ts index 2af1178..51a13ed 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -903,6 +903,8 @@ const challengeDetailResources = { joining: '加入中…', leave: '退出挑战', leaving: '退出中…', + delete: '删除挑战', + deleting: '删除中…', upcoming: '挑战即将开始', expired: '挑战已结束', }, @@ -935,6 +937,14 @@ const challengeDetailResources = { }, joinFailed: '加入挑战失败', leaveFailed: '退出挑战失败', + archiveConfirm: { + title: '确认删除该挑战?', + message: '删除后将无法恢复,参与者也将无法再访问此挑战。', + cancel: '取消', + confirm: '删除挑战', + }, + archiveFailed: '删除挑战失败', + archiveSuccess: '挑战已删除', }, ranking: { title: '排行榜', @@ -1011,6 +1021,8 @@ const challengeDetailResourcesEn = { joining: 'Joining…', leave: 'Leave Challenge', leaving: 'Leaving…', + delete: 'Delete Challenge', + deleting: 'Deleting…', upcoming: 'Starting Soon', expired: 'Challenge Ended', }, @@ -1043,6 +1055,14 @@ const challengeDetailResourcesEn = { }, joinFailed: 'Failed to join challenge', leaveFailed: 'Failed to leave challenge', + archiveConfirm: { + title: 'Delete this challenge?', + message: 'This cannot be undone and participants will lose access.', + cancel: 'Cancel', + confirm: 'Delete Challenge', + }, + archiveFailed: 'Failed to delete challenge', + archiveSuccess: 'Challenge deleted', }, ranking: { title: 'Leaderboard', @@ -1172,6 +1192,103 @@ const resources = { ongoing: '进行中', expired: '已结束', }, + createCustom: { + title: '新建挑战', + editTitle: '编辑挑战', + yourChallenge: '你的专属挑战', + defaultTitle: '自定义挑战', + basicInfo: '基础信息', + challengeSettings: '挑战设置', + displayInteraction: '展示&互动', + durationDays: '持续{{days}}天', + durationDaysChallenge: '{{days}}天挑战', + dayUnit: '天', + typeLabels: { + water: '喝水', + exercise: '运动', + diet: '饮食', + sleep: '睡眠', + mood: '心情', + weight: '体重', + }, + fields: { + title: '标题', + titlePlaceholder: '挑战标题(最多100字)', + coverImage: '封面图', + uploadCover: '上传封面', + challengeDescription: '挑战说明', + descriptionPlaceholder: '简单介绍这个挑战的目标与要求', + challengeType: '挑战类型', + timeRange: '时间范围', + start: '开始', + end: '结束', + duration: '持续时间', + periodLabel: '周期标签', + periodLabelPlaceholder: '如:21天挑战', + dailyTargetAndUnit: '每日目标值与进度单位', + dailyTargetPlaceholder: '如:8', + unitPlaceholder: '单位', + unitHelper: '进度单位表示每日目标的计量单位,如:次、杯、分钟、页等', + minimumCheckInDays: '最少打卡天数', + minimumCheckInDaysPlaceholder: '至少1天', + challengeRequirement: '挑战要求说明', + requirementPlaceholder: '例如:每日完成 30 分钟运动', + maxParticipants: '参与人数上限', + noLimit: '留空表示无限制', + isPublic: '是否公开', + publicDescription: '公开后其他用户可通过邀请码加入', + }, + buttons: { + createAndGenerateCode: '创建并生成邀请码', + updateAndSave: '保存修改', + creating: '创建中…', + updating: '更新中…', + }, + floatingCTA: { + title: '生成自定义挑战', + editTitle: '编辑自定义挑战', + subtitle: '自动创建分享码,邀请好友一起挑战', + editSubtitle: '修改挑战信息,编辑后保存', + }, + shareModal: { + title: '邀请码已生成', + subtitle: '分享给好友即可加入挑战', + copyCode: '复制邀请码', + viewChallenge: '查看挑战', + later: '稍后再说', + generatingCode: '获取中…', + }, + alerts: { + titleRequired: '请填写挑战标题', + requirementRequired: '请填写挑战要求说明', + endTimeError: '结束时间需要晚于开始时间', + targetValueError: '每日目标值需在 1-1000 之间', + minimumDaysError: '最少打卡天数需在 1-365 之间', + minimumDaysExceedError: '最少打卡天数不能超过持续天数', + participantsError: '参与人数需在 2-10000 之间,或留空表示无限制', + createSuccess: '自定义挑战已创建', + updateSuccess: '挑战已更新', + createFailed: '创建失败,请稍后再试', + }, + imageUpload: { + uploading: '上传中…', + clear: '清除', + helper: '建议比例 16:9,清晰展示挑战氛围', + selectSource: '选择封面图', + camera: '拍照', + album: '从相册选择', + cancel: '取消', + cameraPermission: '权限不足', + cameraPermissionMessage: '需要相机权限以拍摄封面', + albumPermissionMessage: '需要相册权限以选择封面', + uploadFailed: '上传失败', + uploadFailedMessage: '封面上传失败,请稍后重试', + cameraFailed: '拍照失败', + cameraFailedMessage: '无法打开相机,请稍后再试', + selectFailed: '选择失败', + selectFailedMessage: '无法打开相册,请稍后再试', + }, + }, }, }, }, @@ -2030,6 +2147,103 @@ const resources = { ongoing: 'Ongoing', expired: 'Expired', }, + createCustom: { + title: 'New Challenge', + editTitle: 'Edit Challenge', + yourChallenge: 'Your Custom Challenge', + defaultTitle: 'Custom Challenge', + basicInfo: 'Basic Info', + challengeSettings: 'Challenge Settings', + displayInteraction: 'Display & Interaction', + durationDays: 'Duration {{days}} days', + durationDaysChallenge: '{{days}}-Day Challenge', + dayUnit: 'day', + typeLabels: { + water: 'Water', + exercise: 'Exercise', + diet: 'Diet', + sleep: 'Sleep', + mood: 'Mood', + weight: 'Weight', + }, + fields: { + title: 'Title', + titlePlaceholder: 'Challenge title (max 100 characters)', + coverImage: 'Cover Image', + uploadCover: 'Upload Cover', + challengeDescription: 'Challenge Description', + descriptionPlaceholder: 'Brief introduction to the challenge goals and requirements', + challengeType: 'Challenge Type', + timeRange: 'Time Range', + start: 'Start', + end: 'End', + duration: 'Duration', + periodLabel: 'Period Label', + periodLabelPlaceholder: 'e.g., 21-Day Challenge', + dailyTargetAndUnit: 'Daily Target & Unit', + dailyTargetPlaceholder: 'e.g., 8', + unitPlaceholder: 'Unit', + unitHelper: 'Progress unit represents the measurement unit for daily goals, such as: times, cups, minutes, pages, etc.', + minimumCheckInDays: 'Minimum Check-in Days', + minimumCheckInDaysPlaceholder: 'At least 1 day', + challengeRequirement: 'Challenge Requirement', + requirementPlaceholder: 'e.g., Complete 30 minutes of exercise daily', + maxParticipants: 'Max Participants', + noLimit: 'Leave empty for unlimited', + isPublic: 'Public', + publicDescription: 'Others can join via invite code when public', + }, + buttons: { + createAndGenerateCode: 'Create & Generate Code', + updateAndSave: 'Save Changes', + creating: 'Creating…', + updating: 'Updating…', + }, + floatingCTA: { + title: 'Generate Custom Challenge', + editTitle: 'Edit Custom Challenge', + subtitle: 'Automatically create share code to invite friends', + editSubtitle: 'Modify challenge information, save after editing', + }, + shareModal: { + title: 'Invite Code Generated', + subtitle: 'Share with friends to join the challenge', + copyCode: 'Copy Invite Code', + viewChallenge: 'View Challenge', + later: 'Later', + generatingCode: 'Generating…', + }, + alerts: { + titleRequired: 'Please enter challenge title', + requirementRequired: 'Please enter challenge requirement description', + endTimeError: 'End time must be later than start time', + targetValueError: 'Daily target value must be between 1-1000', + minimumDaysError: 'Minimum check-in days must be between 1-365', + minimumDaysExceedError: 'Minimum check-in days cannot exceed duration days', + participantsError: 'Participants must be between 2-10000, or leave empty for unlimited', + createSuccess: 'Custom challenge created', + updateSuccess: 'Challenge updated', + createFailed: 'Creation failed, please try again later', + }, + imageUpload: { + uploading: 'Uploading…', + clear: 'Clear', + helper: 'Recommended ratio 16:9, clearly display challenge atmosphere', + selectSource: 'Select Cover Image', + selectMessage: 'Please select image source', + camera: 'Camera', + album: 'From Album', + cancel: 'Cancel', + cameraPermission: 'Permission Denied', + cameraPermissionMessage: 'Camera permission is required to take cover photo', + uploadFailed: 'Upload Failed', + uploadFailedMessage: 'Cover upload failed, please try again later', + cameraFailed: 'Camera Failed', + cameraFailedMessage: 'Unable to open camera, please try again later', + selectFailed: 'Selection Failed', + selectFailedMessage: 'Unable to open album, please try again later', + }, + }, }, }, }, diff --git a/services/challengesApi.ts b/services/challengesApi.ts index 8e48098..36d3311 100644 --- a/services/challengesApi.ts +++ b/services/challengesApi.ts @@ -70,8 +70,6 @@ export type ChallengeListItemDto = { isPublic?: boolean; maxParticipants?: number | null; challengeState?: ChallengeState; - progressUnit?: string; - targetValue?: number; summary?: string | null; }; @@ -114,6 +112,17 @@ export type CreateCustomChallengePayload = { maxParticipants?: number | null; }; +export type UpdateCustomChallengePayload = { + title?: string; + image?: string; + summary?: string; + isPublic?: boolean; + maxParticipants?: number; + highlightTitle?: string; + highlightSubtitle?: string; + ctaLabel?: string; +}; + export async function listChallenges(): Promise { return api.get('/challenges'); } @@ -190,3 +199,14 @@ export async function regenerateChallengeShareCode( `/challenges/custom/${encodeURIComponent(id)}/regenerate-code` ); } + +export async function updateCustomChallenge( + id: string, + payload: UpdateCustomChallengePayload +): Promise { + return api.put(`/challenges/custom/${encodeURIComponent(id)}`, payload); +} + +export async function archiveCustomChallenge(id: string): Promise { + return api.delete(`/challenges/custom/${encodeURIComponent(id)}`); +} diff --git a/store/challengesSlice.ts b/store/challengesSlice.ts index f7a24a4..6ab56da 100644 --- a/store/challengesSlice.ts +++ b/store/challengesSlice.ts @@ -10,6 +10,8 @@ import { type ChallengeStatus, type CreateCustomChallengePayload, type RankingItemDto, + type UpdateCustomChallengePayload, + archiveCustomChallenge, createCustomChallenge, getChallengeByShareCode, getChallengeDetail, @@ -19,6 +21,7 @@ import { leaveChallenge as leaveChallengeApi, listChallenges, reportChallengeProgress as reportChallengeProgressApi, + updateCustomChallenge, } from '@/services/challengesApi'; import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit'; import type { RootState } from './index'; @@ -63,6 +66,10 @@ type ChallengesState = { rankingError: Record; createStatus: AsyncStatus; createError?: string; + updateStatus: AsyncStatus; + updateError?: string; + archiveStatus: Record; + archiveError: Record; joinByCodeStatus: AsyncStatus; joinByCodeError?: string; }; @@ -86,6 +93,10 @@ const initialState: ChallengesState = { rankingError: {}, createStatus: 'idle', createError: undefined, + updateStatus: 'idle', + updateError: undefined, + archiveStatus: {}, + archiveError: {}, joinByCodeStatus: 'idle', joinByCodeError: undefined, }; @@ -210,6 +221,31 @@ export const joinChallengeByCode = createAsyncThunk< } }); +export const updateCustomChallengeThunk = createAsyncThunk< + ChallengeDetail, + { id: string; payload: UpdateCustomChallengePayload }, + { rejectValue: string } +>('challenges/updateCustom', async ({ id, payload }, { rejectWithValue }) => { + try { + return await updateCustomChallenge(id, payload); + } catch (error) { + return rejectWithValue(toErrorMessage(error)); + } +}); + +export const archiveCustomChallengeThunk = createAsyncThunk< + { id: string }, + string, + { rejectValue: string } +>('challenges/archiveCustom', async (id, { rejectWithValue }) => { + try { + await archiveCustomChallenge(id); + return { id }; + } catch (error) { + return rejectWithValue(toErrorMessage(error)); + } +}); + const challengesSlice = createSlice({ name: 'challenges', initialState, @@ -394,6 +430,55 @@ const challengesSlice = createSlice({ state.createError = action.payload ?? toErrorMessage(action.error); }); + builder + .addCase(updateCustomChallengeThunk.pending, (state) => { + state.updateStatus = 'loading'; + state.updateError = undefined; + }) + .addCase(updateCustomChallengeThunk.fulfilled, (state, action) => { + state.updateStatus = 'succeeded'; + state.updateError = undefined; + const challenge = action.payload; + const existing = state.entities[challenge.id]; + const source = ChallengeSource.CUSTOM; + state.entities[challenge.id] = { ...(existing ?? {}), ...challenge, source }; + }) + .addCase(updateCustomChallengeThunk.rejected, (state, action) => { + state.updateStatus = 'failed'; + state.updateError = action.payload ?? toErrorMessage(action.error); + }); + + builder + .addCase(archiveCustomChallengeThunk.pending, (state, action) => { + const id = action.meta.arg; + state.archiveStatus[id] = 'loading'; + state.archiveError[id] = undefined; + }) + .addCase(archiveCustomChallengeThunk.fulfilled, (state, action) => { + const { id } = action.payload; + state.archiveStatus[id] = 'succeeded'; + state.archiveError[id] = undefined; + delete state.entities[id]; + state.orderedIds = state.orderedIds.filter((itemId) => itemId !== 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]; + delete state.rankingList[id]; + delete state.rankingStatus[id]; + delete state.rankingLoadMoreStatus[id]; + delete state.rankingError[id]; + }) + .addCase(archiveCustomChallengeThunk.rejected, (state, action) => { + const id = action.meta.arg; + state.archiveStatus[id] = 'failed'; + state.archiveError[id] = action.payload ?? toErrorMessage(action.error); + }); + builder .addCase(joinChallengeByCode.pending, (state) => { state.joinByCodeStatus = 'loading'; @@ -545,8 +630,8 @@ export const selectChallengeCards = createSelector([selectChallengeList], (chall source: challenge.source, shareCode: challenge.shareCode ?? null, challengeState: challenge.challengeState, - progressUnit: challenge.progressUnit, - targetValue: challenge.targetValue, + progressUnit: challenge.unit, + targetValue: challenge.progress?.target, isCreator: challenge.isCreator, }; }) @@ -578,8 +663,8 @@ export const selectCustomChallengeCards = createSelector( source: challenge.source ?? ChallengeSource.CUSTOM, shareCode: challenge.shareCode ?? null, challengeState: challenge.challengeState, - progressUnit: challenge.progressUnit, - targetValue: challenge.targetValue, + progressUnit: challenge.unit, + targetValue: challenge.progress?.target, isCreator: challenge.isCreator, }; }) @@ -611,8 +696,8 @@ export const selectOfficialChallengeCards = createSelector( source: challenge.source ?? ChallengeSource.SYSTEM, shareCode: challenge.shareCode ?? null, challengeState: challenge.challengeState, - progressUnit: challenge.progressUnit, - targetValue: challenge.targetValue, + progressUnit: challenge.unit, + targetValue: challenge.progress?.target, isCreator: challenge.isCreator, }; }) @@ -676,3 +761,19 @@ export const selectJoinByCodeError = createSelector( [selectChallengesState], (state) => state.joinByCodeError ); + +export const selectUpdateChallengeStatus = createSelector( + [selectChallengesState], + (state) => state.updateStatus +); + +export const selectUpdateChallengeError = createSelector( + [selectChallengesState], + (state) => state.updateError +); + +export const selectArchiveStatus = (id: string) => + createSelector([selectChallengesState], (state) => state.archiveStatus[id] ?? 'idle'); + +export const selectArchiveError = (id: string) => + createSelector([selectChallengesState], (state) => state.archiveError[id]);