From d8a2c727cdc9e885ad4ee9c9c50833e403a33aee Mon Sep 17 00:00:00 2001 From: yhaeul Date: Mon, 10 Aug 2026 21:25:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B3=B5=EC=8B=9D=20=EC=9D=B8=EC=A6=9D=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/entities/club.ts | 17 ++++++++++++++++- .../club/screens/ClubManagementScreen/index.tsx | 17 ++++------------- apps/mobile/src/repositories/club.ts | 11 ++++++++++- 3 files changed, 30 insertions(+), 15 deletions(-) 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/screens/ClubManagementScreen/index.tsx b/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx index 06ada32e..abe903e8 100644 --- a/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx +++ b/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx @@ -52,19 +52,11 @@ const ClubManagementScreen = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [deletedTitle, setDeletedTitle] = useState(null); const [showEditConfirm, setShowEditConfirm] = useState(false); - const [hasRequestedVerification, setHasRequestedVerification] = - useState(false); - const { data: club } = useQuery({ + const { data: club, isLoading: isVerificationStatusLoading } = useQuery({ queryKey: ["managedClub", clubId], queryFn: () => clubService.getManagedClubDetail({ uuid: clubId }), }); - const { data: publicClub, isLoading: isVerificationStatusLoading } = useQuery( - { - queryKey: ["clubs", clubId], - queryFn: () => clubService.getClub({ uuid: clubId }), - }, - ); const { data: recruitmentsData, @@ -114,7 +106,7 @@ const ClubManagementScreen = () => { } = useMutation({ mutationFn: () => clubService.requestOfficialVerification({ clubId }), onSuccess: () => { - setHasRequestedVerification(true); + queryClient.invalidateQueries({ queryKey: ["managedClub", clubId] }); queryClient.invalidateQueries({ queryKey: ["clubs", clubId] }); Toast.show({ type: "success", @@ -124,6 +116,7 @@ const ClubManagementScreen = () => { }, onError: (error) => { const errorContent = getOfficialVerificationRequestErrorContent(error); + queryClient.invalidateQueries({ queryKey: ["managedClub", clubId] }); queryClient.invalidateQueries({ queryKey: ["clubs", clubId] }); Toast.show({ type: "error", @@ -150,9 +143,7 @@ const ClubManagementScreen = () => { const clubAffiliation = club ? getClubAffiliationLabel(club) : ""; const shortIntro = club?.shortDescription || ""; const officialVerificationStatus = - (hasRequestedVerification - ? "PENDING" - : publicClub?.officialVerificationStatus) ?? + club?.officialVerification?.status ?? (club?.isOfficialVerified ? "VERIFIED" : undefined); return ( 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; }; }; From aca203f5d9451bf59b8ba8baa7cc9522647fe97c Mon Sep 17 00:00:00 2001 From: yhaeul Date: Mon, 10 Aug 2026 23:23:40 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B3=B5=EC=8B=9D=20=EC=9D=B8=EC=A6=9D=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/App.tsx | 16 +- ...ficial-verification-complete-indicator.png | Bin 0 -> 798 bytes ...fficial-verification-pending-indicator.png | Bin 0 -> 2243 bytes .../OfficialVerificationRequestButton.tsx | 118 +++++++++++-- .../screens/ClubManagementScreen/index.tsx | 160 +++++++++++++++++- .../utils/officialVerificationRequest.test.ts | 59 ++++++- .../club/utils/officialVerificationRequest.ts | 55 +++++- .../src/shared/components/AlertModal.tsx | 27 ++- apps/mobile/src/shared/components/Button.tsx | 33 +++- 9 files changed, 421 insertions(+), 47 deletions(-) create mode 100644 apps/mobile/src/assets/icons/clubInfo/official-verification-complete-indicator.png create mode 100644 apps/mobile/src/assets/icons/clubInfo/official-verification-pending-indicator.png 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 0000000000000000000000000000000000000000..1f6680f8e5fd3f8f73d5aa9f2d2200697eb742b4 GIT binary patch literal 798 zcmV+(1L6FMP)@~0drDELIAGL9O(c600d`2O+f$vv5yP*0;YFE;z@$7v#_iM<_k80mSf@srJ$#dGZW9>0d2 zeFFJwBRxN;ibBo`kV1=oldyk3H16vPWMuxMnF*(%l zV2H#jjZ>3Y{(fXDUhvZ7pm1V(fl4r+9I#hwNX0W22FQ$e%`A%zxLr=mqc-iW{H`H; ztkig3$1Y$%)9Lz7J;1>h`p#T5?07*qoM6N<$f-e(oQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cc9c23be1e12392579f7d41e9710c1940a79c925 GIT binary patch literal 2243 zcmV;!2t4@~0drDELIAGL9O(c600d`2O+f$vv5yPe;eF8hY01Z!&z5p#RK+-FON^yb^6|}{Uga}-qI2DNI$2-iNGv{ZWH2;Z5 ziFfzx?m6H5o|##M1EA&%m5vqRDgjghp-co-%%DWb|D+EQwE47^W$>dZz|3kF=4)55 z4f`a7JyNqtrQ`p>jSS!#PcqIxk&h%teI%SGOvH101@ql5Obr0cy!$aOz#eGV3<&UJ zt8mj~aFY>~6eI-(WX_F1fwK6Vj=3XW+h+-;*H&S&_Q~$FVs`WJlkccLvH~qOj|Q8^ zn2ezlftNHoxVc}npQ%I==7BU&x-{L|g;)+S}P0?#uwoao}@=oSPYanLAZ-ZE6q z*khdN#;k-fdWs!BSq3Z)1v5iPA{n35w_;*bA9(sbNof4a+p;W}CFdbLX30w#boloD ztP2kc1(^Hy7_@6O?8q(ICyD@NKCSZm<9xiz?@ACL0bb_aV{o{~cYO5nYq66L4B*{u z8F<{J@?e*3o;RT^an^KVa)51QVIT_yf;-QL;jRa~6Z&a}CV0;Jn-}0*>2wm65>RO< z=JVykfCwP(*c%vl_J~?Mcn(}Vcal#S2tkD>hY_Zjq z6>8+(58&B!NaAm_b3Ki=u7YTpb@-_A;r{cOlz721o9sPaq^b)=AE5L(=nM=(g(Yjv zHPt}fv*b0P+>?()=8(YKwGi9Jz;ytd^nvax){H6eXeBzj&Z$gzPkzL4hgkZmvycW* z42jkjhd9$bdueY;*G@?^(OrWkuRQAanB30qm{?*hye(R=8sHFj+1s}!S4g1wi)uvn zgu@0eB${ATnoQG^O&%+GE!dl_IckPHrzZv0x7(FVSb-y?^%W9}DEDBY;FkomgTr{m zi1UIgL~^{!Q^o`zXuuH@htoR>oO~qo4z1&tS=uErp{h3>i(5vN5!J?WrM7VgJZvhy z0MvsWSPj(KEymVIKNK1%NpXrEV+ACwc<) zqT!q8z@-cTK6FA-_;Cb+K2dSau1%6uW|S{mwMZH@y@d#(Hy}rqU1XRCO*qDWki}#& z)8Z&;9E{nRqZNra8}LjaLf^?^cDWc~I;g*ffiG#I=?FCv>At}GL<#ytOG|NW%?5lp zC0~OJ1IwW@_bIG8urN_9y4|oJWUDwe8w-f?Rho4B3~-ANy$Uc1RqDo>b|d;kRke95 z`7G&t2(e8u0|VTsN@7lG|8^rQ*c!H9bXmK0L9$v6+MvpYGH1BVnfDoz{uAX(&8z+L z12*MPLCxN4fgo0k>jd+LbMO0VJ|WvkOiKProR>_%5!3S}TlAMg&oztE_Z$^?r#2Gj zQPKs9`M=}BWr3Cn6Dy#jBI7DVmxLypSQ0Ut!(&{?)RVR790`% z^EcgO1Z6Mjp7x3_3Z??!R*M@?(skyoSwZ`jA%KQf{^@W_y>{lp!PmnnEiG~*c@Ub? zm&#=bkoV2wLr}(HT(BCbRBY-x1(2x9n^4q3iL*bL075O5{-z9A zCVrtCJkxZTIR9w^d&d1@9dPyYY?0gRoi$hxvy>a+U!`MQIAykhFwS#dN9Svll&=u3 zH8W5jv{|SjxIJ=mqqa9?z%uc~Hc{)zK95y~*i^kY463`#wZslJF$y zy|w#SU&qPKaK4lQp}(HlPfQJ6)Z_M(h5;Y3EF;EsG}33o1tyDPl)9wv;oBSZcpi7@ zM&H23%P?-qfY9Pon&7zn1#R?+xLuoE9u>N09(1}P<`~y=69YyHzQZ3*ZjGGUx+k?Q z1C|MPY%UCD#NlieWO^b6=m)>VL{dgmAHu}sdhTOXF|@g*a%Jqy_Iu5aK6G=0X`eoi z6*i|yl^S}Hr|GTkl+K(W?Bwn_3f~%h=Oav^t_%sq=yp zY9=`cOpYO<07k#)bxS(f@NwR~Z?g})zZp1;z>`sk zQO0%s9_$5pe!_i{YUMIYo02yhBj4HL2IcpP1wNT8$Y-nqcl%zz{k?)t>3@866R{&d R{VxCj002ovPDHLkV1l@PDggih literal 0 HcmV?d00001 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/screens/ClubManagementScreen/index.tsx b/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx index abe903e8..be793a74 100644 --- a/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx +++ b/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx @@ -27,6 +27,7 @@ const clubManagementSnuLogo = import OfficialVerificationRequestButton from "@/features/club/components/OfficialVerificationRequestButton"; 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 +41,15 @@ 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 ClubManagementScreen = () => { const route = useRoute(); @@ -52,10 +62,23 @@ const ClubManagementScreen = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [deletedTitle, setDeletedTitle] = useState(null); const [showEditConfirm, setShowEditConfirm] = useState(false); + const [verificationConfirmation, setVerificationConfirmation] = + useState(null); + const [verificationSuccess, setVerificationSuccess] = + useState(null); + const [showRetryLimitNotice, setShowRetryLimitNotice] = useState(false); + const [isRetryLimitAcknowledged, setIsRetryLimitAcknowledged] = + useState(false); - const { data: club, isLoading: isVerificationStatusLoading } = useQuery({ + const { + data: club, + isLoading: isVerificationStatusLoading, + refetch: refetchManagedClub, + } = useQuery({ queryKey: ["managedClub", clubId], queryFn: () => clubService.getManagedClubDetail({ uuid: clubId }), + staleTime: 0, + refetchOnWindowFocus: "always", }); const { @@ -78,9 +101,14 @@ const ClubManagementScreen = () => { useFocusEffect( useCallback(() => { + refetchManagedClub(); refetchRecruitments(); refetchRepresentativeRecruitment(); - }, [refetchRecruitments, refetchRepresentativeRecruitment]), + }, [ + refetchManagedClub, + refetchRecruitments, + refetchRepresentativeRecruitment, + ]), ); const { mutate: deleteRecruitment, isPending: isDeleting } = useMutation({ @@ -104,14 +132,17 @@ const ClubManagementScreen = () => { mutate: requestOfficialVerification, isPending: isRequestingVerification, } = useMutation({ - mutationFn: () => clubService.requestOfficialVerification({ clubId }), - onSuccess: () => { + 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) => { @@ -145,6 +176,19 @@ const ClubManagementScreen = () => { const 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 ( @@ -206,7 +250,17 @@ const ClubManagementScreen = () => { status={officialVerificationStatus} isStatusLoading={isVerificationStatusLoading} isRequesting={isRequestingVerification} - onPress={() => requestOfficialVerification()} + isRetryLimitReached={isRetryLimitReached} + isRetryLimitAcknowledged={isRetryLimitAcknowledged} + onPress={() => + isRetryLimitReached + ? setShowRetryLimitNotice(true) + : setVerificationConfirmation( + officialVerificationStatus === "REJECTED" + ? "retry" + : "request", + ) + } /> {/* 공고 관리 */} @@ -402,6 +456,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", 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/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", From 02b161d96438cba4a1a669ff1b6c3b0d7f8ca4f2 Mon Sep 17 00:00:00 2001 From: yhaeul Date: Tue, 11 Aug 2026 02:45:29 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=B9=B4=EB=93=9C=EC=97=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=83=81=ED=83=9C=20=EC=8A=A4=ED=8B=B0=EC=BB=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClubDetail/VerificationMarkButton.tsx | 31 ++++--------- .../club/components/VerificationMark.tsx | 46 +++++++++++++++++++ .../screens/ClubManagementScreen/index.tsx | 23 ++++++++-- 3 files changed, 73 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/src/features/club/components/VerificationMark.tsx 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/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 be793a74..32799878 100644 --- a/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx +++ b/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx @@ -26,6 +26,7 @@ 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"; @@ -50,6 +51,7 @@ type VerificationSuccess = { }; const VERIFICATION_PROCESSING_DURATION = "최대 일주일"; +const MANAGEMENT_VERIFICATION_MARK_SIZE = 16; const ClubManagementScreen = () => { const route = useRoute(); @@ -228,9 +230,15 @@ const ClubManagementScreen = () => { style={styles.clubLogoImage} /> - - {club?.name ?? ""} - + + + {club?.name ?? ""} + + + {clubAffiliation} @@ -767,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", }, @@ -782,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", @@ -789,6 +803,7 @@ const styles = StyleSheet.create({ lineHeight: ms(24), letterSpacing: -0.02 * 20, color: "#000000", + flexShrink: 1, }, clubCollege: { fontFamily: "Pretendard",