Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/mobile/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import Toast, { type ToastConfig } from "react-native-toast-message";
import { linking } from "@/config/linking";
import AnnouncementEditScreen from "@/features/club/screens/AnnouncementEditScreen";
import AnnouncementRegistrationScreen from "@/features/club/screens/AnnouncementRegistrationScreen";
import ManagerTransferAcceptanceScreen from "@/features/club/screens/ManagerTransferAcceptanceScreen";
import { getAnnouncementRepository } from "@/repositories/announcement";
import { getAppVersionRepository } from "@/repositories/appVersion";
import { getAuthRepository } from "@/repositories/auth";
Expand All @@ -31,7 +32,10 @@ import { getUserRepository } from "@/repositories/user";
import AppModalManager from "@/shared/components/AppModalManager";
import ForceUpdateGate from "@/shared/components/ForceUpdateGate";
import { Colors } from "@/shared/constants/colors";
import { SCREEN_TYPE } from "@/shared/constants/screen";
import {
type RootStackParamList,
SCREEN_TYPE,
} from "@/shared/constants/screen";
import { typography } from "@/shared/constants/typography";
import { LoginBottomSheetProvider } from "@/shared/contexts/loginBottomSheetContext";
import { ManageClubBottomSheetProvider } from "@/shared/contexts/manageClubBottomSheet";
Expand All @@ -58,7 +62,7 @@ import { getReviewService } from "@/usecases/review";
import { getTermService } from "@/usecases/term";
import { getUserService } from "@/usecases/user";

