diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index 5a71a81c..dd84234a 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -1,10 +1,14 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { NavigationContainer } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + focusManager, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; import type React from "react"; import { useCallback, useEffect, useState } from "react"; -import { StyleSheet, Text, View } from "react-native"; +import { AppState, StyleSheet, Text, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { initialWindowMetrics, @@ -130,6 +134,14 @@ function App(): React.JSX.Element { bootstrap(); }, []); + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextAppState) => { + focusManager.setFocused(nextAppState === "active"); + }); + + return () => subscription.remove(); + }, []); + // 부트스트랩 동안에는 네이티브 스플래시가 화면을 덮고 있어 사용자에겐 빈 화면이 보이지 않는다. if (!isBootstrapped) { return ; diff --git a/apps/mobile/src/assets/icons/clubInfo/official-verification-complete-indicator.png b/apps/mobile/src/assets/icons/clubInfo/official-verification-complete-indicator.png new file mode 100644 index 00000000..1f6680f8 Binary files /dev/null and b/apps/mobile/src/assets/icons/clubInfo/official-verification-complete-indicator.png differ diff --git a/apps/mobile/src/assets/icons/clubInfo/official-verification-pending-indicator.png b/apps/mobile/src/assets/icons/clubInfo/official-verification-pending-indicator.png new file mode 100644 index 00000000..cc9c23be Binary files /dev/null and b/apps/mobile/src/assets/icons/clubInfo/official-verification-pending-indicator.png differ diff --git a/apps/mobile/src/entities/club.ts b/apps/mobile/src/entities/club.ts index f356b45b..3a9e7034 100644 --- a/apps/mobile/src/entities/club.ts +++ b/apps/mobile/src/entities/club.ts @@ -6,7 +6,22 @@ export type ClubCollegeMajor = { major: string; }; -export type OfficialVerificationStatus = "VERIFIED" | "PENDING" | "UNVERIFIED"; +export type OfficialVerificationStatus = + | "VERIFIED" + | "PENDING" + | "REJECTED" + | "UNVERIFIED"; + +export type ManagedOfficialVerification = { + status: OfficialVerificationStatus; + requestId: number | null; + attemptNo: number | null; + retryCount: number; + retryLimit: number; + remainingRetryCount: number; + rejectReason: string | null; + requestedAt: string | null; +}; export type Club = { id: string; diff --git a/apps/mobile/src/features/club/components/ClubDetail/VerificationMarkButton.tsx b/apps/mobile/src/features/club/components/ClubDetail/VerificationMarkButton.tsx index fc5631a5..81b47753 100644 --- a/apps/mobile/src/features/club/components/ClubDetail/VerificationMarkButton.tsx +++ b/apps/mobile/src/features/club/components/ClubDetail/VerificationMarkButton.tsx @@ -1,11 +1,9 @@ -import { - Image, - type ImageSourcePropType, - Pressable, - StyleSheet, -} from "react-native"; +import { Pressable, StyleSheet } from "react-native"; import type { OfficialVerificationStatus } from "@/entities/club"; +import VerificationMark, { + DEFAULT_VERIFICATION_MARK_SIZE, +} from "@/features/club/components/VerificationMark"; import { ms } from "@/shared/utils/scale"; type Props = { @@ -13,17 +11,8 @@ type Props = { onPress: () => void; }; -const MARK_SOURCE_BY_STATUS: Partial< - Record -> = { - VERIFIED: require("@/assets/icons/clubInfo/verification-mark.png"), - PENDING: require("@/assets/icons/clubInfo/verification-pending-mark.png"), -}; - const VerificationMarkButton = ({ status, onPress }: Props) => { - const markSource = status ? MARK_SOURCE_BY_STATUS[status] : undefined; - - if (!markSource || status === "UNVERIFIED") return null; + if (status !== "VERIFIED" && status !== "PENDING") return null; return ( { hitSlop={8} style={({ pressed }) => [styles.button, pressed && styles.pressed]} > - + ); }; @@ -42,8 +31,8 @@ export default VerificationMarkButton; const styles = StyleSheet.create({ button: { - width: ms(20), - height: ms(20), + width: ms(DEFAULT_VERIFICATION_MARK_SIZE), + height: ms(DEFAULT_VERIFICATION_MARK_SIZE), justifyContent: "center", alignItems: "center", flexShrink: 0, @@ -51,8 +40,4 @@ const styles = StyleSheet.create({ pressed: { opacity: 0.5, }, - mark: { - width: ms(20), - height: ms(20), - }, }); diff --git a/apps/mobile/src/features/club/components/OfficialVerificationRequestButton.tsx b/apps/mobile/src/features/club/components/OfficialVerificationRequestButton.tsx index 7b971df8..145406aa 100644 --- a/apps/mobile/src/features/club/components/OfficialVerificationRequestButton.tsx +++ b/apps/mobile/src/features/club/components/OfficialVerificationRequestButton.tsx @@ -1,29 +1,51 @@ -import { Pressable, StyleSheet, Text } from "react-native"; +import { Image, Pressable, StyleSheet, Text, View } from "react-native"; import type { OfficialVerificationStatus } from "@/entities/club"; import { getOfficialVerificationButtonState } from "@/features/club/utils/officialVerificationRequest"; import { Colors } from "@/shared/constants/colors"; import { typography } from "@/shared/constants/typography"; -import { ms, vs } from "@/shared/utils/scale"; +import { ms, s, vs } from "@/shared/utils/scale"; type Props = { status?: OfficialVerificationStatus; isStatusLoading: boolean; isRequesting: boolean; + isRetryLimitReached: boolean; + isRetryLimitAcknowledged: boolean; onPress: () => void; }; +const pendingIndicatorSource = + require("@/assets/icons/clubInfo/official-verification-pending-indicator.png") as number; +const completeIndicatorSource = + require("@/assets/icons/clubInfo/official-verification-complete-indicator.png") as number; + +const PENDING_INDICATOR_SIZE = 13.66; +const COMPLETE_INDICATOR_FRAME_WIDTH = 10; +const COMPLETE_INDICATOR_FRAME_HEIGHT = 6.667; +const COMPLETE_INDICATOR_HORIZONTAL_OVERFLOW = 1; +const COMPLETE_INDICATOR_VERTICAL_OVERFLOW = 1; + const OfficialVerificationRequestButton = ({ status, isStatusLoading, isRequesting, + isRetryLimitReached, + isRetryLimitAcknowledged, onPress, }: Props) => { const buttonState = getOfficialVerificationButtonState({ status, isStatusLoading, isRequesting, + isRetryLimitReached, + isRetryLimitAcknowledged, }); + const isStatusButton = buttonState.variant === "status"; + const isFinalRejection = buttonState.variant === "final"; + const showPendingIndicator = + status === "PENDING" || (isRequesting && !isFinalRejection); + const showVerifiedIndicator = status === "VERIFIED"; return ( [ styles.button, - buttonState.disabled && styles.buttonDisabled, + isStatusButton && styles.statusButton, + isFinalRejection && styles.finalRejectionButton, pressed && !buttonState.disabled && styles.buttonPressed, ]} > - - {buttonState.label} - + {({ pressed }) => ( + + + {buttonState.label} + + {showPendingIndicator ? ( + + ) : null} + {showVerifiedIndicator ? ( + + + + ) : null} + + )} ); }; @@ -55,23 +102,60 @@ export default OfficialVerificationRequestButton; const styles = StyleSheet.create({ button: { width: "100%", - height: vs(44), + height: vs(33), + paddingHorizontal: s(10), alignItems: "center", justifyContent: "center", - borderRadius: ms(12), - backgroundColor: Colors.BUTTON_SELECTED, + borderWidth: 1, + borderColor: Colors.POINTCOLOR, + borderRadius: ms(8), + backgroundColor: Colors.WHITE, + }, + statusButton: { + backgroundColor: Colors.BACKGROUND_SUB, }, - buttonDisabled: { - backgroundColor: Colors.BUTTON_UNSELECTED, + finalRejectionButton: { + borderWidth: 0, + backgroundColor: Colors.BADGE_UNSELECTED, }, buttonPressed: { - backgroundColor: Colors.BUTTON_PUSH, + borderColor: Colors.BUTTON_PUSH, + }, + content: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: s(5), + }, + pendingIndicator: { + width: ms(PENDING_INDICATOR_SIZE), + height: ms(PENDING_INDICATOR_SIZE), + }, + completeIndicatorFrame: { + width: ms(COMPLETE_INDICATOR_FRAME_WIDTH), + height: ms(COMPLETE_INDICATOR_FRAME_HEIGHT), + }, + completeIndicator: { + position: "absolute", + top: ms(-COMPLETE_INDICATOR_VERTICAL_OVERFLOW), + left: ms(-COMPLETE_INDICATOR_HORIZONTAL_OVERFLOW), + width: ms( + COMPLETE_INDICATOR_FRAME_WIDTH + + COMPLETE_INDICATOR_HORIZONTAL_OVERFLOW * 2, + ), + height: ms( + COMPLETE_INDICATOR_FRAME_HEIGHT + + COMPLETE_INDICATOR_VERTICAL_OVERFLOW * 2, + ), }, label: { ...typography.bodyMSemibold, - color: Colors.TEXT_BUTTON_SELECTED, + color: Colors.POINTCOLOR, + }, + labelPressed: { + color: Colors.BUTTON_PUSH, }, - labelDisabled: { - color: Colors.WHITE, + finalRejectionLabel: { + color: Colors.TEXTBOX_SELECTED, }, }); diff --git a/apps/mobile/src/features/club/components/VerificationMark.tsx b/apps/mobile/src/features/club/components/VerificationMark.tsx new file mode 100644 index 00000000..ffbf7feb --- /dev/null +++ b/apps/mobile/src/features/club/components/VerificationMark.tsx @@ -0,0 +1,46 @@ +import { Image, type ImageSourcePropType, StyleSheet } from "react-native"; + +import type { OfficialVerificationStatus } from "@/entities/club"; +import { ms } from "@/shared/utils/scale"; + +type Props = { + status?: OfficialVerificationStatus; + size?: number; +}; + +export const DEFAULT_VERIFICATION_MARK_SIZE = 20; + +const MARK_SOURCE_BY_STATUS: Partial< + Record +> = { + VERIFIED: require("@/assets/icons/clubInfo/verification-mark.png"), + PENDING: require("@/assets/icons/clubInfo/verification-pending-mark.png"), +}; + +const VerificationMark = ({ + status, + size = DEFAULT_VERIFICATION_MARK_SIZE, +}: Props) => { + const markSource = status ? MARK_SOURCE_BY_STATUS[status] : undefined; + + if (!markSource) return null; + + const scaledSize = ms(size); + + return ( + + ); +}; + +export default VerificationMark; + +const styles = StyleSheet.create({ + mark: { + flexShrink: 0, + }, +}); diff --git a/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx b/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx index 06ada32e..32799878 100644 --- a/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx +++ b/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx @@ -26,7 +26,9 @@ const clubManagementSnuLogo = require("@/assets/images/club-management-snu-logo.png") as number; import OfficialVerificationRequestButton from "@/features/club/components/OfficialVerificationRequestButton"; +import VerificationMark from "@/features/club/components/VerificationMark"; import { getOfficialVerificationRequestErrorContent } from "@/features/club/utils/officialVerificationRequest"; +import AlertModal from "@/shared/components/AlertModal"; import EditPencilButton from "@/shared/components/EditPencilButton"; import { Colors } from "@/shared/constants/colors"; import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen"; @@ -40,6 +42,16 @@ type RouteProps = RouteProp; const VISIBLE_COUNT = 2; type DeleteTarget = { id: number; title: string } | null; +type VerificationConfirmation = "request" | "retry" | null; +type VerificationSuccess = { + isRetry: boolean; + retryCount: number; + retryLimit: number; + remainingRetryCount: number; +}; + +const VERIFICATION_PROCESSING_DURATION = "최대 일주일"; +const MANAGEMENT_VERIFICATION_MARK_SIZE = 16; const ClubManagementScreen = () => { const route = useRoute(); @@ -52,19 +64,24 @@ const ClubManagementScreen = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [deletedTitle, setDeletedTitle] = useState(null); const [showEditConfirm, setShowEditConfirm] = useState(false); - const [hasRequestedVerification, setHasRequestedVerification] = + const [verificationConfirmation, setVerificationConfirmation] = + useState(null); + const [verificationSuccess, setVerificationSuccess] = + useState(null); + const [showRetryLimitNotice, setShowRetryLimitNotice] = useState(false); + const [isRetryLimitAcknowledged, setIsRetryLimitAcknowledged] = useState(false); - const { data: club } = useQuery({ + const { + data: club, + isLoading: isVerificationStatusLoading, + refetch: refetchManagedClub, + } = useQuery({ queryKey: ["managedClub", clubId], queryFn: () => clubService.getManagedClubDetail({ uuid: clubId }), + staleTime: 0, + refetchOnWindowFocus: "always", }); - const { data: publicClub, isLoading: isVerificationStatusLoading } = useQuery( - { - queryKey: ["clubs", clubId], - queryFn: () => clubService.getClub({ uuid: clubId }), - }, - ); const { data: recruitmentsData, @@ -86,9 +103,14 @@ const ClubManagementScreen = () => { useFocusEffect( useCallback(() => { + refetchManagedClub(); refetchRecruitments(); refetchRepresentativeRecruitment(); - }, [refetchRecruitments, refetchRepresentativeRecruitment]), + }, [ + refetchManagedClub, + refetchRecruitments, + refetchRepresentativeRecruitment, + ]), ); const { mutate: deleteRecruitment, isPending: isDeleting } = useMutation({ @@ -112,18 +134,22 @@ const ClubManagementScreen = () => { mutate: requestOfficialVerification, isPending: isRequestingVerification, } = useMutation({ - mutationFn: () => clubService.requestOfficialVerification({ clubId }), - onSuccess: () => { - setHasRequestedVerification(true); + mutationFn: (_isRetry: boolean) => + clubService.requestOfficialVerification({ clubId }), + onSuccess: (response, isRetry) => { + queryClient.invalidateQueries({ queryKey: ["managedClub", clubId] }); queryClient.invalidateQueries({ queryKey: ["clubs", clubId] }); - Toast.show({ - type: "success", - text1: "총동연 공식 인증을 신청했어요", - text2: "운영진 검토 후 결과를 반영할게요", + setVerificationSuccess({ + isRetry, + retryCount: response.data.retry_count, + retryLimit: + response.data.retry_count + response.data.remaining_retry_count, + remainingRetryCount: response.data.remaining_retry_count, }); }, onError: (error) => { const errorContent = getOfficialVerificationRequestErrorContent(error); + queryClient.invalidateQueries({ queryKey: ["managedClub", clubId] }); queryClient.invalidateQueries({ queryKey: ["clubs", clubId] }); Toast.show({ type: "error", @@ -150,10 +176,21 @@ const ClubManagementScreen = () => { const clubAffiliation = club ? getClubAffiliationLabel(club) : ""; const shortIntro = club?.shortDescription || ""; const officialVerificationStatus = - (hasRequestedVerification - ? "PENDING" - : publicClub?.officialVerificationStatus) ?? + club?.officialVerification?.status ?? (club?.isOfficialVerified ? "VERIFIED" : undefined); + const isRetryLimitReached = + club?.officialVerification?.status === "REJECTED" && + club.officialVerification.remainingRetryCount === 0; + const isRetryConfirmation = verificationConfirmation === "retry"; + const verificationConfirmationTitle = isRetryConfirmation + ? "올클 공식인증이 반려되었어요." + : "공식 인증을 요청할까요?"; + const verificationConfirmationDescription = isRetryConfirmation + ? "세부사항을 확인하고 인증을 재요청해주세요" + : "인증된 동아리는 스티커를 부여해드려요."; + const verificationSuccessDescription = verificationSuccess?.isRetry + ? `재요청은 최대 ${verificationSuccess.retryLimit}회까지 가능해요\n${verificationSuccess.retryCount}/${verificationSuccess.retryLimit}회 (남은 횟수 ${verificationSuccess.remainingRetryCount}회)` + : `인증이 완료되기까지는\n${VERIFICATION_PROCESSING_DURATION}이 소요될 수 있어요`; return ( @@ -193,9 +230,15 @@ const ClubManagementScreen = () => { style={styles.clubLogoImage} /> - - {club?.name ?? ""} - + + + {club?.name ?? ""} + + + {clubAffiliation} @@ -215,7 +258,17 @@ const ClubManagementScreen = () => { status={officialVerificationStatus} isStatusLoading={isVerificationStatusLoading} isRequesting={isRequestingVerification} - onPress={() => requestOfficialVerification()} + isRetryLimitReached={isRetryLimitReached} + isRetryLimitAcknowledged={isRetryLimitAcknowledged} + onPress={() => + isRetryLimitReached + ? setShowRetryLimitNotice(true) + : setVerificationConfirmation( + officialVerificationStatus === "REJECTED" + ? "retry" + : "request", + ) + } /> {/* 공고 관리 */} @@ -411,6 +464,85 @@ const ClubManagementScreen = () => { + + !isRequestingVerification && setVerificationConfirmation(null) + } + title={verificationConfirmationTitle} + titleContent={ + isRetryConfirmation ? ( + <> + 올클 공식인증이{" "} + + 반려되었어요. + + + ) : ( + <> + + 공식 인증을 요청 + + 할까요? + + ) + } + description={verificationConfirmationDescription} + buttonLabel={isRetryConfirmation ? "재요청" : "확인"} + onButtonPress={() => { + const isRetry = verificationConfirmation === "retry"; + setVerificationConfirmation(null); + requestOfficialVerification(isRetry); + }} + buttonDisabled={isRequestingVerification} + hasCancel + dismissOnBackdropPress={!isRequestingVerification} + /> + + setVerificationSuccess(null)} + title={ + verificationSuccess?.isRetry + ? "인증 재요청이 정상적으로 완료되었어요!" + : "인증요청이 정상적으로 완료되었어요!" + } + titleStyle={styles.verificationSuccessModalTitle} + description={verificationSuccessDescription} + descriptionContent={ + verificationSuccess?.isRetry ? ( + <> + 재요청은 최대 {verificationSuccess.retryLimit}회까지 가능해요 + {"\n"} + + {verificationSuccess.retryCount} + + /{verificationSuccess.retryLimit}회 (남은 횟수{" "} + + {verificationSuccess.remainingRetryCount} + + 회) + + ) : undefined + } + buttonLabel="확인" + onButtonPress={() => setVerificationSuccess(null)} + /> + + setShowRetryLimitNotice(false)} + title={"공식인증 재요청 가능 횟수가\n모두 소진되었어요."} + titleStyle={styles.verificationRetryLimitModalTitle} + description="아쉽지만 다음학기에 요청해주세요." + buttonLabel="확인" + onButtonPress={() => { + setShowRetryLimitNotice(false); + setIsRetryLimitAcknowledged(true); + }} + hasCancel + /> + {/* 동아리 정보 수정 확인 모달 */} { export default ClubManagementScreen; const styles = StyleSheet.create({ + verificationPointModalTitle: { + color: Colors.POINTCOLOR, + }, + verificationSuccessModalTitle: { + color: Colors.BLACK, + }, + verificationRetryLimitModalTitle: { + color: Colors.BODYTEXT_MAIN, + }, safeArea: { flex: 1, backgroundColor: "#FFFFFF", @@ -634,7 +775,8 @@ const styles = StyleSheet.create({ paddingVertical: vs(30), }, clubDetails: { - width: s(152), + flex: 1, + marginRight: s(16), gap: vs(12), alignItems: "flex-start", }, @@ -649,6 +791,11 @@ const styles = StyleSheet.create({ alignSelf: "stretch", gap: vs(8), }, + clubNameRow: { + flexDirection: "row", + alignItems: "center", + gap: s(4), + }, clubName: { fontFamily: "Pretendard", fontWeight: "700", @@ -656,6 +803,7 @@ const styles = StyleSheet.create({ lineHeight: ms(24), letterSpacing: -0.02 * 20, color: "#000000", + flexShrink: 1, }, clubCollege: { fontFamily: "Pretendard", diff --git a/apps/mobile/src/features/club/utils/officialVerificationRequest.test.ts b/apps/mobile/src/features/club/utils/officialVerificationRequest.test.ts index 80d3e9e1..824dcd2a 100644 --- a/apps/mobile/src/features/club/utils/officialVerificationRequest.test.ts +++ b/apps/mobile/src/features/club/utils/officialVerificationRequest.test.ts @@ -8,6 +8,14 @@ const createAxiosError = (status: number) => ({ response: { status }, }); +const createRetryLimitExceededAxiosError = () => ({ + isAxiosError: true, + response: { + status: 409, + data: "official verification retry limit exceeded", + }, +}); + describe("getOfficialVerificationButtonState", () => { it("인증 상태를 확인하는 동안 신청할 수 없다", () => { expect( @@ -18,6 +26,7 @@ describe("getOfficialVerificationButtonState", () => { ).toEqual({ label: "인증 상태 확인 중...", disabled: true, + variant: "status", }); }); @@ -28,20 +37,22 @@ describe("getOfficialVerificationButtonState", () => { isStatusLoading: false, }), ).toEqual({ - label: "총동연 공식 인증 신청하기", + label: "올클 공식인증 요청하기", disabled: false, + variant: "outline", }); }); it.each([ - ["PENDING", "총동연 공식 인증 심사 중"], - ["VERIFIED", "총동연 공식 인증 완료"], + ["PENDING", "올클 공식인증 심사중"], + ["VERIFIED", "올클 공식인증완료"], ] as const)("%s 상태에서는 중복 신청할 수 없다", (status, label) => { expect( getOfficialVerificationButtonState({ status, isStatusLoading: false }), ).toEqual({ label, disabled: true, + variant: "status", }); }); @@ -53,8 +64,37 @@ describe("getOfficialVerificationButtonState", () => { isRequesting: true, }), ).toEqual({ - label: "총동연 공식 인증 신청 중...", + label: "올클 공식인증 요청 중...", + disabled: true, + variant: "status", + }); + }); + + it("반려된 동아리는 인증을 재요청할 수 있다", () => { + expect( + getOfficialVerificationButtonState({ + status: "REJECTED", + isStatusLoading: false, + }), + ).toEqual({ + label: "올클 공식인증 반려 및 재요청", + disabled: false, + variant: "outline", + }); + }); + + it("재요청 횟수 소진 안내를 확인하면 버튼을 비활성화한다", () => { + expect( + getOfficialVerificationButtonState({ + status: "REJECTED", + isStatusLoading: false, + isRetryLimitReached: true, + isRetryLimitAcknowledged: true, + }), + ).toEqual({ + label: "올클 공식인증 반려", disabled: true, + variant: "final", }); }); }); @@ -69,6 +109,17 @@ describe("getOfficialVerificationRequestErrorContent", () => { }); }); + it("재요청 횟수 초과 응답은 재요청 불가로 안내한다", () => { + expect( + getOfficialVerificationRequestErrorContent( + createRetryLimitExceededAxiosError(), + ), + ).toEqual({ + title: "재요청 횟수를 모두 사용했어요", + description: "총동연 공식 인증은 더 이상 재요청할 수 없어요", + }); + }); + it("응답 상태를 알 수 없으면 재시도를 안내한다", () => { expect( getOfficialVerificationRequestErrorContent(new Error("network error")), diff --git a/apps/mobile/src/features/club/utils/officialVerificationRequest.ts b/apps/mobile/src/features/club/utils/officialVerificationRequest.ts index b610c0da..199f3fc9 100644 --- a/apps/mobile/src/features/club/utils/officialVerificationRequest.ts +++ b/apps/mobile/src/features/club/utils/officialVerificationRequest.ts @@ -1,26 +1,34 @@ import type { OfficialVerificationStatus } from "@/entities/club"; import { getApiErrorStatus } from "@/shared/utils/apiError"; +export type OfficialVerificationButtonVariant = "outline" | "status" | "final"; + type OfficialVerificationButtonStateParams = { status?: OfficialVerificationStatus; isStatusLoading: boolean; isRequesting?: boolean; + isRetryLimitReached?: boolean; + isRetryLimitAcknowledged?: boolean; }; type OfficialVerificationButtonState = { label: string; disabled: boolean; + variant: OfficialVerificationButtonVariant; }; export const getOfficialVerificationButtonState = ({ status, isStatusLoading, isRequesting = false, + isRetryLimitReached = false, + isRetryLimitAcknowledged = false, }: OfficialVerificationButtonStateParams): OfficialVerificationButtonState => { if (isRequesting) { return { - label: "총동연 공식 인증 신청 중...", + label: "올클 공식인증 요청 중...", disabled: true, + variant: "status", }; } @@ -28,26 +36,48 @@ export const getOfficialVerificationButtonState = ({ return { label: "인증 상태 확인 중...", disabled: true, + variant: "status", }; } if (status === "PENDING") { return { - label: "총동연 공식 인증 심사 중", + label: "올클 공식인증 심사중", disabled: true, + variant: "status", }; } if (status === "VERIFIED") { return { - label: "총동연 공식 인증 완료", + label: "올클 공식인증완료", disabled: true, + variant: "status", + }; + } + + if (status === "REJECTED") { + if (isRetryLimitReached) { + return { + label: isRetryLimitAcknowledged + ? "올클 공식인증 반려" + : "올클 공식인증 반려 및 재요청", + disabled: isRetryLimitAcknowledged, + variant: isRetryLimitAcknowledged ? "final" : "outline", + }; + } + + return { + label: "올클 공식인증 반려 및 재요청", + disabled: false, + variant: "outline", }; } return { - label: "총동연 공식 인증 신청하기", + label: "올클 공식인증 요청하기", disabled: false, + variant: "outline", }; }; @@ -61,6 +91,9 @@ const DEFAULT_ERROR_CONTENT: OfficialVerificationRequestErrorContent = { description: "네트워크 상태를 확인한 후 다시 시도해주세요", }; +const RETRY_LIMIT_EXCEEDED_ERROR_MESSAGE = + "official verification retry limit exceeded"; + const ERROR_CONTENT_BY_STATUS: Partial< Record > = { @@ -86,5 +119,19 @@ export const getOfficialVerificationRequestErrorContent = ( error: unknown, ): OfficialVerificationRequestErrorContent => { const status = getApiErrorStatus(error); + if (status === 409 && isRetryLimitExceeded(error)) { + return { + title: "재요청 횟수를 모두 사용했어요", + description: "총동연 공식 인증은 더 이상 재요청할 수 없어요", + }; + } + return (status && ERROR_CONTENT_BY_STATUS[status]) || DEFAULT_ERROR_CONTENT; }; + +const isRetryLimitExceeded = (error: unknown): boolean => { + if (typeof error !== "object" || error === null) return false; + + const response = (error as { response?: { data?: unknown } }).response; + return response?.data === RETRY_LIMIT_EXCEEDED_ERROR_MESSAGE; +}; diff --git a/apps/mobile/src/repositories/club.ts b/apps/mobile/src/repositories/club.ts index 03211629..df74b5fd 100644 --- a/apps/mobile/src/repositories/club.ts +++ b/apps/mobile/src/repositories/club.ts @@ -1,4 +1,9 @@ -import type { Club, ClubRanking, ManagedClubListItem } from "@/entities/club"; +import type { + Club, + ClubRanking, + ManagedClubListItem, + ManagedOfficialVerification, +} from "@/entities/club"; import type { ManagedClubManagerPatch } from "@/repositories/managedClubUpdate"; import { buildManagedClubUpdateBody } from "@/repositories/managedClubUpdate"; import { apiConnector } from "@/shared/utils/api"; @@ -65,6 +70,7 @@ export type ClubManager = { export type ManagedClubDetail = Club & { managers: ClubManager[]; + officialVerification: ManagedOfficialVerification; }; export type GetManagedClubDetailRequest = { @@ -145,6 +151,9 @@ export type RequestOfficialVerificationResponse = { club_uuid: Club["uuid"]; status: "PENDING"; created_at: string; + attempt_no: number; + retry_count: number; + remaining_retry_count: number; }; }; diff --git a/apps/mobile/src/shared/components/AlertModal.tsx b/apps/mobile/src/shared/components/AlertModal.tsx index f89c9280..4c1bae11 100644 --- a/apps/mobile/src/shared/components/AlertModal.tsx +++ b/apps/mobile/src/shared/components/AlertModal.tsx @@ -1,5 +1,14 @@ import { BlurView } from "@react-native-community/blur"; -import { Modal, Pressable, StyleSheet, Text, View } from "react-native"; +import type { ReactNode } from "react"; +import { + Modal, + Pressable, + type StyleProp, + StyleSheet, + Text, + type TextStyle, + View, +} from "react-native"; import Button, { type ButtonVariant } from "@/shared/components/Button"; import { Colors } from "@/shared/constants/colors"; @@ -7,7 +16,10 @@ export type AlertModalProps = { visible: boolean; onClose: () => void; title: string; + titleStyle?: StyleProp; + titleContent?: ReactNode; description?: string; + descriptionContent?: ReactNode; buttonLabel: string; onButtonPress: () => void; buttonDisabled?: boolean; @@ -23,7 +35,10 @@ const AlertModal = ({ visible, onClose, title, + titleStyle, + titleContent, description, + descriptionContent, buttonLabel, onButtonPress, buttonDisabled = false, @@ -56,9 +71,13 @@ const AlertModal = ({ style={styles.container} onPress={(e) => e.stopPropagation()} > - {title} - {description ? ( - {description} + + {titleContent ?? title} + + {description || descriptionContent ? ( + + {descriptionContent ?? description} + ) : null} {hasCancel && ( diff --git a/apps/mobile/src/shared/components/Button.tsx b/apps/mobile/src/shared/components/Button.tsx index af23da74..d9d9a238 100644 --- a/apps/mobile/src/shared/components/Button.tsx +++ b/apps/mobile/src/shared/components/Button.tsx @@ -48,26 +48,45 @@ export const Button = ({ variantStyle.text, textStyle, ].filter((s): s is TextStyle => !!s); + const pressedStyle = getPressedStyle(variant); return ( [ containerStyle, - pressed && - (variant === "primary" - ? { backgroundColor: Colors.BUTTON_PUSH } - : variant === "destructive" - ? { opacity: 0.8 } - : { opacity: 0.7 }), + pressed && pressedStyle.container, ]} onPress={onPress} disabled={disabled} > - {label} + {({ pressed }) => ( + {label} + )} ); }; +const getPressedStyle = ( + variant: ButtonVariant, +): { container?: ViewStyle; text?: TextStyle } => { + if (variant === "primary") { + return { container: { backgroundColor: Colors.BUTTON_PUSH } }; + } + + if (variant === "outline") { + return { + container: { borderColor: Colors.BUTTON_PUSH }, + text: { color: Colors.BUTTON_PUSH }, + }; + } + + if (variant === "destructive") { + return { container: { opacity: 0.8 } }; + } + + return {}; +}; + const disabledVariantMap: Record = { primary: "primaryDisabled", outline: "outlineDisabled",