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]);