- 新增自定义挑战的编辑模式,支持修改挑战信息 - 在详情页为创建者添加删除(归档)挑战的功能入口 - 全面完善挑战创建页面的国际化(i18n)文案适配 - 优化个人中心页面的字体样式,统一使用 AliBold/Regular - 更新 Store 逻辑以处理挑战更新、删除及列表数据映射调整
780 lines
27 KiB
TypeScript
780 lines
27 KiB
TypeScript
import dayjs from 'dayjs';
|
|
|
|
import { appStoreReviewService } from '@/services/appStoreReview';
|
|
import {
|
|
type ChallengeDetailDto,
|
|
type ChallengeListItemDto,
|
|
type ChallengeProgressDto,
|
|
ChallengeSource,
|
|
ChallengeState,
|
|
type ChallengeStatus,
|
|
type CreateCustomChallengePayload,
|
|
type RankingItemDto,
|
|
type UpdateCustomChallengePayload,
|
|
archiveCustomChallenge,
|
|
createCustomChallenge,
|
|
getChallengeByShareCode,
|
|
getChallengeDetail,
|
|
getChallengeRankings,
|
|
joinChallenge as joinChallengeApi,
|
|
joinChallengeByCode as joinChallengeByCodeApi,
|
|
leaveChallenge as leaveChallengeApi,
|
|
listChallenges,
|
|
reportChallengeProgress as reportChallengeProgressApi,
|
|
updateCustomChallenge,
|
|
} from '@/services/challengesApi';
|
|
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
|
|
import type { RootState } from './index';
|
|
|
|
type AsyncStatus = 'idle' | 'loading' | 'succeeded' | 'failed';
|
|
|
|
export type ChallengeProgress = ChallengeProgressDto;
|
|
export type RankingItem = RankingItemDto;
|
|
export type ChallengeSummary = ChallengeListItemDto;
|
|
export type ChallengeDetail = ChallengeDetailDto;
|
|
export type { ChallengeSource, ChallengeState, ChallengeStatus };
|
|
export type ChallengeEntity = ChallengeSummary & {
|
|
summary?: string | null;
|
|
rankings?: RankingItem[];
|
|
userRank?: number;
|
|
};
|
|
|
|
type ChallengeRankingList = {
|
|
items: RankingItem[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type ChallengesState = {
|
|
entities: Record<string, ChallengeEntity>;
|
|
orderedIds: string[];
|
|
listStatus: AsyncStatus;
|
|
listError?: string;
|
|
detailStatus: Record<string, AsyncStatus>;
|
|
detailError: Record<string, string | undefined>;
|
|
joinStatus: Record<string, AsyncStatus>;
|
|
joinError: Record<string, string | undefined>;
|
|
leaveStatus: Record<string, AsyncStatus>;
|
|
leaveError: Record<string, string | undefined>;
|
|
progressStatus: Record<string, AsyncStatus>;
|
|
progressError: Record<string, string | undefined>;
|
|
rankingList: Record<string, ChallengeRankingList | undefined>;
|
|
rankingStatus: Record<string, AsyncStatus>;
|
|
rankingLoadMoreStatus: Record<string, AsyncStatus>;
|
|
rankingError: Record<string, string | undefined>;
|
|
createStatus: AsyncStatus;
|
|
createError?: string;
|
|
updateStatus: AsyncStatus;
|
|
updateError?: string;
|
|
archiveStatus: Record<string, AsyncStatus>;
|
|
archiveError: Record<string, string | undefined>;
|
|
joinByCodeStatus: AsyncStatus;
|
|
joinByCodeError?: string;
|
|
};
|
|
|
|
const initialState: ChallengesState = {
|
|
entities: {},
|
|
orderedIds: [],
|
|
listStatus: 'idle',
|
|
listError: undefined,
|
|
detailStatus: {},
|
|
detailError: {},
|
|
joinStatus: {},
|
|
joinError: {},
|
|
leaveStatus: {},
|
|
leaveError: {},
|
|
progressStatus: {},
|
|
progressError: {},
|
|
rankingList: {},
|
|
rankingStatus: {},
|
|
rankingLoadMoreStatus: {},
|
|
rankingError: {},
|
|
createStatus: 'idle',
|
|
createError: undefined,
|
|
updateStatus: 'idle',
|
|
updateError: undefined,
|
|
archiveStatus: {},
|
|
archiveError: {},
|
|
joinByCodeStatus: 'idle',
|
|
joinByCodeError: undefined,
|
|
};
|
|
|
|
const toErrorMessage = (error: unknown): string => {
|
|
if (typeof error === 'string') {
|
|
return error;
|
|
}
|
|
if (error instanceof Error && error.message) {
|
|
return error.message;
|
|
}
|
|
return '请求失败,请稍后再试';
|
|
};
|
|
|
|
export const fetchChallenges = createAsyncThunk<ChallengeSummary[], void, { rejectValue: string }>(
|
|
'challenges/fetchAll',
|
|
async (_, { rejectWithValue }) => {
|
|
try {
|
|
return await listChallenges();
|
|
} catch (error) {
|
|
return rejectWithValue(toErrorMessage(error));
|
|
}
|
|
}
|
|
);
|
|
|
|
export const fetchChallengeDetail = createAsyncThunk<ChallengeDetail, string, { rejectValue: string }>(
|
|
'challenges/fetchDetail',
|
|
async (id, { rejectWithValue }) => {
|
|
try {
|
|
const ret = await getChallengeDetail(id);
|
|
|
|
return ret;
|
|
} catch (error) {
|
|
console.log('######', error);
|
|
return rejectWithValue(toErrorMessage(error));
|
|
}
|
|
}
|
|
);
|
|
|
|
export const joinChallenge = createAsyncThunk<{ id: string; progress: ChallengeProgress }, string, { rejectValue: string }>(
|
|
'challenges/join',
|
|
async (id, { rejectWithValue }) => {
|
|
try {
|
|
const progress = await joinChallengeApi(id);
|
|
|
|
// 用户成功加入挑战后,尝试请求应用评分
|
|
// 使用 setTimeout 延迟执行,避免阻塞主流程
|
|
setTimeout(() => {
|
|
appStoreReviewService.requestReview().catch((error) => {
|
|
console.error('应用评分请求失败:', error);
|
|
});
|
|
}, 1000);
|
|
|
|
return { id, progress };
|
|
} catch (error) {
|
|
return rejectWithValue(toErrorMessage(error));
|
|
}
|
|
}
|
|
);
|
|
|
|
export const leaveChallenge = createAsyncThunk<{ id: string }, string, { rejectValue: string }>(
|
|
'challenges/leave',
|
|
async (id, { rejectWithValue }) => {
|
|
try {
|
|
await leaveChallengeApi(id);
|
|
return { id };
|
|
} catch (error) {
|
|
return rejectWithValue(toErrorMessage(error));
|
|
}
|
|
}
|
|
);
|
|
|
|
export const reportChallengeProgress = createAsyncThunk<
|
|
{ id: string; progress: ChallengeProgress },
|
|
{ id: string; value?: number },
|
|
{ rejectValue: string }
|
|
>('challenges/reportProgress', async ({ id, value }, { rejectWithValue }) => {
|
|
try {
|
|
const progress = await reportChallengeProgressApi(id, value);
|
|
return { id, progress };
|
|
} catch (error) {
|
|
return rejectWithValue(toErrorMessage(error));
|
|
}
|
|
});
|
|
|
|
export const fetchChallengeRankings = createAsyncThunk<
|
|
{ id: string; total: number; page: number; pageSize: number; items: RankingItem[] },
|
|
{ id: string; page?: number; pageSize?: number },
|
|
{ rejectValue: string }
|
|
>('challenges/fetchRankings', async ({ id, page = 1, pageSize = 20 }, { rejectWithValue }) => {
|
|
try {
|
|
const data = await getChallengeRankings(id, { page, pageSize });
|
|
return { id, ...data };
|
|
} catch (error) {
|
|
return rejectWithValue(toErrorMessage(error));
|
|
}
|
|
});
|
|
|
|
export const createCustomChallengeThunk = createAsyncThunk<
|
|
ChallengeDetail,
|
|
CreateCustomChallengePayload,
|
|
{ rejectValue: string }
|
|
>('challenges/createCustom', async (payload, { rejectWithValue }) => {
|
|
try {
|
|
return await createCustomChallenge(payload);
|
|
} catch (error) {
|
|
return rejectWithValue(toErrorMessage(error));
|
|
}
|
|
});
|
|
|
|
export const joinChallengeByCode = createAsyncThunk<
|
|
{ challenge: ChallengeDetail; progress: ChallengeProgress },
|
|
string,
|
|
{ rejectValue: string }
|
|
>('challenges/joinByCode', async (shareCode, { rejectWithValue }) => {
|
|
try {
|
|
const progress = await joinChallengeByCodeApi(shareCode);
|
|
const challenge = await getChallengeByShareCode(shareCode);
|
|
return { challenge: { ...challenge, progress }, progress };
|
|
} catch (error) {
|
|
return rejectWithValue(toErrorMessage(error));
|
|
}
|
|
});
|
|
|
|
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,
|
|
reducers: {
|
|
resetJoinByCodeState: (state) => {
|
|
state.joinByCodeStatus = 'idle';
|
|
state.joinByCodeError = undefined;
|
|
},
|
|
},
|
|
extraReducers: (builder) => {
|
|
builder
|
|
.addCase(fetchChallenges.pending, (state) => {
|
|
state.listStatus = 'loading';
|
|
state.listError = undefined;
|
|
})
|
|
.addCase(fetchChallenges.fulfilled, (state, action) => {
|
|
state.listStatus = 'succeeded';
|
|
state.listError = undefined;
|
|
const incomingIds = new Set<string>();
|
|
action.payload.forEach((challenge) => {
|
|
incomingIds.add(challenge.id);
|
|
const source = challenge.source ?? ChallengeSource.SYSTEM;
|
|
const existing = state.entities[challenge.id];
|
|
state.entities[challenge.id] = { ...(existing ?? {}), ...challenge, source };
|
|
});
|
|
Object.keys(state.entities).forEach((id) => {
|
|
if (!incomingIds.has(id) && !state.entities[id]?.isJoined) {
|
|
delete state.entities[id];
|
|
delete state.detailStatus[id];
|
|
delete state.detailError[id];
|
|
delete state.joinStatus[id];
|
|
delete state.joinError[id];
|
|
delete state.leaveStatus[id];
|
|
delete state.leaveError[id];
|
|
delete state.progressStatus[id];
|
|
delete state.progressError[id];
|
|
}
|
|
});
|
|
state.orderedIds = action.payload.map((item) => item.id);
|
|
})
|
|
.addCase(fetchChallenges.rejected, (state, action) => {
|
|
state.listStatus = 'failed';
|
|
state.listError = action.payload ?? toErrorMessage(action.error);
|
|
})
|
|
.addCase(fetchChallengeDetail.pending, (state, action) => {
|
|
const id = action.meta.arg;
|
|
state.detailStatus[id] = 'loading';
|
|
state.detailError[id] = undefined;
|
|
})
|
|
.addCase(fetchChallengeDetail.fulfilled, (state, action) => {
|
|
const detail = action.payload;
|
|
state.detailStatus[detail.id] = 'succeeded';
|
|
state.detailError[detail.id] = undefined;
|
|
const existing = state.entities[detail.id];
|
|
const source = detail.source ?? existing?.source ?? ChallengeSource.SYSTEM;
|
|
state.entities[detail.id] = { ...(existing ?? {}), ...detail, source };
|
|
})
|
|
.addCase(fetchChallengeDetail.rejected, (state, action) => {
|
|
const id = action.meta.arg;
|
|
state.detailStatus[id] = 'failed';
|
|
state.detailError[id] = action.payload ?? toErrorMessage(action.error);
|
|
})
|
|
.addCase(joinChallenge.pending, (state, action) => {
|
|
const id = action.meta.arg;
|
|
state.joinStatus[id] = 'loading';
|
|
state.joinError[id] = undefined;
|
|
})
|
|
.addCase(joinChallenge.fulfilled, (state, action) => {
|
|
const { id, progress } = action.payload;
|
|
state.joinStatus[id] = 'succeeded';
|
|
state.joinError[id] = undefined;
|
|
const entity = state.entities[id];
|
|
if (entity) {
|
|
entity.isJoined = true;
|
|
entity.progress = progress;
|
|
}
|
|
})
|
|
.addCase(joinChallenge.rejected, (state, action) => {
|
|
const id = action.meta.arg;
|
|
state.joinStatus[id] = 'failed';
|
|
state.joinError[id] = action.payload ?? toErrorMessage(action.error);
|
|
})
|
|
.addCase(leaveChallenge.pending, (state, action) => {
|
|
const id = action.meta.arg;
|
|
state.leaveStatus[id] = 'loading';
|
|
state.leaveError[id] = undefined;
|
|
})
|
|
.addCase(leaveChallenge.fulfilled, (state, action) => {
|
|
const { id } = action.payload;
|
|
state.leaveStatus[id] = 'succeeded';
|
|
state.leaveError[id] = undefined;
|
|
const entity = state.entities[id];
|
|
if (entity) {
|
|
entity.isJoined = false;
|
|
delete entity.progress;
|
|
}
|
|
})
|
|
.addCase(leaveChallenge.rejected, (state, action) => {
|
|
const id = action.meta.arg;
|
|
state.leaveStatus[id] = 'failed';
|
|
state.leaveError[id] = action.payload ?? toErrorMessage(action.error);
|
|
})
|
|
.addCase(reportChallengeProgress.pending, (state, action) => {
|
|
const id = action.meta.arg.id;
|
|
state.progressStatus[id] = 'loading';
|
|
state.progressError[id] = undefined;
|
|
})
|
|
.addCase(reportChallengeProgress.fulfilled, (state, action) => {
|
|
const { id, progress } = action.payload;
|
|
state.progressStatus[id] = 'succeeded';
|
|
state.progressError[id] = undefined;
|
|
const entity = state.entities[id];
|
|
if (entity) {
|
|
entity.progress = progress;
|
|
}
|
|
})
|
|
.addCase(reportChallengeProgress.rejected, (state, action) => {
|
|
const id = action.meta.arg.id;
|
|
state.progressStatus[id] = 'failed';
|
|
state.progressError[id] = action.payload ?? toErrorMessage(action.error);
|
|
})
|
|
.addCase(fetchChallengeRankings.pending, (state, action) => {
|
|
const { id, page = 1 } = action.meta.arg;
|
|
if (page <= 1) {
|
|
state.rankingStatus[id] = 'loading';
|
|
state.rankingError[id] = undefined;
|
|
state.rankingLoadMoreStatus[id] = 'idle';
|
|
} else {
|
|
state.rankingLoadMoreStatus[id] = 'loading';
|
|
}
|
|
})
|
|
.addCase(fetchChallengeRankings.fulfilled, (state, action) => {
|
|
const { id, items, page, pageSize, total } = action.payload;
|
|
const existing = state.rankingList[id];
|
|
let merged: RankingItem[];
|
|
if (!existing || page <= 1) {
|
|
merged = [...items];
|
|
} else {
|
|
const map = new Map(existing.items.map((item) => [item.id, item] as const));
|
|
items.forEach((item) => {
|
|
map.set(item.id, item);
|
|
});
|
|
merged = Array.from(map.values());
|
|
}
|
|
const hasMore = merged.length < total;
|
|
state.rankingList[id] = { items: merged, total, page, pageSize, hasMore };
|
|
if (page <= 1) {
|
|
state.rankingStatus[id] = 'succeeded';
|
|
state.rankingError[id] = undefined;
|
|
} else {
|
|
state.rankingLoadMoreStatus[id] = 'succeeded';
|
|
}
|
|
})
|
|
.addCase(fetchChallengeRankings.rejected, (state, action) => {
|
|
const { id, page = 1 } = action.meta.arg;
|
|
const message = action.payload ?? toErrorMessage(action.error);
|
|
if (page <= 1) {
|
|
state.rankingStatus[id] = 'failed';
|
|
state.rankingError[id] = message;
|
|
} else {
|
|
state.rankingLoadMoreStatus[id] = 'failed';
|
|
state.rankingError[id] = message;
|
|
}
|
|
});
|
|
|
|
builder
|
|
.addCase(createCustomChallengeThunk.pending, (state) => {
|
|
state.createStatus = 'loading';
|
|
state.createError = undefined;
|
|
})
|
|
.addCase(createCustomChallengeThunk.fulfilled, (state, action) => {
|
|
state.createStatus = 'succeeded';
|
|
state.createError = undefined;
|
|
const challenge = action.payload;
|
|
const existing = state.entities[challenge.id];
|
|
const source = ChallengeSource.CUSTOM;
|
|
state.entities[challenge.id] = { ...(existing ?? {}), ...challenge, source };
|
|
state.orderedIds = [challenge.id, ...state.orderedIds.filter((id) => id !== challenge.id)];
|
|
})
|
|
.addCase(createCustomChallengeThunk.rejected, (state, action) => {
|
|
state.createStatus = 'failed';
|
|
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';
|
|
state.joinByCodeError = undefined;
|
|
})
|
|
.addCase(joinChallengeByCode.fulfilled, (state, action) => {
|
|
state.joinByCodeStatus = 'succeeded';
|
|
state.joinByCodeError = undefined;
|
|
const { challenge, progress } = action.payload;
|
|
const existing = state.entities[challenge.id];
|
|
const source = challenge.source ?? existing?.source ?? ChallengeSource.SYSTEM;
|
|
const merged = { ...(existing ?? {}), ...challenge, progress, isJoined: true, source };
|
|
state.entities[challenge.id] = merged as ChallengeEntity;
|
|
state.orderedIds = [challenge.id, ...state.orderedIds.filter((id) => id !== challenge.id)];
|
|
})
|
|
.addCase(joinChallengeByCode.rejected, (state, action) => {
|
|
state.joinByCodeStatus = 'failed';
|
|
state.joinByCodeError = action.payload ?? toErrorMessage(action.error);
|
|
});
|
|
},
|
|
});
|
|
|
|
export const { resetJoinByCodeState } = challengesSlice.actions;
|
|
|
|
export default challengesSlice.reducer;
|
|
|
|
const selectChallengesState = (state: RootState) => state.challenges;
|
|
|
|
export const selectChallengesListStatus = createSelector(
|
|
[selectChallengesState],
|
|
(state) => state.listStatus
|
|
);
|
|
|
|
export const selectChallengesListError = createSelector(
|
|
[selectChallengesState],
|
|
(state) => state.listError
|
|
);
|
|
|
|
export const selectChallengeEntities = createSelector(
|
|
[selectChallengesState],
|
|
(state) => state.entities
|
|
);
|
|
|
|
const selectChallengeOrder = createSelector(
|
|
[selectChallengesState],
|
|
(state) => state.orderedIds
|
|
);
|
|
|
|
export const selectChallengeList = createSelector(
|
|
[selectChallengeEntities, selectChallengeOrder],
|
|
(entities, orderedIds) => orderedIds.map((id) => entities[id]).filter(Boolean) as ChallengeEntity[]
|
|
);
|
|
|
|
export const selectCustomChallengeList = createSelector(
|
|
[selectChallengeList],
|
|
(list) => list.filter((challenge) => challenge.source === ChallengeSource.CUSTOM)
|
|
);
|
|
|
|
export const selectOfficialChallengeList = createSelector(
|
|
[selectChallengeList],
|
|
(list) => list.filter((challenge) => challenge.source !== ChallengeSource.CUSTOM)
|
|
);
|
|
|
|
const formatNumberWithSeparator = (value: number): string =>
|
|
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
|
|
const formatMonthDay = (input: string | number | undefined): string | undefined => {
|
|
if (!input) return undefined;
|
|
const date = new Date(input);
|
|
if (Number.isNaN(date.getTime())) return undefined;
|
|
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
|
};
|
|
|
|
const buildDateRangeLabel = (challenge: ChallengeEntity): string => {
|
|
const startLabel = formatMonthDay(challenge.startAt);
|
|
const endLabel = formatMonthDay(challenge.endAt);
|
|
if (startLabel && endLabel) {
|
|
return `${startLabel} - ${endLabel}`;
|
|
}
|
|
return challenge.periodLabel ?? challenge.durationLabel;
|
|
};
|
|
|
|
const deriveStatus = (challenge: ChallengeEntity): ChallengeStatus => {
|
|
if (challenge.status) return challenge.status;
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
return 'expired';
|
|
}
|
|
const now = dayjs();
|
|
const start = challenge.startAt ? dayjs(challenge.startAt) : null;
|
|
const end = challenge.endAt ? dayjs(challenge.endAt) : null;
|
|
if (start?.isValid() && start.isAfter(now)) {
|
|
return 'upcoming';
|
|
}
|
|
if (end?.isValid() && end.isBefore(now)) {
|
|
return 'expired';
|
|
}
|
|
return 'ongoing';
|
|
};
|
|
|
|
const FALLBACK_CHALLENGE_IMAGE =
|
|
'https://images.unsplash.com/photo-1506126613408-eca07ce68773?auto=format&fit=crop&w=1000&q=80';
|
|
|
|
export type ChallengeCardViewModel = {
|
|
id: string;
|
|
title: string;
|
|
image: string;
|
|
dateRange: string;
|
|
participantsLabel: string;
|
|
status: ChallengeStatus;
|
|
isJoined: boolean;
|
|
endAt?: string | number;
|
|
periodLabel?: string;
|
|
durationLabel: string;
|
|
requirementLabel: string;
|
|
highlightTitle: string;
|
|
highlightSubtitle: string;
|
|
ctaLabel: string;
|
|
progress?: ChallengeProgress;
|
|
avatars: string[];
|
|
source?: ChallengeSource;
|
|
shareCode?: string | null;
|
|
challengeState?: ChallengeState;
|
|
progressUnit?: string;
|
|
targetValue?: number;
|
|
isCreator?: boolean;
|
|
};
|
|
|
|
export const selectChallengeCards = createSelector([selectChallengeList], (challenges) =>
|
|
challenges.map<ChallengeCardViewModel>((challenge) => {
|
|
const participants =
|
|
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
|
|
return {
|
|
id: challenge.id,
|
|
title: challenge.title,
|
|
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
|
|
dateRange: buildDateRangeLabel(challenge),
|
|
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
|
|
status: deriveStatus(challenge),
|
|
isJoined: challenge.isJoined,
|
|
endAt: challenge.endAt,
|
|
periodLabel: challenge.periodLabel,
|
|
durationLabel: challenge.durationLabel,
|
|
requirementLabel: challenge.requirementLabel,
|
|
highlightTitle: challenge.highlightTitle,
|
|
highlightSubtitle: challenge.highlightSubtitle,
|
|
ctaLabel: challenge.ctaLabel,
|
|
progress: challenge.progress,
|
|
avatars: [],
|
|
source: challenge.source,
|
|
shareCode: challenge.shareCode ?? null,
|
|
challengeState: challenge.challengeState,
|
|
progressUnit: challenge.unit,
|
|
targetValue: challenge.progress?.target,
|
|
isCreator: challenge.isCreator,
|
|
};
|
|
})
|
|
);
|
|
|
|
export const selectCustomChallengeCards = createSelector(
|
|
[selectCustomChallengeList],
|
|
(challenges) =>
|
|
challenges.map<ChallengeCardViewModel>((challenge) => {
|
|
const participants =
|
|
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
|
|
return {
|
|
id: challenge.id,
|
|
title: challenge.title,
|
|
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
|
|
dateRange: buildDateRangeLabel(challenge),
|
|
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
|
|
status: deriveStatus(challenge),
|
|
isJoined: challenge.isJoined,
|
|
endAt: challenge.endAt,
|
|
periodLabel: challenge.periodLabel,
|
|
durationLabel: challenge.durationLabel,
|
|
requirementLabel: challenge.requirementLabel,
|
|
highlightTitle: challenge.highlightTitle,
|
|
highlightSubtitle: challenge.highlightSubtitle,
|
|
ctaLabel: challenge.ctaLabel,
|
|
progress: challenge.progress,
|
|
avatars: [],
|
|
source: challenge.source ?? ChallengeSource.CUSTOM,
|
|
shareCode: challenge.shareCode ?? null,
|
|
challengeState: challenge.challengeState,
|
|
progressUnit: challenge.unit,
|
|
targetValue: challenge.progress?.target,
|
|
isCreator: challenge.isCreator,
|
|
};
|
|
})
|
|
);
|
|
|
|
export const selectOfficialChallengeCards = createSelector(
|
|
[selectOfficialChallengeList],
|
|
(challenges) =>
|
|
challenges.map<ChallengeCardViewModel>((challenge) => {
|
|
const participants =
|
|
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
|
|
return {
|
|
id: challenge.id,
|
|
title: challenge.title,
|
|
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
|
|
dateRange: buildDateRangeLabel(challenge),
|
|
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
|
|
status: deriveStatus(challenge),
|
|
isJoined: challenge.isJoined,
|
|
endAt: challenge.endAt,
|
|
periodLabel: challenge.periodLabel,
|
|
durationLabel: challenge.durationLabel,
|
|
requirementLabel: challenge.requirementLabel,
|
|
highlightTitle: challenge.highlightTitle,
|
|
highlightSubtitle: challenge.highlightSubtitle,
|
|
ctaLabel: challenge.ctaLabel,
|
|
progress: challenge.progress,
|
|
avatars: [],
|
|
source: challenge.source ?? ChallengeSource.SYSTEM,
|
|
shareCode: challenge.shareCode ?? null,
|
|
challengeState: challenge.challengeState,
|
|
progressUnit: challenge.unit,
|
|
targetValue: challenge.progress?.target,
|
|
isCreator: challenge.isCreator,
|
|
};
|
|
})
|
|
);
|
|
|
|
export const selectChallengeById = (id: string) =>
|
|
createSelector([selectChallengeEntities], (entities) => entities[id]);
|
|
|
|
export const selectChallengeDetailStatus = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.detailStatus[id] ?? 'idle');
|
|
|
|
export const selectChallengeDetailError = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.detailError[id]);
|
|
|
|
export const selectJoinStatus = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.joinStatus[id] ?? 'idle');
|
|
|
|
export const selectJoinError = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.joinError[id]);
|
|
|
|
export const selectLeaveStatus = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.leaveStatus[id] ?? 'idle');
|
|
|
|
export const selectLeaveError = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.leaveError[id]);
|
|
|
|
export const selectProgressStatus = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.progressStatus[id] ?? 'idle');
|
|
|
|
export const selectProgressError = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.progressError[id]);
|
|
|
|
export const selectChallengeRankingList = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.rankingList[id]);
|
|
|
|
export const selectChallengeRankingStatus = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.rankingStatus[id] ?? 'idle');
|
|
|
|
export const selectChallengeRankingLoadMoreStatus = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.rankingLoadMoreStatus[id] ?? 'idle');
|
|
|
|
export const selectChallengeRankingError = (id: string) =>
|
|
createSelector([selectChallengesState], (state) => state.rankingError[id]);
|
|
|
|
export const selectCreateChallengeStatus = createSelector(
|
|
[selectChallengesState],
|
|
(state) => state.createStatus
|
|
);
|
|
|
|
export const selectCreateChallengeError = createSelector(
|
|
[selectChallengesState],
|
|
(state) => state.createError
|
|
);
|
|
|
|
export const selectJoinByCodeStatus = createSelector(
|
|
[selectChallengesState],
|
|
(state) => state.joinByCodeStatus
|
|
);
|
|
|
|
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]);
|