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",