const RootStack = createNativeStackNavigator();
const RootStack = createNativeStackNavigator<RootStackParamList>();

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -177,6 +181,10 @@ function App(): React.JSX.Element {
name={SCREEN_TYPE.ANNOUNCEMENT_EDIT}
component={AnnouncementEditScreen}
/>
<RootStack.Screen
name={SCREEN_TYPE.MANAGER_TRANSFER_ACCEPTANCE}
component={ManagerTransferAcceptanceScreen}
/>
</RootStack.Navigator>
</NavigationContainer>
{shouldShowGlobalAppModals({ entryFlowComplete }) && (
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1803,6 +1803,27 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNCClipboard (1.16.3):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNCMaskedView (0.3.2):
- DoubleConversion
- glog
Expand Down Expand Up @@ -2124,6 +2145,7 @@ DEPENDENCIES:
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
- "RNAppleAuthentication (from `../../../node_modules/@invertase/react-native-apple-authentication`)"
- "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
- "RNCClipboard (from `../../../node_modules/@react-native-clipboard/clipboard`)"
- "RNCMaskedView (from `../../../node_modules/@react-native-masked-view/masked-view`)"
- RNDeviceInfo (from `../../../node_modules/react-native-device-info`)
- "RNFBAnalytics (from `../../../node_modules/@react-native-firebase/analytics`)"
Expand Down Expand Up @@ -2305,6 +2327,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/@invertase/react-native-apple-authentication"
RNCAsyncStorage:
:path: "../../../node_modules/@react-native-async-storage/async-storage"
RNCClipboard:
:path: "../../../node_modules/@react-native-clipboard/clipboard"
RNCMaskedView:
:path: "../../../node_modules/@react-native-masked-view/masked-view"
RNDeviceInfo:
Expand Down Expand Up @@ -2414,6 +2438,7 @@ SPEC CHECKSUMS:
ReactCommon: bf4612cba0fa356b529385029f470d5529dddde4
RNAppleAuthentication: a89c9804592b38ed4ab11f0aee68d05ba12ad432
RNCAsyncStorage: dc5ca6aeca46e73f963ea4f79e222838c2d186cb
RNCClipboard: ba9fda8e2c9f003a29f7f66793069c4d0dada955
RNCMaskedView: 308c763227e237d4d260bd8841870e099572bb3e
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNFBAnalytics: f12c83fab147fea9b422524c0a1d9548d908ba90
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@homielab/react-native-auto-scroll": "^1.1.2",
"@invertase/react-native-apple-authentication": "^2.3.0",
"@react-native-async-storage/async-storage": "^1.17.11",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/blur": "^4.3.2",
"@react-native-firebase/analytics": "^20.3.0",
"@react-native-firebase/app": "^20.3.0",
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/config/linking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getStateFromPath } from "@react-navigation/native";
import { linking } from "@/config/linking";
import { SCREEN_TYPE } from "@/shared/constants/screen";

jest.mock("react-native-config", () => ({
API_SERVER_BASE_URL: "https://dev.all-clear.cc/api",
PROFILE: "dev",
WEB_URL: "https://dev.all-clear.cc",
}));

describe("deep linking", () => {
it("관리자 권한 이전 링크에서 토큰을 루트 수락 화면으로 전달한다", () => {
const token = "a".repeat(43);

expect(
getStateFromPath(`/manager-transfer/${token}`, linking.config),
).toMatchObject({
routes: [
{
name: SCREEN_TYPE.MANAGER_TRANSFER_ACCEPTANCE,
params: { token },
},
],
});
});
});
9 changes: 7 additions & 2 deletions apps/mobile/src/config/linking.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { LinkingOptions } from "@react-navigation/native";
import { ENV } from "@/config/ENV";
import { SCREEN_TYPE } from "@/shared/constants/screen";
import {
type RootStackParamList,
SCREEN_TYPE,
} from "@/shared/constants/screen";

export const linking = {
export const linking: LinkingOptions<RootStackParamList> = {
prefixes: [
"allclear://",
"https://all-clear.cc",
Expand All @@ -10,6 +14,7 @@ export const linking = {
],
config: {
screens: {
[SCREEN_TYPE.MANAGER_TRANSFER_ACCEPTANCE]: "manager-transfer/:token",
Main: {
screens: {
홈: {
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/entities/userNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ export type UserNotificationType =
| "CLUB_REGISTRATION_APPROVED"
| "CLUB_REGISTRATION_REJECTED"
| "MANAGER_REQUEST_APPROVED"
| "MANAGER_REQUEST_REJECTED";
| "MANAGER_REQUEST_REJECTED"
| "MANAGER_TRANSFER_COMPLETED";

export type UserNotificationSourceType = "CLUB" | "CLUB_MANAGER_REQUEST";
export type UserNotificationSourceType =
| "CLUB"
| "CLUB_MANAGER_REQUEST"
| "MANAGER_TRANSFER";

export type UserNotificationMetadata = {
rejectReason?: string;
clubName?: string;
};

export type UserNotification = {
Expand Down
165 changes: 165 additions & 0 deletions apps/mobile/src/features/club/components/ManagerTransferSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { useMutation } from "@tanstack/react-query";
import { useContext, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import Toast from "react-native-toast-message";
import {
copyManagerTransferLink,
getManagerTransferErrorContent,
} from "@/features/club/utils/managerTransfer";
import type { ClubManager } from "@/repositories/club";
import AlertModal from "@/shared/components/AlertModal";
import { Colors } from "@/shared/constants/colors";
import { serviceContext } from "@/shared/contexts/serviceContext";
import { ms, s, vs } from "@/shared/utils/scale";

type ManagerTransferPhase = "idle" | "confirm" | "copied";

type Props = {
clubId: string;
clubName: string;
managers: ClubManager[];
};

const ManagerTransferSection = ({ clubId, clubName, managers }: Props) => {
const { clubService } = useContext(serviceContext);
const [phase, setPhase] = useState<ManagerTransferPhase>("idle");
const { mutate: createInvitation, isPending } = useMutation({
mutationFn: () => clubService.createManagerTransferInvitation({ clubId }),
onSuccess: ({ transferUrl }) => {
copyManagerTransferLink(transferUrl);
setPhase("copied");
},
onError: (error) => {
const content = getManagerTransferErrorContent(error);
setPhase("idle");
Toast.show({
type: "error",
text1: content.title,
text2: content.description,
});
},
});

return (
<View style={styles.section}>
<View style={styles.sectionLabel}>
<Text style={styles.sectionLabelText}>운영진 목록</Text>
</View>
<View style={styles.listContainer}>
{managers.length === 0 ? (
<View style={styles.managerRow}>
<Text style={styles.emptyText}>등록된 운영진이 없어요</Text>
</View>
) : (
managers.map((manager, index) => (
<View key={manager.serviceUserId} style={styles.managerRow}>
<View style={styles.managerIdentity}>
<Text style={styles.managerName}>{manager.name}</Text>
{!!manager.studentId && (
<Text style={styles.managerStudentId}>
{manager.studentId}
</Text>
)}
</View>
{index === 0 && (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${clubName} 관리자 권한 이전`}
style={({ pressed }) => [
styles.transferButton,
pressed && styles.transferButtonPressed,
]}
onPress={() => setPhase("confirm")}
>
<Text style={styles.transferButtonText}>
관리자 권한 이전
</Text>
</Pressable>
)}
</View>
))
)}
</View>

<AlertModal
visible={phase === "confirm"}
onClose={() => !isPending && setPhase("idle")}
title={`‘${clubName}’ 동아리의\n관리자 권한을 이전하시겠어요?`}
description={
"아래 버튼을 통해 링크를 복사하고, 관리자 권한을\n이전할 상대에게 보내주세요. 상대가 링크를 통해\n앱에 접속하면 관리자 권한이 이전돼요."
}
buttonLabel={isPending ? "링크 만드는 중..." : "링크 복사"}
onButtonPress={() => createInvitation()}
buttonDisabled={isPending}
hasCancel
dismissOnBackdropPress={!isPending}
/>

<AlertModal
visible={phase === "copied"}
onClose={() => setPhase("idle")}
title="링크 복사 완료!"
description="관리자 권한을 이전할 상대에게\n링크를 보내주세요"
buttonLabel="확인"
onButtonPress={() => setPhase("idle")}
/>
</View>
);
};

export default ManagerTransferSection;

const styles = StyleSheet.create({
section: { gap: 0 },
sectionLabel: {
height: vs(34),
justifyContent: "center",
paddingHorizontal: s(5),
},
sectionLabelText: {
fontFamily: "Pretendard-Medium",
fontSize: ms(14),
color: Colors.BODYTEXT_SUB,
},
listContainer: { gap: vs(10) },
managerRow: {
minHeight: vs(58),
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: Colors.WHITE,
borderRadius: ms(12),
paddingHorizontal: s(15),
paddingVertical: vs(10),
},
emptyText: {
flex: 1,
textAlign: "center",
fontFamily: "Pretendard-Medium",
fontSize: ms(12),
color: Colors.BODYTEXT_SUB,
},
managerIdentity: { flex: 1, gap: vs(3) },
managerName: {
fontFamily: "Pretendard-SemiBold",
fontSize: ms(13),
color: Colors.BODYTEXT_MAIN,
},
managerStudentId: {
fontFamily: "Pretendard-Regular",
fontSize: ms(12),
color: Colors.BODYTEXT_SUB,
},
transferButton: {
backgroundColor: Colors.POINTCOLOR,
borderRadius: ms(12),
paddingHorizontal: s(12),
paddingVertical: vs(8),
},
transferButtonPressed: { backgroundColor: Colors.BUTTON_PUSH },
transferButtonText: {
fontFamily: "Pretendard-SemiBold",
fontSize: ms(10),
color: Colors.WHITE,
},
});
Loading
Loading