diff --git a/app/_layout.tsx b/app/_layout.tsx index e49a771..a408175 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -127,6 +127,7 @@ function AuthGate() { const group = segmentList[0] const screen = segmentList[1] const inAuthGroup = group === '(auth)' + const inOAuthCallback = group === 'oauth' const hasOnboardingToken = typeof onboardingToken === 'string' && onboardingToken.trim().length > 0 const isLoginRoute = inAuthGroup && screen === 'login' @@ -134,6 +135,10 @@ function AuthGate() { const isOnboardingRoute = inAuthGroup && screen === 'onboarding' const isMidSignupRoute = isAgreementRoute || isOnboardingRoute + if (inOAuthCallback) { + return + } + if (isAuthenticated && inAuthGroup) { router.replace('/(tabs)') return @@ -145,11 +150,11 @@ function AuthGate() { } if (isLoginRoute || !inAuthGroup) { - router.replace('/(auth)/agreement') + router.replace('/agreement') return } - router.replace('/(auth)/agreement') + router.replace('/agreement') return } @@ -203,6 +208,7 @@ function RootLayoutNav() { + {/* Works detail screen — header managed by Stack.Screen inside the screen */} diff --git a/app/oauth/x.tsx b/app/oauth/x.tsx new file mode 100644 index 0000000..e025cc3 --- /dev/null +++ b/app/oauth/x.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef } from 'react' +import { ActivityIndicator, Alert, StyleSheet, Text, View } from 'react-native' +import { useLocalSearchParams, useRouter } from 'expo-router' + +import { useXLogin } from '../../src/features/auth/hooks' +import { + clearPendingXOAuthSession, + getPendingXOAuthSession, + X_OAUTH_CONFIG, +} from '../../src/features/auth/lib/xOAuth' +import { C, Typography } from '../../src/theme' + +export default function XOAuthCallbackScreen() { + const params = useLocalSearchParams<{ + code?: string + state?: string + error?: string + error_description?: string + }>() + const router = useRouter() + const xLoginMutation = useXLogin() + const handledRef = useRef(false) + + useEffect(() => { + if (handledRef.current) return + handledRef.current = true + + const completeLogin = async () => { + const code = Array.isArray(params.code) ? params.code[0] : params.code + const state = Array.isArray(params.state) ? params.state[0] : params.state + const oauthError = Array.isArray(params.error) + ? params.error[0] + : params.error + const oauthErrorDescription = Array.isArray(params.error_description) + ? params.error_description[0] + : params.error_description + + console.log('[X OAuth Callback] params:', { + hasCode: !!code, + state, + error: oauthError, + errorDescription: oauthErrorDescription, + }) + + if (oauthError) { + await clearPendingXOAuthSession() + Alert.alert('로그인 실패', 'X 로그인이 취소되었거나 실패했어요.') + router.replace('/(auth)/login') + return + } + + if (!code || !state) { + await clearPendingXOAuthSession() + Alert.alert('로그인 실패', 'X 로그인 응답이 올바르지 않아요.') + router.replace('/(auth)/login') + return + } + + const pendingSession = await getPendingXOAuthSession() + + if (!pendingSession) { + Alert.alert('로그인 실패', 'X 로그인 세션이 만료되었어요. 다시 시도해주세요.') + router.replace('/(auth)/login') + return + } + + if (pendingSession.state !== state) { + await clearPendingXOAuthSession() + Alert.alert('로그인 실패', 'X 로그인 검증에 실패했어요. 다시 시도해주세요.') + router.replace('/(auth)/login') + return + } + + await clearPendingXOAuthSession() + xLoginMutation.mutate({ + code, + redirectUri: X_OAUTH_CONFIG.redirectUri, + codeVerifier: pendingSession.codeVerifier, + }) + } + + void completeLogin() + }, [params, router, xLoginMutation]) + + return ( + + + X 로그인 처리 중입니다. + + ) +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + backgroundColor: C.card, + }, + text: { + ...Typography.body2Medium, + color: C.text, + }, +}) diff --git a/assets/common/cardshare/image-gallery-saved.svg b/assets/common/cardshare/image-gallery-saved.svg new file mode 100644 index 0000000..92a7fbc --- /dev/null +++ b/assets/common/cardshare/image-gallery-saved.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/common/comment-dropdown.svg b/assets/icons/common/comment-dropdown.svg index 15a46b8..46c000d 100644 --- a/assets/icons/common/comment-dropdown.svg +++ b/assets/icons/common/comment-dropdown.svg @@ -1,4 +1,20 @@ - - + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/common/icon-download.svg b/assets/icons/common/icon-download.svg new file mode 100644 index 0000000..8a4419b --- /dev/null +++ b/assets/icons/common/icon-download.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/common/icon-help.svg b/assets/icons/common/icon-help.svg new file mode 100644 index 0000000..06583b2 --- /dev/null +++ b/assets/icons/common/icon-help.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/common/icon-twitter.svg b/assets/icons/common/icon-twitter.svg new file mode 100644 index 0000000..d4eaa34 --- /dev/null +++ b/assets/icons/common/icon-twitter.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/feed/block-done.svg b/assets/icons/feed/block-done.svg new file mode 100644 index 0000000..5708d45 --- /dev/null +++ b/assets/icons/feed/block-done.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/library/review-card-title.svg b/assets/icons/library/review-card-title.svg new file mode 100644 index 0000000..ddecf96 --- /dev/null +++ b/assets/icons/library/review-card-title.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/profile/icon-library.svg b/assets/icons/profile/icon-library.svg new file mode 100644 index 0000000..860f01a --- /dev/null +++ b/assets/icons/profile/icon-library.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/profile/icon-liked.svg b/assets/icons/profile/icon-liked.svg new file mode 100644 index 0000000..16ca330 --- /dev/null +++ b/assets/icons/profile/icon-liked.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/profile/id-card-title.svg b/assets/icons/profile/id-card-title.svg new file mode 100644 index 0000000..3acba79 --- /dev/null +++ b/assets/icons/profile/id-card-title.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/profile/review.svg b/assets/icons/profile/review.svg new file mode 100644 index 0000000..e744942 --- /dev/null +++ b/assets/icons/profile/review.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/expo-start.err.log b/expo-start.err.log new file mode 100644 index 0000000..aa2bcb9 --- /dev/null +++ b/expo-start.err.log @@ -0,0 +1,5 @@ +The following packages should be updated for best compatibility with the installed expo version: + expo@54.0.30 - expected version: ~54.0.35 + expo-font@14.0.11 - expected version: ~14.0.12 + expo-router@6.0.23 - expected version: ~6.0.24 +Your project may not work correctly until you install the expected versions of the packages. diff --git a/expo-start.out.log b/expo-start.out.log new file mode 100644 index 0000000..08ec9e2 --- /dev/null +++ b/expo-start.out.log @@ -0,0 +1,7 @@ +env: load .env.local +env: export EXPO_PUBLIC_API_URL KAKAO_CLIENT_ID KAKAO_REDIRECT_URI NAVER_CLIENT_ID NAVER_REDIRECT_URI EXPO_PUBLIC_NAVER_CLIENT_ID EXPO_PUBLIC_KAKAO_CLIENT_ID EXPO_PUBLIC_KAKAO_REDIRECT_URI EXPO_PUBLIC_NAVER_REDIRECT_URI EXPO_PUBLIC_KAKAO_NATIVE_APP_KEY EXPO_PUBLIC_NAVER_CLIENT_SECRET EXPO_PUBLIC_NAVER_APP_NAME EXPO_PUBLIC_NAVER_URL_SCHEME EXPO_PUBLIC_GA_ID +Starting project at C:\Users\chaet\anaconda3\FE\STORIX\STORIX-FE-2.1 +Starting Metro Bundler +Waiting on http://localhost:8081 +Logs for your project will appear below. +› Detected a change in metro.config.js. Restart the server to see the new results. diff --git a/expo-web.err.log b/expo-web.err.log new file mode 100644 index 0000000..f79221a --- /dev/null +++ b/expo-web.err.log @@ -0,0 +1,20 @@ +The following packages should be updated for best compatibility with the installed expo version: + expo@54.0.30 - expected version: ~54.0.35 + expo-font@14.0.11 - expected version: ~14.0.12 + expo-router@6.0.23 - expected version: ~6.0.24 +Your project may not work correctly until you install the expected versions of the packages. +"shadow*" style props are deprecated. Use "boxShadow". +Require cycle: src/features/plus/index.ts -> src/features/plus/ui/index.ts -> src/features/plus/ui/FeedWriteEntryScreen.tsx -> src/features/profile/index.ts -> src/features/profile/ui/index.ts -> src/features/profile/ui/ProfileScreen.tsx -> src/features/profile/ui/ProfileRatingSection.tsx -> src/features/plus/index.ts + +Require cycles are allowed, but can result in uninitialized values. Consider refactoring to remove the need for a cycle. +"shadow*" style props are deprecated. Use "boxShadow". +Require cycle: src/features/plus/index.ts -> src/features/plus/ui/index.ts -> src/features/plus/ui/FeedWriteEntryScreen.tsx -> src/features/profile/index.ts -> src/features/profile/ui/index.ts -> src/features/profile/ui/ProfileScreen.tsx -> src/features/profile/ui/ProfileRatingSection.tsx -> src/features/plus/index.ts + +Require cycles are allowed, but can result in uninitialized values. Consider refactoring to remove the need for a cycle. +"shadow*" style props are deprecated. Use "boxShadow". +Require cycle: src/features/plus/index.ts -> src/features/plus/ui/index.ts -> src/features/plus/ui/FeedWriteEntryScreen.tsx -> src/features/profile/index.ts -> src/features/profile/ui/index.ts -> src/features/profile/ui/ProfileScreen.tsx -> src/features/profile/ui/ProfileRatingSection.tsx -> src/features/plus/index.ts + +Require cycles are allowed, but can result in uninitialized values. Consider refactoring to remove the need for a cycle. +Error: Premature close + at onclose (node:internal/streams/end-of-stream:162:30) + at processTicksAndRejections (node:internal/process/task_queues:85:11) diff --git a/expo-web.out.log b/expo-web.out.log new file mode 100644 index 0000000..5d147db --- /dev/null +++ b/expo-web.out.log @@ -0,0 +1,26 @@ +env: load .env.local +env: export EXPO_PUBLIC_API_URL KAKAO_CLIENT_ID KAKAO_REDIRECT_URI NAVER_CLIENT_ID NAVER_REDIRECT_URI EXPO_PUBLIC_NAVER_CLIENT_ID EXPO_PUBLIC_KAKAO_CLIENT_ID EXPO_PUBLIC_KAKAO_REDIRECT_URI EXPO_PUBLIC_NAVER_REDIRECT_URI EXPO_PUBLIC_KAKAO_NATIVE_APP_KEY EXPO_PUBLIC_NAVER_CLIENT_SECRET EXPO_PUBLIC_NAVER_APP_NAME EXPO_PUBLIC_NAVER_URL_SCHEME EXPO_PUBLIC_GA_ID +Starting project at C:\Users\chaet\anaconda3\FE\STORIX\STORIX-FE-2.1 +Starting Metro Bundler +Waiting on http://localhost:8081 +Logs for your project will appear below. +Web .\index.js ▓▓▓▓▓▓▓▓▓▓▓▓░░░░ 79.1% (1443/1622) +λ node_modules\expo-router\node\render.js ▓▓▓▓▓▓▓▓▓▓░░░░░░ 67.0% (1250/1527) +Web .\index.js ░░░░░░░░░░░░░░░░ 0.0% (0/1) +λ node_modules\expo-router\node\render.js ░░░░░░░░░░░░░░░░ 0.0% (0/1) +λ Bundled 4227ms node_modules\expo-router\node\render.js (2228 modules) +λ Bundled 4287ms node_modules\expo-router\node\render.js (1 module) +Web .\index.js ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ 97.6% (2071/2096) +Web .\index.js ░░░░░░░░░░░░░░░░ 0.0% (0/1) +Web Bundled 10210ms index.js (2096 modules) +Web .\index.js ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ 97.6% (2071/2096) +Web .\index.js ░░░░░░░░░░░░░░░░ 0.0% (0/1) +Web Bundled 10273ms index.js (1 module) +Web .\index.js ▓▓▓▓▓▓▓▓▓▓▓▓░░░░ 81.1% (1664/1848) +Web Bundled 2453ms index.js (2126 modules) + LOG [web] Logs will appear in the browser console +Web .\index.js ░░░░░░░░░░░░░░░░ 0.0% (0/1) +λ Bundled 757ms node_modules\expo-router\node\render.js (1 module) +Web .\index.js ░░░░░░░░░░░░░░░░ 0.0% (0/1) +Web Bundled 10768ms index.js (1 module) +Web .\index.js ░░░░░░░░░░░░░░░░ 0.0% (0/1) diff --git a/package-lock.json b/package-lock.json index e60ceb2..ff1361e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,16 +20,20 @@ "@tanstack/react-query": "^5.100.6", "axios": "^1.15.2", "expo": "~54.0.30", + "expo-blur": "~15.0.8", "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.9", "expo-font": "~14.0.11", "expo-image": "~3.0.11", "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.11", "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", + "expo-media-library": "~18.2.1", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-web-browser": "~15.0.10", @@ -40,7 +44,9 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-share": "^12.3.1", "react-native-svg": "15.12.1", + "react-native-view-shot": "4.0.3", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "text-encoding": "^0.7.0", @@ -4574,6 +4580,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5157,6 +5172,15 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -5700,6 +5724,17 @@ "react-native": "*" } }, + "node_modules/expo-blur": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.8.tgz", + "integrity": "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-build-properties": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-1.0.10.tgz", @@ -5739,6 +5774,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "15.0.9", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz", + "integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.22", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", @@ -5848,6 +5895,16 @@ "react-native": "*" } }, + "node_modules/expo-media-library": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-18.2.1.tgz", + "integrity": "sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.23", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.23.tgz", @@ -6148,6 +6205,15 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "31.0.13", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", @@ -7317,6 +7383,19 @@ "license": "MIT", "optional": true }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -9973,6 +10052,15 @@ "react-native": "*" } }, + "node_modules/react-native-share": { + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-12.3.1.tgz", + "integrity": "sha512-mRVRie0qKbtj+jWqJ2POPkeSd8SxnMp6aazFZVmq8ZbkUGtQLENR/L58ky8zH4VUEF/WYRmdBiBHWHVtzOewtQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/react-native-svg": { "version": "15.12.1", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", @@ -9988,6 +10076,19 @@ "react-native": "*" } }, + "node_modules/react-native-view-shot": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz", + "integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==", + "license": "MIT", + "dependencies": { + "html2canvas": "^1.4.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", @@ -11260,6 +11361,15 @@ "deprecated": "no longer maintained", "license": "(Unlicense OR Apache-2.0)" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -11578,6 +11688,15 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", diff --git a/package.json b/package.json index 9974d96..662fa14 100644 --- a/package.json +++ b/package.json @@ -25,16 +25,20 @@ "@tanstack/react-query": "^5.100.6", "axios": "^1.15.2", "expo": "~54.0.30", + "expo-blur": "~15.0.8", "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.9", "expo-font": "~14.0.11", "expo-image": "~3.0.11", "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.11", "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", + "expo-media-library": "~18.2.1", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-web-browser": "~15.0.10", @@ -45,7 +49,9 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-share": "^12.3.1", "react-native-svg": "15.12.1", + "react-native-view-shot": "4.0.3", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "text-encoding": "^0.7.0", diff --git a/src/components/works/RecordCardModal.tsx b/src/components/works/RecordCardModal.tsx new file mode 100644 index 0000000..43902cc --- /dev/null +++ b/src/components/works/RecordCardModal.tsx @@ -0,0 +1,401 @@ +import { Modal, Pressable, StyleSheet, Text, View, ActivityIndicator } from 'react-native' +import { Image } from 'expo-image' +import { useRef, useState } from 'react' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import ViewShot from 'react-native-view-shot' +import Svg, { + ClipPath, + Defs, + G, + Image as SvgImage, + LinearGradient, + Path, + Rect, + Stop, +} from 'react-native-svg' +import { C, Gray, Typography } from '../../theme' +import { useCardShare } from '../../features/profile/hooks/useCardShare' + +const recordCardTitle = require('../../../assets/icons/library/review-card-title.svg') +const closeIcon = require('../../../assets/icons/common/x.svg') +const downloadIcon = require('../../../assets/icons/common/icon-download.svg') +const shareIcon = require('../../../assets/icons/common/icon-share.svg') +const twitterIcon = require('../../../assets/icons/common/icon-twitter.svg') +const star = require('../../../assets/onboarding/star-gray.svg') +const storixLogo = require('../../../assets/logos/logo-white.svg') + +const CARD_WIDTH = 322 +const CARD_HEIGHT = 429 +const REVIEW_CARD_OUTLINE = + 'M16 0H145C153.837 0 161 7.163 161 16C161 7.163 168.163 0 177 0H306C314.837 0 322 7.163 322 16V145C322 153.837 314.837 161 306 161C314.837 161 322 168.163 322 177V413C322 421.837 314.837 429 306 429H16C7.163 429 0 421.837 0 413V177C0 168.163 7.163 161 16 161C7.163 161 0 153.837 0 145V16C0 7.163 7.163 0 16 0Z' + +export type RecordCardModalProps = { + visible: boolean + onClose: () => void + coverImageUrl?: string | null + nickname: string + createdAt: string + reviewContent: string + worksTitle: string + rating: number + onSaveSuccess?: () => void +} + +const formatDate = (value: string) => { + const trimmed = value.trim() + if (!trimmed) return '' + + const dateParts = trimmed.match(/^(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})/) + if (dateParts) { + const [, yyyy, mm, dd] = dateParts + return `${yyyy}.${mm.padStart(2, '0')}.${dd.padStart(2, '0')}` + } + + const d = new Date(trimmed) + if (Number.isNaN(d.getTime())) return trimmed + + const yyyy = d.getFullYear() + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + return `${yyyy}.${mm}.${dd}` +} + +const truncateText = (text: string, maxLength: number) => { + if (text.length <= maxLength) return text + return text.substring(0, maxLength) + '...' +} + +function ReviewCardSurface({ + imageUrl, + isMagentaTheme, +}: { + imageUrl?: string | null + isMagentaTheme: boolean +}) { + return ( + + + + + + {isMagentaTheme ? ( + + + + + ) : ( + + + + + + + )} + + + + + {imageUrl ? ( + + ) : null} + + + + ) +} + +export function RecordCardModal({ + visible, + onClose, + coverImageUrl, + nickname, + createdAt, + reviewContent, + worksTitle, + rating, + onSaveSuccess, +}: RecordCardModalProps) { + const insets = useSafeAreaInsets() + const viewShotRef = useRef(null) + const [isMagentaTheme, setIsMagentaTheme] = useState(false) + const { saveToGallery, shareImage, shareToTwitter, isSaving, isSharing } = useCardShare() + + const captureCard = async (): Promise => { + if (!viewShotRef.current) return null + try { + const uri = await viewShotRef.current.capture?.() + return uri ?? null + } catch (error) { + console.error('Capture error:', error) + return null + } + } + + return ( + + + + + + + + + { + event.stopPropagation() + setIsMagentaTheme((prev) => !prev) + }} + > + 테마 변경 + + + + event.stopPropagation()}> + + + + + + + + + + + + {rating.toFixed(1)} + + + + {worksTitle} + + + + {truncateText(reviewContent, 220)} + + + + {nickname} · {formatDate(createdAt)} + + + + + + + + { + saveToGallery(captureCard, () => { + onClose() + onSaveSuccess?.() + }, 'STORIX 기록카드') + }} + disabled={isSaving} + style={styles.actionButton} + > + + {isSaving ? ( + + ) : ( + + )} + + 저장 + + + shareImage(captureCard, 'STORIX 기록카드')} + disabled={isSharing} + style={styles.actionButton} + > + + {isSharing ? ( + + ) : ( + + )} + + 공유 + + + shareToTwitter(captureCard, 'STORIX 기록카드')} + style={styles.actionButton} + > + + + + X에 공유 + + + + + + + ) +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(19, 17, 18, 0.70)', + }, + backdropPressable: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + closeButton: { + position: 'absolute', + left: 16, + width: 24, + height: 24, + zIndex: 10, + }, + closeIcon: { + width: 24, + height: 24, + }, + contentWrapper: { + width: CARD_WIDTH, + }, + themeButton: { + position: 'absolute', + top: -36, + right: 0, + zIndex: 20, + minHeight: 26, + paddingHorizontal: 10, + justifyContent: 'center', + borderRadius: 13, + backgroundColor: C.card, + }, + themeButtonText: { + fontFamily: 'SUITBold', + fontSize: 11, + lineHeight: 15.4, + color: Gray[900], + }, + cardContainer: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + position: 'relative', + }, + cardContent: { + position: 'relative', + zIndex: 1, + flex: 1, + paddingVertical: 32, + paddingHorizontal: 20, + justifyContent: 'space-between', + }, + topSection: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + cardTitleImage: { + width: 227, + height: 74, + }, + logoImage: { + width: 28, + height: 28, + marginTop: 4, + }, + bottomSection: { + gap: 8, + }, + ratingBadge: { + flexDirection: 'row', + paddingHorizontal: 8, + paddingVertical: 4, + gap: 4, + borderRadius: 13, + backgroundColor: '#FF4093', + alignSelf: 'flex-start', + alignItems: 'center', + }, + starIcon: { + width: 14, + height: 14, + }, + ratingText: { + fontFamily: 'SUITExtraBold', + fontSize: 12, + lineHeight: 16.8, + color: '#FFE1ED', + }, + worksTitle: { + fontFamily: 'SUITBold', + fontSize: 18, + lineHeight: 25.2, + color: C.card, + }, + reviewContent: { + fontFamily: 'SUITMedium', + fontSize: 14, + lineHeight: 19.6, + color: C.card, + textAlign: 'justify', + }, + metaText: { + fontFamily: 'SUITExtraBold', + fontSize: 12, + lineHeight: 16.8, + color: C.card, + }, + actionButtons: { + height: 136, + paddingHorizontal: 67, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + actionButton: { + width: 48, + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + actionButtonCircle: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: C.card, + alignItems: 'center', + justifyContent: 'center', + }, + actionIcon: { + width: 24, + height: 24, + }, + twitterIcon: { + width: 48, + height: 48, + }, + actionButtonText: { + ...Typography.caption1Medium, + color: C.card, + textAlign: 'center', + }, +}) diff --git a/src/components/works/ReviewDetailScreen.tsx b/src/components/works/ReviewDetailScreen.tsx index 9ad5e4e..48b870c 100644 --- a/src/components/works/ReviewDetailScreen.tsx +++ b/src/components/works/ReviewDetailScreen.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { ActivityIndicator, Alert, + Modal, Pressable, ScrollView, StyleSheet, @@ -22,6 +23,7 @@ import { C } from '../../theme/colors' import { Radius } from '../../theme/radius' import { Typography } from '../../theme/typography' import { ReviewSpoilerBlock } from './ReviewSpoilerBlock' +import { RecordCardModal } from './RecordCardModal' const backIcon = require('../../../assets/icons/common/back.svg') const reviewProfileIcon = require('../../../assets/icons/common/reviewProfile.svg') @@ -29,6 +31,7 @@ const littleStarIcon = require('../../../assets/icons/common/littleStar.svg') const likeIcon = require('../../../assets/icons/common/icon-like.svg') const likePinkIcon = require('../../../assets/icons/common/icon-like-pink.svg') const menuDotsIcon = require('../../../assets/icons/common/menu-3dots.svg') +const savedToast = require('../../../assets/common/cardshare/image-gallery-saved.svg') type Props = { reviewId: number @@ -48,6 +51,8 @@ const formatKoreanDate = (iso?: string) => { export function ReviewDetailScreen({ reviewId }: Props) { const router = useRouter() const insets = useSafeAreaInsets() + const [showRecordCard, setShowRecordCard] = useState(false) + const [showSavedToast, setShowSavedToast] = useState(false) const isValidReviewId = Number.isFinite(reviewId) && reviewId > 0 const { data, isLoading, isError } = useWorksReviewDetail( @@ -195,6 +200,8 @@ export function ReviewDetailScreen({ reviewId }: Props) { onBack={handleBack} onPressMenu={isMine ? onConfirmDelete : undefined} showMenu={isMine} + onPressRecordCard={() => setShowRecordCard(true)} + showRecordCard={isMine} /> + + {/* 기록카드 모달 */} + setShowRecordCard(false)} + coverImageUrl={ui.coverSrc} + nickname={ui.userName} + createdAt={data?.lastCreatedTime ?? data?.createdAt ?? ''} + reviewContent={ui.content} + worksTitle={ui.worksTitle} + rating={ui.rating ?? 0} + onSaveSuccess={() => { + setShowSavedToast(true) + setTimeout(() => setShowSavedToast(false), 1500) + }} + /> + + {/* 저장 완료 토스트 */} + {showSavedToast && ( + + + + + + )} ) } @@ -305,11 +337,15 @@ function TopBar({ onBack, onPressMenu, showMenu = false, + onPressRecordCard, + showRecordCard = false, }: { topInset: number onBack: () => void onPressMenu?: () => void showMenu?: boolean + onPressRecordCard?: () => void + showRecordCard?: boolean }) { return ( @@ -325,9 +361,24 @@ function TopBar({ - 리뷰 + + 리뷰 + - + + {showRecordCard && onPressRecordCard && ( + [ + topBarStyles.recordCardButton, + pressed && topBarStyles.pressed, + ]} + onPress={onPressRecordCard} + accessibilityRole="button" + accessibilityLabel="기록카드" + > + 기록카드 + + )} {showMenu && onPressMenu ? ( [ @@ -344,7 +395,7 @@ function TopBar({ contentFit="contain" /> - ) : null} + ) : } ) @@ -369,16 +420,43 @@ const topBarStyles = StyleSheet.create({ width: 24, height: 24, }, + titleWrapper: { + position: 'absolute', + left: 0, + right: 0, + height: 32, + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none', + }, title: { ...Typography.body1Medium, color: C.text, + textAlign: 'center', + lineHeight: 32, }, - rightSpacer: { - width: 32, - height: 32, - alignItems: 'flex-end', + rightActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + recordCardButton: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 24, + borderWidth: 1, + borderColor: '#E3DCDF', + backgroundColor: '#FFF', + alignItems: 'center', justifyContent: 'center', }, + recordCardText: { + fontFamily: 'SUIT', + fontSize: 12, + fontWeight: '700', + lineHeight: 16.8, + color: '#645C5F', + }, pressed: { opacity: 0.7, }, @@ -531,4 +609,17 @@ const styles = StyleSheet.create({ pressed: { opacity: 0.7, }, + toastContainer: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + justifyContent: 'center', + zIndex: 100, + elevation: 100, + }, + toastImage: { + width: 320, + height: 82, + }, }) diff --git a/src/features/auth/api/auth.schema.ts b/src/features/auth/api/auth.schema.ts index 39b0c06..58d08a7 100644 --- a/src/features/auth/api/auth.schema.ts +++ b/src/features/auth/api/auth.schema.ts @@ -116,9 +116,9 @@ export const SignupRequestSchema = z.object({ privacyPolicyAgree: z.boolean(), ageOver14: z.boolean(), nickName: z.string().min(1), + profileDescription: z.string(), favoriteGenreList: z.array(GenreKeySchema), favoriteWorksIdList: z.array(z.number()), - profileDescription: z.string(), }) export const SignupResponseSchema = ApiResponseSchema( diff --git a/src/features/auth/api/index.ts b/src/features/auth/api/index.ts index cf469b7..f0ec3e1 100644 --- a/src/features/auth/api/index.ts +++ b/src/features/auth/api/index.ts @@ -2,6 +2,7 @@ export * from './auth.schema' export * from './kakao.api' export * from './naver.api' export * from './apple.api' +export * from './x.api' export * from './signup.api' export * from './logout.api' export * from './nickname.api' diff --git a/src/features/auth/api/naver.api.ts b/src/features/auth/api/naver.api.ts index 213caa9..0e2491d 100644 --- a/src/features/auth/api/naver.api.ts +++ b/src/features/auth/api/naver.api.ts @@ -19,14 +19,18 @@ export const naverLogin = async (args: { } /** - * Naver native login — iOS/Android SDK supplies the accessToken directly. + * Naver native login — iOS/Android SDK supplies the accessToken and refreshToken directly. */ export const naverNativeLogin = async (args: { accessToken: string + refreshToken: string }): Promise => { const response = await apiClient.post( '/api/v1/auth/oauth/naver-native/login', - { accessToken: args.accessToken }, + { + accessToken: args.accessToken, + refreshToken: args.refreshToken, + }, ) return SocialLoginResponseSchema.parse(response.data) } diff --git a/src/features/auth/api/x.api.ts b/src/features/auth/api/x.api.ts new file mode 100644 index 0000000..92d562c --- /dev/null +++ b/src/features/auth/api/x.api.ts @@ -0,0 +1,42 @@ +import { apiClient } from '../../../lib/api/axios-instance' +import { + SocialLoginResponseSchema, + type SocialLoginResponse, +} from './auth.schema' + +/** + * X (Twitter) OAuth 2.0 PKCE login + * Exchanges an authorization code + code_verifier for tokens via the backend. + * + * @param code - One-time authorization code (30s expiry) + * @param redirectUri - Must match the redirect URI used in authorization request + * @param codeVerifier - PKCE code verifier (generated with code_challenge) + */ +export const xLogin = async (args: { + code: string + redirectUri: string + codeVerifier: string +}): Promise => { + console.log('[X Login API] request:', { + hasCode: !!args.code, + redirectUri: args.redirectUri, + codeVerifierLength: args.codeVerifier.length, + }) + + const response = await apiClient.get('/api/v1/auth/oauth/x/login', { + params: { + code: args.code, + redirectUri: args.redirectUri, + codeVerifier: args.codeVerifier, + }, + }) + + console.log('[X Login API] response:', { + status: response.status, + code: response.data?.code, + isSuccess: response.data?.isSuccess, + isRegistered: response.data?.result?.isRegistered, + }) + + return SocialLoginResponseSchema.parse(response.data) +} diff --git a/src/features/auth/hooks/index.ts b/src/features/auth/hooks/index.ts index 61bad1b..89de783 100644 --- a/src/features/auth/hooks/index.ts +++ b/src/features/auth/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useKakaoLogin' export * from './useNativeSocialLogin' +export * from './useXLogin' export * from './useSignup' diff --git a/src/features/auth/hooks/useNativeSocialLogin.ts b/src/features/auth/hooks/useNativeSocialLogin.ts index d632db5..bcd5064 100644 --- a/src/features/auth/hooks/useNativeSocialLogin.ts +++ b/src/features/auth/hooks/useNativeSocialLogin.ts @@ -41,8 +41,8 @@ const callBackend = async ( } return kakaoNativeLogin({ accessToken, idToken }); } - const { accessToken } = await nativeSocialAuthProvider.loginWithNaver(); - return naverNativeLogin({ accessToken }); + const { accessToken, refreshToken } = await nativeSocialAuthProvider.loginWithNaver(); + return naverNativeLogin({ accessToken, refreshToken }); }; // ─── hook ───────────────────────────────────────────────────────────────────── diff --git a/src/features/auth/hooks/useSignup.ts b/src/features/auth/hooks/useSignup.ts index 4badf14..9341c00 100644 --- a/src/features/auth/hooks/useSignup.ts +++ b/src/features/auth/hooks/useSignup.ts @@ -14,6 +14,7 @@ import { useAuthStore } from '../../../store/auth.store' export const useSignup = () => { const setLoginTokens = useAuthStore((s) => s.setLoginTokens) + const clearAuth = useAuthStore((s) => s.clearAuth) return useMutation({ mutationFn: async (data: SignupRequest) => { @@ -32,11 +33,24 @@ export const useSignup = () => { onError: (error) => { if (isAxiosError(error)) { + const code = + typeof error.response?.data === 'object' && error.response?.data + ? (error.response.data as { code?: unknown }).code + : undefined + console.log('[useSignup] failed:', { message: error.message, status: error.response?.status, data: error.response?.data, }) + console.log( + '[useSignup] failed detail:', + JSON.stringify(error.response?.data, null, 2), + ) + + if (code === 'TOKEN_ERROR_001' || code === 'TOKEN_ERROR_002') { + void clearAuth() + } return } diff --git a/src/features/auth/hooks/useXLogin.ts b/src/features/auth/hooks/useXLogin.ts new file mode 100644 index 0000000..5d75b98 --- /dev/null +++ b/src/features/auth/hooks/useXLogin.ts @@ -0,0 +1,75 @@ +// src/features/auth/hooks/useXLogin.ts +// +// X (Twitter) OAuth 2.0 PKCE login +// Exchanges an authorization code + code_verifier for tokens. + +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'expo-router' +import { Alert } from 'react-native' +import { AxiosError } from 'axios' + +import { xLogin } from '../api/x.api' +import { + extractLoginTokens, + type SocialLoginResponse, +} from '../api/auth.schema' +import { useAuthStore } from '../../../store/auth.store' +import { setItem } from '../../../lib/storage/async' +import { SOCIAL_PROVIDER_KEY } from '../../profile/hooks/useSocialProvider' + +export const useXLogin = () => { + const router = useRouter() + const setLoginTokens = useAuthStore((s) => s.setLoginTokens) + const setOnboardingToken = useAuthStore((s) => s.setOnboardingToken) + + return useMutation({ + mutationFn: (args: { + code: string + redirectUri: string + codeVerifier: string + }) => xLogin(args), + + onSuccess: async (data: SocialLoginResponse) => { + console.log('[useXLogin] success:', { + isRegistered: data.result.isRegistered, + hasRegularLoginResponse: !!data.result.regularLoginResponse, + hasReaderLoginResponse: !!data.result.readerLoginResponse, + hasPreLoginResponse: !!data.result.readerPreLoginResponse, + }) + + const { isRegistered, readerPreLoginResponse } = data.result + const loginTokens = extractLoginTokens(data.result) + + if (isRegistered && loginTokens) { + await Promise.all([ + setLoginTokens(loginTokens), + setItem(SOCIAL_PROVIDER_KEY, 'x'), + ]) + router.replace('/(tabs)') + return + } + + if (!isRegistered && readerPreLoginResponse?.onboardingToken) { + await setOnboardingToken(readerPreLoginResponse.onboardingToken) + console.log('[useXLogin] onboarding token stored, navigating to agreement') + router.replace('/agreement' as never) + return + } + + Alert.alert( + '로그인 오류', + '로그인 처리 중 오류가 발생했어요. 다시 시도해주세요.', + ) + }, + + onError: (error: AxiosError) => { + console.error('[useXLogin] failed:', { + message: error.message, + status: error.response?.status, + data: error.response?.data, + }) + Alert.alert('로그인 실패', '로그인에 실패했어요. 다시 시도해주세요.') + router.replace('/(auth)/login') + }, + }) +} diff --git a/src/features/auth/lib/xOAuth.ts b/src/features/auth/lib/xOAuth.ts new file mode 100644 index 0000000..2842035 --- /dev/null +++ b/src/features/auth/lib/xOAuth.ts @@ -0,0 +1,208 @@ +// src/features/auth/lib/xOAuth.ts +// +// X (Twitter) OAuth 2.0 PKCE flow utilities + +import * as WebBrowser from 'expo-web-browser' +import * as Crypto from 'expo-crypto' +import { Platform } from 'react-native' +import { getItem, removeItem, setItem } from '../../../lib/storage/async' + +/** + * X OAuth 2.0 configuration + */ +export const X_OAUTH_CONFIG = { + authorizationEndpoint: 'https://x.com/i/oauth2/authorize', + clientId: + process.env.EXPO_PUBLIC_X_CLIENT_ID ?? + 'ZzFZMEV3X19ydnBnR09IZ2FNNkg6MTpjaQ', + redirectUri: process.env.EXPO_PUBLIC_X_REDIRECT_URI ?? 'storixfe21://oauth/x', + scopes: ['tweet.read', 'users.read', 'offline.access'], +} as const + +const X_OAUTH_SESSION_KEY = 'auth.x.oauthSession' + +export type PendingXOAuthSession = { + codeVerifier: string + state: string +} + +/** + * PKCE (Proof Key for Code Exchange) utilities + * Uses S256 (SHA-256) challenge method + */ + +const toBase64Url = (bytes: Uint8Array): string => + btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + +const buildQueryString = (params: Record): string => + Object.entries(params) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + ) + .join('&') + +const getAndroidBrowserPackage = async (): Promise => { + if (Platform.OS !== 'android') return undefined + + try { + const browsers = await WebBrowser.getCustomTabsSupportingBrowsersAsync() + console.log('[X OAuth] Custom Tabs browsers:', browsers) + + // Samsung Internet can get stuck in an x.com self-redirect loop for OAuth. + // Chrome is installed on our Android test device and handles this flow more reliably. + if ( + browsers.browserPackages.includes('com.android.chrome') || + browsers.servicePackages.includes('com.android.chrome') + ) { + return 'com.android.chrome' + } + + return browsers.preferredBrowserPackage ?? browsers.defaultBrowserPackage + } catch (error) { + console.warn('[X OAuth] Failed to resolve Custom Tabs browser:', error) + return 'com.android.chrome' + } +} + +export const storePendingXOAuthSession = async ( + session: PendingXOAuthSession, +): Promise => { + await setItem(X_OAUTH_SESSION_KEY, session) +} + +export const getPendingXOAuthSession = + async (): Promise => { + return getItem(X_OAUTH_SESSION_KEY) + } + +export const clearPendingXOAuthSession = async (): Promise => { + await removeItem(X_OAUTH_SESSION_KEY) +} + +/** + * Generates a cryptographically random code verifier + * Length: 43-128 characters (base64url-encoded random bytes) + */ +export const generateCodeVerifier = async (): Promise => { + const randomBytes = await Crypto.getRandomBytesAsync(32) + return toBase64Url(randomBytes) +} + +/** + * Generates a code challenge from the code verifier + * Method: S256 (SHA-256) + */ +export const generateCodeChallenge = async ( + codeVerifier: string, +): Promise => { + const digest = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + codeVerifier, + { encoding: Crypto.CryptoEncoding.BASE64 }, + ) + // Convert to base64url + return digest + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +/** + * Builds the authorization URL for X OAuth 2.0 PKCE flow + */ +export const buildAuthorizationUrl = async (): Promise<{ + url: string + codeVerifier: string + state: string +}> => { + const codeVerifier = await generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + const state = toBase64Url(await Crypto.getRandomBytesAsync(16)) + await storePendingXOAuthSession({ codeVerifier, state }) + + const params = buildQueryString({ + response_type: 'code', + client_id: X_OAUTH_CONFIG.clientId, + redirect_uri: X_OAUTH_CONFIG.redirectUri, + scope: X_OAUTH_CONFIG.scopes.join(' '), + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + }) + + return { + url: `${X_OAUTH_CONFIG.authorizationEndpoint}?${params}`, + codeVerifier, + state, + } +} + +/** + * Opens X authorization page in an in-app browser + * Returns the authorization code if successful + */ +export const openXAuthorizationPage = async (): Promise<{ + code: string + codeVerifier: string +} | null> => { + try { + const { url, codeVerifier, state } = await buildAuthorizationUrl() + const browserPackage = await getAndroidBrowserPackage() + + console.log('[X OAuth] Authorization URL:', url) + console.log('[X OAuth] Code verifier length:', codeVerifier.length) + console.log('[X OAuth] Browser package:', browserPackage ?? 'default') + + const result = await WebBrowser.openAuthSessionAsync( + url, + X_OAUTH_CONFIG.redirectUri, + { + preferEphemeralSession: true, + showTitle: true, + ...(browserPackage ? { browserPackage } : null), + }, + ) + + console.log('[X OAuth] WebBrowser result:', result) + + if (result.type !== 'success') { + console.log('[X OAuth] User cancelled or error:', result.type) + return null + } + + // Parse the redirect URL to extract the code + const redirectUrl = new URL(result.url) + const code = redirectUrl.searchParams.get('code') + const returnedState = redirectUrl.searchParams.get('state') + const oauthError = redirectUrl.searchParams.get('error') + const oauthErrorDescription = redirectUrl.searchParams.get('error_description') + + if (oauthError) { + console.error('[X OAuth] Authorization error:', { + error: oauthError, + description: oauthErrorDescription, + }) + return null + } + + if (returnedState !== state) { + console.error('[X OAuth] State mismatch') + return null + } + + if (!code) { + console.error('[X OAuth] No code in redirect URL') + return null + } + + await clearPendingXOAuthSession() + return { code, codeVerifier } + } catch (error) { + console.error('[X OAuth error]:', error) + return null + } +} diff --git a/src/features/auth/ui/LoginScreen.tsx b/src/features/auth/ui/LoginScreen.tsx index 38c6575..18f4b4f 100644 --- a/src/features/auth/ui/LoginScreen.tsx +++ b/src/features/auth/ui/LoginScreen.tsx @@ -14,7 +14,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAuthStore } from "../../../store/auth.store"; import { C, Typography } from "../../../theme"; import { developerLogin } from "../api"; -import { useNativeSocialLogin } from "../hooks"; +import { useNativeSocialLogin, useXLogin } from "../hooks"; +import { openXAuthorizationPage } from "../lib/xOAuth"; // Dev-only login entry. Gated behind BOTH __DEV__ (stripped from release builds) // and an explicit opt-in env flag, so it can never surface in a production build. @@ -36,11 +37,13 @@ export function LoginScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); const mutation = useNativeSocialLogin(); + const xLoginMutation = useXLogin(); const setLoginTokens = useAuthStore((s) => s.setLoginTokens); const [devPending, setDevPending] = useState(false); + const [xLoginPending, setXLoginPending] = useState(false); const pendingProvider = mutation.variables; - const pending = mutation.isPending || devPending; + const pending = mutation.isPending || devPending || xLoginPending || xLoginMutation.isPending; const loginErrorMessage = mutation.error instanceof Error ? mutation.error.message @@ -68,6 +71,18 @@ export function LoginScreen() { } }; + const handleXLogin = async () => { + setXLoginPending(true); + try { + await openXAuthorizationPage(); + + } catch (error) { + Alert.alert("오류", "X 로그인에 실패했어요."); + } finally { + setXLoginPending(false); + } + }; + return ( @@ -102,9 +117,8 @@ export function LoginScreen() { /> - Alert.alert("안내", "트위터 로그인은 아직 지원되지 않아요.") - } + onPress={handleXLogin} + loading={xLoginPending} disabled={pending} /> {Platform.OS === "ios" && ( diff --git a/src/features/favorite/hooks/useFavoriteWork.ts b/src/features/favorite/hooks/useFavoriteWork.ts index 33c8b49..2950315 100644 --- a/src/features/favorite/hooks/useFavoriteWork.ts +++ b/src/features/favorite/hooks/useFavoriteWork.ts @@ -43,6 +43,7 @@ export function useFavoriteWork( optionsRef.current?.onAdded?.(worksId!) queryClient.invalidateQueries({ queryKey }) queryClient.invalidateQueries({ queryKey: ['feed', 'favoriteWorks'] }) + queryClient.invalidateQueries({ queryKey: ['profile', 'favorite-works'] }) queryClient.invalidateQueries({ queryKey: PROFILE_FAVORITE_WORKS_QUERY_KEY }) addMutation.reset() } @@ -50,6 +51,7 @@ export function useFavoriteWork( optionsRef.current?.onRemoved?.(worksId!) queryClient.invalidateQueries({ queryKey }) queryClient.invalidateQueries({ queryKey: ['feed', 'favoriteWorks'] }) + queryClient.invalidateQueries({ queryKey: ['profile', 'favorite-works'] }) queryClient.invalidateQueries({ queryKey: PROFILE_FAVORITE_WORKS_QUERY_KEY }) removeMutation.reset() } diff --git a/src/features/feed/ui/BlockConfirmModal.tsx b/src/features/feed/ui/BlockConfirmModal.tsx new file mode 100644 index 0000000..df72655 --- /dev/null +++ b/src/features/feed/ui/BlockConfirmModal.tsx @@ -0,0 +1,247 @@ +import { useEffect, useRef, useState } from 'react' +import { Animated, Modal, Pressable, StyleSheet, Text, View } from 'react-native' +import { Image } from 'expo-image' +import { C, Gray, Typography } from '../../../theme' + +const defaultProfileImage = require('../../../../assets/placeholders/profile-default.png') +const reportDoneIcon = require('../../../../assets/icons/feed/report-done.svg') +const blockDoneIcon = require('../../../../assets/icons/feed/block-done.svg') + +type ActionType = 'report' | 'block' + +type UserActionModalProps = { + visible: boolean + onClose: () => void + onConfirm: () => Promise + profileImageUrl?: string | null + nickname: string + type: ActionType +} + +export function UserActionModal({ + visible, + onClose, + onConfirm, + profileImageUrl, + nickname, + type, +}: UserActionModalProps) { + const isReport = type === 'report' + const [confirming, setConfirming] = useState(false) + const [doneVisible, setDoneVisible] = useState(false) + const doneOpacity = useRef(new Animated.Value(0)).current + + useEffect(() => { + if (!visible) { + setDoneVisible(false) + } + }, [visible]) + + const showDone = () => { + setDoneVisible(true) + Animated.sequence([ + Animated.timing(doneOpacity, { toValue: 1, duration: 200, useNativeDriver: true }), + Animated.delay(2000), + Animated.timing(doneOpacity, { toValue: 0, duration: 300, useNativeDriver: true }), + ]).start(() => { + setDoneVisible(false) + onClose() + }) + } + + const handleConfirm = async () => { + if (confirming) return + setConfirming(true) + try { + await onConfirm() + showDone() + } catch (error: any) { + onClose() + } finally { + setConfirming(false) + } + } + + return ( + + + {/* 확인 모달 */} + {!doneVisible && ( + + e.stopPropagation()}> + {/* 타이틀 */} + {isReport ? '신고하기' : '차단하기'} + + {/* 프로필 영역 */} + + + {nickname} + + + {/* 설명 */} + + {isReport ? ( + <> + 이 유저를 신고하시겠습니까?{'\n'} + 접수된 신고는 운영 정책에 따라 검토되며{'\n'} + 신고 내용에 따라 조치 여부가 결정됩니다. + + ) : ( + <> + 정말로 위의 유저를 차단하시겠습니까?{'\n'} + 이 작성자가 피드 및 리뷰에 노출되지 않으며{'\n'} + 다시 해제하실 수 없습니다. + + )} + + + {/* 버튼 영역 */} + + {/* 취소 버튼 */} + [ + styles.cancelButton, + pressed && styles.buttonPressed, + ]} + onPress={onClose} + disabled={confirming} + > + 취소 + + + {/* 확인 버튼 */} + [ + styles.confirmButton, + pressed && styles.buttonPressed, + ]} + onPress={() => void handleConfirm()} + disabled={confirming} + > + + {isReport ? '신고하기' : '차단하기'} + + + + + + )} + + {/* 완료 팝업 */} + {doneVisible && ( + + + + )} + + + ) +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(19, 17, 18, 0.60)', + alignItems: 'center', + justifyContent: 'center', + }, + backdropTouchable: { + flex: 1, + width: '100%', + alignItems: 'center', + justifyContent: 'center', + }, + modal: { + width: 306, + backgroundColor: C.card, + borderRadius: 8, + paddingTop: 28, + paddingBottom: 16, + paddingHorizontal: 16, + }, + title: { + fontSize: 20, + fontWeight: '700', + lineHeight: 28, + color: Gray[900], + textAlign: 'center', + }, + profileSection: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginTop: 8, + }, + profileImage: { + width: 32, + height: 32, + borderRadius: 16, + }, + nickname: { + ...Typography.body2Bold, + color: Gray[500], + marginLeft: 12, + }, + description: { + ...Typography.body2Medium, + color: Gray[500], + textAlign: 'center', + marginTop: 8, + }, + buttonRow: { + flexDirection: 'row', + gap: 10, + marginTop: 28, + }, + cancelButton: { + width: 135, + height: 49, + borderRadius: 8, + borderWidth: 1, + borderColor: Gray[200], + backgroundColor: Gray[50], + justifyContent: 'center', + alignItems: 'center', + }, + cancelButtonText: { + ...Typography.body1Medium, + color: Gray[700], + }, + confirmButton: { + width: 135, + height: 49, + borderRadius: 8, + backgroundColor: '#EF433E', + justifyContent: 'center', + alignItems: 'center', + }, + confirmButtonText: { + ...Typography.body1Medium, + color: C.card, + }, + buttonPressed: { + opacity: 0.8, + }, + doneWrap: { + position: 'absolute', + bottom: 88, + alignSelf: 'center', + }, + doneImage: { + width: 320, + height: 82, + }, +}) diff --git a/src/features/feed/ui/FeedCommentItem.tsx b/src/features/feed/ui/FeedCommentItem.tsx index b413692..2113626 100644 --- a/src/features/feed/ui/FeedCommentItem.tsx +++ b/src/features/feed/ui/FeedCommentItem.tsx @@ -1,6 +1,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native' import { Image } from 'expo-image' import type { ReplyItem } from '../api/feed/readerBoardDetail.api' +import { formatCreatedAtLabel } from '../../../lib/utils/formatCreatedAtLabel' import { C, Gray, Radius, Typography } from '../../../theme' const likeIcon = require('../../../../assets/icons/common/icon-like.svg') @@ -21,6 +22,7 @@ type BaseProps = { onToggleLike: () => void onOpenDelete: () => void onOpenReport: () => void + onOpenBlock: () => void } type ReplyProps = BaseProps & { @@ -37,11 +39,12 @@ type SubReplyProps = BaseProps & { type Props = ReplyProps | SubReplyProps export function FeedCommentItem(props: Props) { - const { myUserId, writerUserId, item, isMenuOpen, onToggleMenu, onToggleLike, onOpenDelete, onOpenReport } = + const { myUserId, writerUserId, item, isMenuOpen, onToggleMenu, onToggleLike, onOpenDelete, onOpenReport, onOpenBlock } = props const isMine = myUserId != null && item.reply.userId === myUserId const isWriter = writerUserId != null && item.reply.userId === writerUserId const isReplyTarget = props.variant === 'reply' && props.isReplyTarget + const displayCreatedAt = formatCreatedAtLabel(item.reply.lastCreatedTime) const card = ( {item.profile.nickName}{isWriter ? (글쓴이) : null} · - {item.reply.lastCreatedTime} + {displayCreatedAt} @@ -74,20 +77,46 @@ export function FeedCommentItem(props: Props) { {isMenuOpen ? ( - { - onToggleMenu() - if (isMine) onOpenDelete() - else onOpenReport() - }} - style={styles.dropdownButton} - > - - + + {isMine ? ( + + { + onToggleMenu() + onOpenDelete() + }} + > + 삭제하기 + + + ) : ( + + {/* 신고하기 */} + { + onToggleMenu() + onOpenReport() + }} + > + 신고하기 + + {/* 구분선 */} + + {/* 차단하기 */} + { + onToggleMenu() + onOpenBlock() + }} + > + 차단하기 + + + )} + ) : null} @@ -228,11 +257,29 @@ const styles = StyleSheet.create({ shadowRadius: 8, shadowOffset: { width: 0, height: 2 }, elevation: 4, + overflow: 'hidden', }, dropdownImage: { width: 96, height: 36, }, + menuTextWrapper: { + width: 96, + padding: 8, + }, + menuTextItem: { + justifyContent: 'center', + alignItems: 'flex-start', + }, + menuTextItemText: { + ...Typography.body2Medium, + color: Gray[500], + }, + menuDivider: { + height: 1, + backgroundColor: Gray[200], + marginVertical: 6, + }, commentText: { ...Typography.body2Medium, color: Gray[900], diff --git a/src/features/feed/ui/FeedDetailScreen.tsx b/src/features/feed/ui/FeedDetailScreen.tsx index 5a2b664..7b52730 100644 --- a/src/features/feed/ui/FeedDetailScreen.tsx +++ b/src/features/feed/ui/FeedDetailScreen.tsx @@ -29,10 +29,11 @@ import { } from '../api/feed/readerBoardDetail.api' import { deleteBoard, reportBoard, toggleBoardLike } from '../api/feed/readerBoard.api' import { useBoardDetailInfinite } from '../hooks/feed/useBoardDetailInfinite' +import { blockUser } from '../../users/api/users.api' import { FeedCommentInput, type FeedCommentInputHandle } from './FeedCommentInput' import { FeedCommentItem } from './FeedCommentItem' import { FeedPostCard } from './FeedPostCard' -import { ReportModal } from './ReportModal' +import { UserActionModal } from './BlockConfirmModal' const backIcon = require('../../../../assets/icons/common/back.svg') const warningIcon = require('../../../../assets/icons/profile/warning.svg') @@ -87,6 +88,12 @@ export function FeedDetailScreen() { onConfirm: () => Promise } | null>(null) + const [blockTarget, setBlockTarget] = useState<{ + profileImageUrl?: string | null + nickname: string + onConfirm: () => Promise + } | null>(null) + useEffect(() => { const showSubscription = Keyboard.addListener('keyboardDidShow', () => { setKeyboardVisible(true) @@ -213,6 +220,24 @@ export function FeedDetailScreen() { }) }, [boardId, profile]) + const onBlockBoard = useCallback(() => { + if (!profile) return + setBlockTarget({ + profileImageUrl: profile.profileImageUrl, + nickname: profile.nickName, + onConfirm: async () => { + await blockUser(profile.userId) + // 차단 후 쿼리 새로고침 + qc.invalidateQueries({ queryKey: ['allBoards'] }) + qc.invalidateQueries({ queryKey: ['boardsByWorksId'] }) + qc.invalidateQueries({ queryKey: ['boardComments'] }) + qc.invalidateQueries({ queryKey: ['topicroom'] }) + qc.invalidateQueries({ queryKey: ['worksReviews'] }) + router.back() + }, + }) + }, [profile, qc, router]) + const onDeleteReply = useCallback( (replyId: number, parentReplyId?: number) => { if (!boardId) return @@ -261,6 +286,29 @@ export function FeedDetailScreen() { [boardId], ) + const onBlockReply = useCallback( + ( + blockedUserId: number, + authorProfile: { profileImageUrl?: string | null; nickName: string }, + ) => { + setBlockTarget({ + profileImageUrl: authorProfile.profileImageUrl, + nickname: authorProfile.nickName, + onConfirm: async () => { + await blockUser(blockedUserId) + // 차단 후 쿼리 새로고침 + qc.invalidateQueries({ queryKey: ['allBoards'] }) + qc.invalidateQueries({ queryKey: ['boardsByWorksId'] }) + qc.invalidateQueries({ queryKey: ['boardComments'] }) + qc.invalidateQueries({ queryKey: ['topicroom'] }) + qc.invalidateQueries({ queryKey: ['worksReviews'] }) + await detailQuery.refetch() + }, + }) + }, + [qc, detailQuery], + ) + const onSubmitComment = useCallback(async () => { const trimmed = commentText.trim() if (!trimmed || !boardId || submitting) return @@ -426,6 +474,7 @@ export function FeedDetailScreen() { board.isWorksSelected && board.worksId ? () => router.push(`/works/${board.worksId}` as const) : undefined } onOpenReport={profile.userId !== myUserId ? onReportBoard : undefined} + onOpenBlock={profile.userId !== myUserId ? onBlockBoard : undefined} onOpenDelete={profile.userId === myUserId ? onDeleteBoard : undefined} birthdayTheme={board.theme === 'BIRTHDAY'} /> @@ -464,6 +513,7 @@ export function FeedDetailScreen() { }} onOpenDelete={() => onDeleteReply(item.reply.replyId)} onOpenReport={() => onReportReply(item.reply.replyId, item.reply.userId, item.profile)} + onOpenBlock={() => onBlockReply(item.reply.userId, item.profile)} /> {[...(item.childReplies ?? []), ...(subRepliesMap[item.reply.replyId] ?? [])].map((subReply) => { @@ -498,6 +548,7 @@ export function FeedDetailScreen() { onOpenReport={() => onReportReply(subReply.reply.replyId, subReply.reply.userId, subReply.profile) } + onOpenBlock={() => onBlockReply(subReply.reply.userId, subReply.profile)} /> ) })} @@ -520,13 +571,22 @@ export function FeedDetailScreen() { /> )} - setReportTarget(null)} onConfirm={reportTarget?.onConfirm ?? (() => Promise.resolve())} /> + setBlockTarget(null)} + onConfirm={blockTarget?.onConfirm ?? (() => Promise.resolve())} + /> ) } diff --git a/src/features/feed/ui/FeedPostCard.tsx b/src/features/feed/ui/FeedPostCard.tsx index 1c69088..aa09105 100644 --- a/src/features/feed/ui/FeedPostCard.tsx +++ b/src/features/feed/ui/FeedPostCard.tsx @@ -12,7 +12,9 @@ import { import { GestureDetector, Gesture } from "react-native-gesture-handler"; import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { formatCreatedAtLabel } from "../../../lib/utils/formatCreatedAtLabel"; import { C, Gray, Magenta } from "../../../theme/colors"; +import { Typography } from "../../../theme/typography"; // ─── Assets ────────────────────────────────────────────────────────────────── @@ -62,6 +64,7 @@ type FeedPostCardProps = { onClickWorksArrow?: () => void; onOpenReport?: () => void; onOpenDelete?: () => void; + onOpenBlock?: () => void; onPressCard?: () => void; birthdayTheme?: boolean; }; @@ -196,6 +199,7 @@ export function FeedPostCard({ onClickWorksArrow, onOpenReport, onOpenDelete, + onOpenBlock, onPressCard, birthdayTheme = false, }: FeedPostCardProps) { @@ -231,6 +235,7 @@ export function FeedPostCard({ const isMine = currentUserId != null && writerUserId === currentUserId; const isSpoilerHidden = isSpoiler && !spoilerRevealed; + const displayCreatedAt = formatCreatedAtLabel(createdAt); const showWorks = works != null && @@ -267,7 +272,9 @@ export function FeedPostCard({ {nickName} - {!!createdAt && {createdAt}} + {!!displayCreatedAt && ( + {displayCreatedAt} + )} {/* Menu button */} @@ -298,20 +305,46 @@ export function FeedPostCard({ style={StyleSheet.absoluteFillObject} onPress={() => setMenuOpen(false)} > - { - setMenuOpen(false); - if (isMine) onOpenDelete?.(); - else onOpenReport?.(); - }} - > - - + + {isMine ? ( + + { + setMenuOpen(false); + onOpenDelete?.(); + }} + > + 삭제하기 + + + ) : ( + + {/* 신고하기 */} + { + setMenuOpen(false); + onOpenReport?.(); + }} + > + 신고하기 + + {/* 구분선 */} + + {/* 차단하기 */} + { + setMenuOpen(false); + onOpenBlock?.(); + }} + > + 차단하기 + + + )} + )} @@ -602,11 +635,29 @@ const styles = StyleSheet.create({ shadowRadius: 8, shadowOffset: { width: 0, height: 2 }, elevation: 4, + overflow: "hidden", }, menuDropdownImg: { width: 96, height: 36, }, + menuTextWrapper: { + width: 96, + padding: 8, + }, + menuTextItem: { + justifyContent: "center", + alignItems: "flex-start", + }, + menuTextItemText: { + ...Typography.body2Medium, + color: Gray[500], + }, + menuDivider: { + height: 1, + backgroundColor: Gray[200], + marginVertical: 6, + }, // Works section worksSection: { @@ -644,16 +695,12 @@ const styles = StyleSheet.create({ justifyContent: "space-between", }, worksName: { - fontSize: 14, - fontWeight: "700", - lineHeight: 20, - color: C.text, + ...Typography.body2Bold, + color: Gray[800], marginBottom: 4, }, worksMeta: { - fontSize: 12, - fontWeight: "500", - lineHeight: 17, + ...Typography.caption1Medium, color: Gray[500], }, worksArrowBtn: { @@ -684,9 +731,7 @@ const styles = StyleSheet.create({ backgroundColor: C.divider, }, hashtagText: { - fontSize: 10, - fontWeight: "500", - lineHeight: 14, + ...Typography.caption2Medium, color: Gray[500], }, diff --git a/src/features/feed/ui/FeedScreen.tsx b/src/features/feed/ui/FeedScreen.tsx index 0ffa0bf..d6049b7 100644 --- a/src/features/feed/ui/FeedScreen.tsx +++ b/src/features/feed/ui/FeedScreen.tsx @@ -27,7 +27,8 @@ import { TopicRoomFeedSection } from '../../topicroom/ui/TopicRoomFeedSection' import { FeedPostCard } from './FeedPostCard' import { FeedTopbar, type FeedTab } from './FeedTopbar' import { FeedWorksPicker } from './FeedWorksPicker' -import { ReportModal } from './ReportModal' +import { UserActionModal } from './BlockConfirmModal' +import { blockUser } from '../../users/api/users.api' type LikeOverride = { isLiked: boolean; likeCount: number } @@ -60,6 +61,12 @@ export function FeedScreen() { onConfirm: () => Promise } | null>(null) + const [blockTarget, setBlockTarget] = useState<{ + profileImageUrl?: string | null + nickname: string + onConfirm: () => Promise + } | null>(null) + const [createSheetOpen, setCreateSheetOpen] = useState(false) const handlePressSearchTopicRoom = useCallback(() => { @@ -136,7 +143,7 @@ export function FeedScreen() { [], ) - // ── Report / Delete ────────────────────────────────────────────────────────── + // ── Report / Delete / Block ────────────────────────────────────────────────── const handleReport = useCallback( (boardId: number, writerUserId: number, reportedProfile: { profileImageUrl?: string | null; nickName: string }) => { setReportTarget({ @@ -153,6 +160,27 @@ export function FeedScreen() { [], ) + const handleBlock = useCallback( + (writerUserId: number, blockedProfile: { profileImageUrl?: string | null; nickName: string }) => { + setBlockTarget({ + profileImageUrl: blockedProfile.profileImageUrl, + nickname: blockedProfile.nickName, + onConfirm: async () => { + await blockUser(writerUserId) + // 차단 후 모든 관련 쿼리 새로고침 + qc.invalidateQueries({ queryKey: ['allBoards'] }) + qc.invalidateQueries({ queryKey: ['boardsByWorksId'] }) + qc.invalidateQueries({ queryKey: ['boardComments'] }) + qc.invalidateQueries({ queryKey: ['topicroom'] }) + qc.invalidateQueries({ queryKey: ['worksReviews'] }) + // 즉시 피드 새로고침 + await activeQuery.refetch() + }, + }) + }, + [qc, activeQuery], + ) + const handleDelete = useCallback( async (boardId: number) => { Alert.alert('삭제', '이 게시글을 삭제할까요?', [ @@ -240,6 +268,11 @@ export function FeedScreen() { ? () => handleReport(board.boardId, profile.userId, profile) : undefined } + onOpenBlock={ + !isMine + ? () => handleBlock(profile.userId, profile) + : undefined + } onOpenDelete={ isMine ? () => handleDelete(board.boardId) : undefined } @@ -252,6 +285,7 @@ export function FeedScreen() { currentUserId, handleDelete, handleReport, + handleBlock, handleToggleLike, likeOverrides, router, @@ -280,7 +314,8 @@ export function FeedScreen() { ) const reportModal = ( - ) + const blockModal = ( + setBlockTarget(null)} + onConfirm={blockTarget?.onConfirm ?? (() => Promise.resolve())} + /> + ) + if (tab === 'writers') { return ( <> @@ -311,6 +357,7 @@ export function FeedScreen() { {reportModal} + {blockModal} {createWorksSheet} ) @@ -366,6 +413,7 @@ export function FeedScreen() { } /> {reportModal} + {blockModal} {createWorksSheet} ) diff --git a/src/features/feed/ui/FeedTopbar.tsx b/src/features/feed/ui/FeedTopbar.tsx index f711c15..430908c 100644 --- a/src/features/feed/ui/FeedTopbar.tsx +++ b/src/features/feed/ui/FeedTopbar.tsx @@ -1,6 +1,7 @@ import { Image } from "expo-image"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { C, Gray } from "../../../theme/colors"; +import { Typography } from "../../../theme/typography"; const searchIcon = require("../../../../assets/icons/common/search.svg"); const addTopicRoomIcon = require("../../../../assets/topicroom/icon-add-topicroom.svg"); @@ -104,9 +105,7 @@ const styles = StyleSheet.create({ gap: 20, }, tab: { - fontSize: 24, - fontWeight: "700", - lineHeight: 34, + ...Typography.heading1, }, tabActive: { color: Gray[900], diff --git a/src/features/navigation/ui/BottomNavBar.tsx b/src/features/navigation/ui/BottomNavBar.tsx index a3356e7..0079d2e 100644 --- a/src/features/navigation/ui/BottomNavBar.tsx +++ b/src/features/navigation/ui/BottomNavBar.tsx @@ -26,7 +26,7 @@ type NavItem = { const NAV_ITEMS: NavItem[] = [ { routeName: 'index', label: '홈' }, - { routeName: 'feed', label: '피드' }, + { routeName: 'feed', label: '소통' }, { routeName: 'library', label: '서재' }, { routeName: 'profile', label: '프로필' }, ] diff --git a/src/features/onboarding/ui/OnboardingScreen.tsx b/src/features/onboarding/ui/OnboardingScreen.tsx index 5470dc3..2d7822f 100644 --- a/src/features/onboarding/ui/OnboardingScreen.tsx +++ b/src/features/onboarding/ui/OnboardingScreen.tsx @@ -102,9 +102,9 @@ export function OnboardingScreen() { privacyPolicyAgree, ageOver14, nickName: nickname.trim(), + profileDescription: bio, favoriteGenreList: genres, favoriteWorksIdList: favoriteIds, - profileDescription: bio, }) if (profileImageUri) { await uploadAndSetProfileImage(profileImageUri).catch(() => {}) diff --git a/src/features/profile/api/profile-card-share.api.ts b/src/features/profile/api/profile-card-share.api.ts new file mode 100644 index 0000000..fb13329 --- /dev/null +++ b/src/features/profile/api/profile-card-share.api.ts @@ -0,0 +1,54 @@ +import { apiClient } from '../../../lib/api/axios-instance' + +type ApiEnvelope = { + result: T +} + +type ProfileCardImagePresignResult = { + url: string + objectKey: string +} + +type ProfileCardShareResult = { + shareUrl: string +} + +export async function postProfileCardImagePresignedUrl(contentType: string) { + const { data } = await apiClient.post< + ApiEnvelope + >('/api/v1/image/profile-card', { + file: { contentType }, + }) + + return data.result +} + +export async function createProfileCardShare(objectKey: string) { + const { data } = await apiClient.post>( + '/api/v1/profile/card/share', + { objectKey }, + ) + + return data.result +} + +export async function uploadProfileCardImage(params: { + url: string + uri: string + contentType: string +}) { + const image = await fetch(params.uri) + const blob = await image.blob() + + const res = await fetch(params.url, { + method: 'PUT', + headers: { + 'Content-Type': params.contentType, + }, + body: blob, + }) + + if (!res.ok) { + throw new Error(`S3 upload failed: ${res.status}`) + } +} diff --git a/src/features/profile/api/profile.api.ts b/src/features/profile/api/profile.api.ts index 483cd00..c1c1f3e 100644 --- a/src/features/profile/api/profile.api.ts +++ b/src/features/profile/api/profile.api.ts @@ -27,11 +27,19 @@ const normalizeMeProfile = (raw: MeProfileResult): MeProfileResult => { nickName: nickName.length > 0 ? nickName : DEFAULT_NICKNAME, profileDescription: raw.profileDescription ?? '', profileImageUrl: raw.profileImageUrl ?? null, + // V2 fields with safe defaults + topGenre: raw.topGenre ?? '', + title: raw.title ?? null, + stage: raw.stage ?? '미진입', + nextStage: raw.nextStage ?? '입문', + topGenreScore: raw.topGenreScore ?? 0, + remainingScore: raw.remainingScore ?? 0, + progressPercentage: raw.progressPercentage ?? 0, } } export const getMyProfile = async (): Promise> => { - const res = await apiClient.get('/api/v1/profile/me') + const res = await apiClient.get('/api/v2/profile/me') const data = res.data as ApiResponse return data.result ? { ...data, result: normalizeMeProfile(data.result) } diff --git a/src/features/profile/hooks/useCardShare.ts b/src/features/profile/hooks/useCardShare.ts new file mode 100644 index 0000000..461be5f --- /dev/null +++ b/src/features/profile/hooks/useCardShare.ts @@ -0,0 +1,176 @@ +import { useCallback, useState } from 'react' +import { Alert, Linking } from 'react-native' +import * as MediaLibrary from 'expo-media-library' +import * as Sharing from 'expo-sharing' +import Share, { Social } from 'react-native-share' +import { + createProfileCardShare, + postProfileCardImagePresignedUrl, + uploadProfileCardImage, +} from '../api/profile-card-share.api' + +export type CaptureFunction = () => Promise + +const SHARE_MESSAGE = 'STORIX \uD504\uB85C\uD544 \uCE74\uB4DC' +const TWITTER_ANDROID_PACKAGE = 'com.twitter.android' +const TWITTER_WEB_INTENT_URL = 'https://twitter.com/intent/tweet' +const TWITTER_HOME_URL = 'https://twitter.com' + +export function useCardShare() { + const [isSaving, setIsSaving] = useState(false) + const [isSharing, setIsSharing] = useState(false) + + const saveToGallery = useCallback(async ( + captureImage: CaptureFunction, + onSuccess?: () => void, + message: string = 'STORIX 프로필 카드', + ) => { + try { + setIsSaving(true) + + const { status } = await MediaLibrary.requestPermissionsAsync() + if (status !== 'granted') { + Alert.alert( + '\uAD8C\uD55C \uD544\uC694', + '\uAC24\uB7EC\uB9AC\uC5D0 \uC800\uC7A5\uD558\uB824\uBA74 \uC0AC\uC9C4 \uC811\uADFC \uAD8C\uD55C\uC774 \uD544\uC694\uD569\uB2C8\uB2E4.', + ) + return + } + + const uri = await captureImage() + if (!uri) { + Alert.alert('\uC624\uB958', '\uC774\uBBF8\uC9C0\uB97C \uC0DD\uC131\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.') + return + } + + await MediaLibrary.saveToLibraryAsync(uri) + + // \uC131\uACF5 \uCF5C\uBC31 \uD638\uCD9C (\uBAA8\uB2EC \uB2EB\uAE30 + \uD1A0\uC2A4\uD2B8 \uD45C\uC2DC) + onSuccess?.() + } catch (error) { + console.error('Save to gallery error:', error) + Alert.alert('\uC800\uC7A5 \uC2E4\uD328', '\uC774\uBBF8\uC9C0 \uC800\uC7A5 \uC911 \uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4.') + } finally { + setIsSaving(false) + } + }, []) + + const shareImage = useCallback(async ( + captureImage: CaptureFunction, + message: string = 'STORIX 프로필 카드', + ) => { + try { + setIsSharing(true) + + const uri = await captureImage() + if (!uri) { + Alert.alert('\uC624\uB958', '\uC774\uBBF8\uC9C0\uB97C \uC0DD\uC131\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.') + return + } + + const isSharingAvailable = await Sharing.isAvailableAsync() + if (!isSharingAvailable) { + Alert.alert( + '\uACF5\uC720 \uBD88\uAC00', + '\uC774 \uAE30\uAE30\uC5D0\uC11C\uB294 \uACF5\uC720 \uAE30\uB2A5\uC744 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.', + ) + return + } + + await Sharing.shareAsync(uri, { + mimeType: 'image/png', + dialogTitle: '\uD504\uB85C\uD544 \uCE74\uB4DC \uACF5\uC720', + }) + } catch (error) { + console.error('Share error:', error) + Alert.alert('\uACF5\uC720 \uC2E4\uD328', '\uC774\uBBF8\uC9C0 \uACF5\uC720 \uC911 \uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4.') + } finally { + setIsSharing(false) + } + }, []) + + const shareToTwitter = useCallback(async ( + captureImage: CaptureFunction, + message: string = 'STORIX \uD504\uB85C\uD544 \uCE74\uB4DC', + ) => { + try { + setIsSharing(true) + + const uri = await captureImage() + if (!uri) { + Alert.alert('\uC624\uB958', '\uC774\uBBF8\uC9C0\uB97C \uC0DD\uC131\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.') + return + } + + const isTwitterInstalled = await isTwitterAppInstalled() + if (!isTwitterInstalled) { + const shareUrl = await uploadProfileCardForWebShare(uri) + await openTwitterWebIntent(shareUrl, message) + return + } + + await Share.shareSingle({ + social: Social.Twitter, + url: normalizeShareUri(uri), + type: 'image/png', + message, + }) + } catch (error) { + console.error('Twitter share error:', error) + await openTwitterWebIntent(undefined, message) + } finally { + setIsSharing(false) + } + }, []) + + return { + saveToGallery, + shareImage, + shareToTwitter, + isSaving, + isSharing, + } +} + +function normalizeShareUri(uri: string) { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(uri)) return uri + return `file://${uri}` +} + +async function isTwitterAppInstalled() { + try { + const result = await Share.isPackageInstalled(TWITTER_ANDROID_PACKAGE) + return result.isInstalled + } catch { + return false + } +} + +async function uploadProfileCardForWebShare(uri: string) { + const contentType = 'image/png' + const presigned = await postProfileCardImagePresignedUrl(contentType) + + await uploadProfileCardImage({ + url: presigned.url, + uri, + contentType, + }) + + const share = await createProfileCardShare(presigned.objectKey) + return share.shareUrl +} + +async function openTwitterWebIntent(shareUrl?: string, message: string = SHARE_MESSAGE) { + const text = encodeURIComponent(message) + const query = shareUrl + ? `text=${text}&url=${encodeURIComponent(shareUrl)}` + : `text=${text}` + const webIntentUrl = `${TWITTER_WEB_INTENT_URL}?${query}` + + try { + const canOpenWebIntent = await Linking.canOpenURL(webIntentUrl) + await Linking.openURL(canOpenWebIntent ? webIntentUrl : TWITTER_HOME_URL) + } catch { + await Linking.openURL(TWITTER_HOME_URL) + } +} diff --git a/src/features/profile/hooks/useSocialProvider.ts b/src/features/profile/hooks/useSocialProvider.ts index 5acc95d..07b5425 100644 --- a/src/features/profile/hooks/useSocialProvider.ts +++ b/src/features/profile/hooks/useSocialProvider.ts @@ -1,21 +1,27 @@ import { useEffect, useState } from 'react' import { getItem } from '../../../lib/storage/async' -export type SocialProvider = 'kakao' | 'naver' | 'apple' +export type SocialProvider = 'kakao' | 'naver' | 'apple' | 'x' const PROVIDER_NAMES: Record = { kakao: '카카오', naver: '네이버', apple: '애플', + x: 'X(트위터)', } +const isSocialProvider = (value: unknown): value is SocialProvider => + value === 'kakao' || value === 'naver' || value === 'apple' || value === 'x' + export const SOCIAL_PROVIDER_KEY = 'socialProvider' export const useSocialProvider = () => { const [provider, setProvider] = useState(null) useEffect(() => { - void getItem(SOCIAL_PROVIDER_KEY).then(setProvider) + void getItem(SOCIAL_PROVIDER_KEY).then((value) => { + setProvider(isSocialProvider(value) ? value : null) + }) }, []) return provider ? PROVIDER_NAMES[provider] : null diff --git a/src/features/profile/ui/LevelProgress.tsx b/src/features/profile/ui/LevelProgress.tsx new file mode 100644 index 0000000..d5faebb --- /dev/null +++ b/src/features/profile/ui/LevelProgress.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Image } from 'expo-image' +import { C, Gray, Magenta, Typography } from '../../../theme' +import { TitleInfoModal } from './TitleInfoModal' + +const helpIcon = require('../../../../assets/icons/common/icon-help.svg') + +export type LevelProgressProps = { + /** 현재 단계 */ + level: string + /** 다음 칭호명 */ + nextTitle: string + /** 남은 포인트 (다음 단계까지) */ + remainingPoints: number + /** 진행률 (0-1 사이의 값) */ + progress: number + /** 대표 장르 */ + topGenre: string | null + /** 현재 칭호 */ + title: string | null +} + +export function LevelProgress({ + level, + nextTitle, + remainingPoints, + progress, + topGenre, + title +}: LevelProgressProps) { + const [showModal, setShowModal] = useState(false) + + // 최고 단계 달성 여부 (nextStage가 현재 stage와 같거나 비어있으면) + const isMaxLevel = !nextTitle || nextTitle === level + + return ( + + {/* 텍스트 영역 */} + + {isMaxLevel ? ( + + {level} 칭호 달성 완료 + + ) : ( + <> + + {nextTitle} 칭호까지 + + {remainingPoints} 점 + + )} + setShowModal(true)} hitSlop={8}> + + + + + {/* 프로그레스바 */} + + + + + {/* 칭호 안내 모달 */} + setShowModal(false)} + topGenre={topGenre} + title={title} + /> + + ) +} + +const styles = StyleSheet.create({ + container: { + height: 67, + paddingHorizontal: 16, + paddingTop: 2, + backgroundColor: C.card, + }, + textRow: { + flexDirection: 'row', + alignItems: 'center', + }, + levelText: { + ...Typography.body2Medium, + color: Gray[900], + textAlign: 'center', + }, + pointsText: { + ...Typography.body2Bold, + color: Magenta[300], + textAlign: 'center', + marginLeft: 4, + }, + helpIcon: { + width: 24, + height: 24, + marginLeft: 4, + }, + progressBarContainer: { + marginTop: 10, + height: 9, + backgroundColor: Gray[100], + borderRadius: 4.5, // 높이의 절반으로 완전히 둥글게 (나중에 조정 가능) + overflow: 'hidden', + }, + progressBarFill: { + height: '100%', + backgroundColor: Magenta[300], + borderRadius: 4.5, + }, +}) diff --git a/src/features/profile/ui/ProfileActivityCommentItem.tsx b/src/features/profile/ui/ProfileActivityCommentItem.tsx index 792ba64..61d53cf 100644 --- a/src/features/profile/ui/ProfileActivityCommentItem.tsx +++ b/src/features/profile/ui/ProfileActivityCommentItem.tsx @@ -7,6 +7,7 @@ import { deleteReply, toggleReplyLike } from '../../feed/api/feed/readerBoardDet import { reportReply } from '../../feed/api/feed/readerReply.api' import type { ProfileActivityReplyItem } from '../api/profile-activity.api' import { ReportModal } from '../../feed/ui/ReportModal' +import { formatCreatedAtLabel } from '../../../lib/utils/formatCreatedAtLabel' import { C, Gray, Radius, Typography } from '../../../theme' const warningIcon = require('../../../../assets/icons/profile/warning.svg') @@ -33,6 +34,7 @@ export function ProfileActivityCommentItem({ const qc = useQueryClient() const isMine = currentUserId != null && item.reply.userId === currentUserId const [reportModalVisible, setReportModalVisible] = useState(false) + const displayCreatedAt = formatCreatedAtLabel(item.reply.lastCreatedTime) const syncReplyItem = ( replyId: number, @@ -136,7 +138,7 @@ export function ProfileActivityCommentItem({ {item.profile.nickName} - {item.reply.lastCreatedTime} + {displayCreatedAt} diff --git a/src/features/profile/ui/ProfileCardModal.tsx b/src/features/profile/ui/ProfileCardModal.tsx new file mode 100644 index 0000000..07bde66 --- /dev/null +++ b/src/features/profile/ui/ProfileCardModal.tsx @@ -0,0 +1,326 @@ +import { Modal, Pressable, StyleSheet, Text, View, ActivityIndicator } from 'react-native' +import { Image } from 'expo-image' +import { SvgXml } from 'react-native-svg' +import { useRef } from 'react' +import ViewShot from 'react-native-view-shot' +import { C, Gray, Magenta, Radius, Typography } from '../../../theme' +import { useCardShare } from '../hooks/useCardShare' + +const idCardTitle = require('../../../../assets/icons/profile/id-card-title.svg') +const closeIcon = require('../../../../assets/icons/common/x.svg') +const reviewIcon = require('../../../../assets/icons/profile/review.svg') +const likedIcon = require('../../../../assets/icons/profile/icon-liked.svg') +const libraryIcon = require('../../../../assets/icons/profile/icon-library.svg') +const downloadIcon = require('../../../../assets/icons/common/icon-download.svg') +const shareIcon = require('../../../../assets/icons/common/icon-share.svg') +const twitterIcon = require('../../../../assets/icons/common/icon-twitter.svg') + +export type ProfileCardModalProps = { + visible: boolean + onClose: () => void + nickname: string + title: string + topGenreIconSvg?: string + averageRating?: number + topGenreName?: string + reviewCount?: number +} + +export function ProfileCardModal({ + visible, + onClose, + nickname, + title, + topGenreIconSvg, + averageRating = 0, + topGenreName = '로맨스', + reviewCount = 0, +}: ProfileCardModalProps) { + const viewShotRef = useRef(null) + const { saveToGallery, shareImage, shareToTwitter, isSaving, isSharing } = useCardShare() + + const captureCard = async (): Promise => { + if (!viewShotRef.current) return null + try { + const uri = await viewShotRef.current.capture?.() + return uri ?? null + } catch (error) { + console.error('Capture error:', error) + return null + } + } + + console.log('[ProfileCardModal] visible:', visible) + + return ( + + + {/* X 버튼 */} + + + + + + + e.stopPropagation()}> + {/* 상단 영역: 핑크 + 검정 */} + + {/* 왼쪽 핑크 영역 */} + + + + + + {nickname} + + + + + {title} + + + + {/* 오른쪽 검정 영역 */} + + {topGenreIconSvg && ( + + )} + + + + {/* 하단 검정 영역 */} + + {/* 별점평균 */} + + {averageRating.toFixed(1)} + 별점 평균 + + + + {/* 최애장르 */} + + {topGenreName} + 최애 장르 + + + + {/* 작품 리뷰 */} + + {reviewCount} + 리뷰 작품 + + + + + + + {/* 하단 버튼 영역 */} + + {/* 저장 버튼 */} + saveToGallery(captureCard)} + disabled={isSaving} + style={styles.actionButton} + > + + {isSaving ? ( + + ) : ( + + )} + + 저장 + + + {/* 공유 버튼 */} + shareImage(captureCard)} + disabled={isSharing} + style={styles.actionButton} + > + + {isSharing ? ( + + ) : ( + + )} + + 공유 + + + {/* X(트위터) 공유 버튼 */} + shareToTwitter(captureCard)} + style={styles.actionButton} + > + + + + X에 공유 + + + + + + ) +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: '#00000099', + alignItems: 'center', + justifyContent: 'center', + }, + closeButton: { + position: 'absolute', + top: 16, + left: 16, + width: 24, + height: 24, + zIndex: 10, + }, + closeIcon: { + width: 24, + height: 24, + }, + contentWrapper: { + width: 322, + }, + cardContainer: { + backgroundColor: C.card, + }, + topRow: { + flexDirection: 'row', + }, + pinkSection: { + width: 161, + height: 161, + paddingTop: 13, + paddingBottom: 13, + paddingHorizontal: 12, + flexDirection: 'column', + alignItems: 'flex-start', + backgroundColor: Magenta[300], + borderTopLeftRadius: Radius.lg, + borderTopRightRadius: Radius.lg, + borderBottomRightRadius: 0, + borderBottomLeftRadius: Radius.lg, + }, + idCardTitle: { + width: 137, // 161 - 12*2 + height: 20, + }, + nicknameBadge: { + maxWidth: 96, + maxHeight: 23, + paddingHorizontal: 8, + paddingVertical: 4, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 44.722, + backgroundColor: Magenta[200], + marginTop: 12, + }, + nicknameText: { + ...Typography.caption1Semibold, + color: C.card, + }, + titleText: { + ...Typography.caption1Medium, + color: C.card, + marginTop: 4, + }, + blackSectionRight: { + width: 161, + height: 161, + padding: 40, + paddingHorizontal: 32, + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: Gray[900], + borderTopLeftRadius: Radius.lg, + borderTopRightRadius: Radius.lg, + borderBottomRightRadius: Radius.lg, + borderBottomLeftRadius: 0, + }, + genreIcon: { + width: 97, // 161 - 32*2 + height: 81, // 161 - 40*2 + }, + blackSectionBottom: { + width: 322, + height: 161, + paddingTop: 33, + paddingBottom: 33, + paddingHorizontal: 32, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: Gray[900], + borderRadius: Radius.lg, + }, + statItem: { + width: 56, + flexDirection: 'column', + alignItems: 'center', + }, + statValue: { + ...Typography.heading2, + color: Magenta[200], + textAlign: 'center', + }, + statLabel: { + ...Typography.caption1Medium, + color: Magenta[200], + textAlign: 'center', + marginTop: 4, + }, + statIcon: { + width: 24, + height: 24, + marginTop: 16, + }, + actionButtons: { + height: 136, + paddingHorizontal: 67, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + actionButton: { + width: 48, + height: 76, + alignItems: 'center', + justifyContent: 'flex-start', + gap: 4, + }, + actionButtonCircle: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: C.card, + alignItems: 'center', + justifyContent: 'center', + }, + actionIcon: { + width: 24, + height: 24, + }, + twitterIcon: { + width: 48, + height: 48, + }, + actionButtonText: { + ...Typography.caption1Medium, + color: C.card, + textAlign: 'center', + }, +}) diff --git a/src/features/profile/ui/ProfileCardModalSimple.tsx b/src/features/profile/ui/ProfileCardModalSimple.tsx new file mode 100644 index 0000000..a753907 --- /dev/null +++ b/src/features/profile/ui/ProfileCardModalSimple.tsx @@ -0,0 +1,375 @@ +import { Modal, Pressable, StyleSheet, Text, View, ActivityIndicator } from 'react-native' +import { Image } from 'expo-image' +import { SvgXml } from 'react-native-svg' +import { useMemo, useRef } from 'react' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { BlurView } from 'expo-blur' +import ViewShot from 'react-native-view-shot' +import { C, Gray, Magenta, Radius, Typography } from '../../../theme' +import { useCardShare } from '../hooks/useCardShare' + +const idCardTitle = require('../../../../assets/icons/profile/id-card-title.svg') +const closeIcon = require('../../../../assets/icons/common/x.svg') +const reviewIcon = require('../../../../assets/icons/profile/review.svg') +const likedIcon = require('../../../../assets/icons/profile/icon-liked.svg') +const libraryIcon = require('../../../../assets/icons/profile/icon-library.svg') +const downloadIcon = require('../../../../assets/icons/common/icon-download.svg') +const shareIcon = require('../../../../assets/icons/common/icon-share.svg') +const twitterIcon = require('../../../../assets/icons/common/icon-twitter.svg') + +export type ProfileCardModalProps = { + visible: boolean + onClose: () => void + nickname: string + title: string + topGenreIconSvg?: string + averageRating?: number + topGenreName?: string + reviewCount?: number + onSaveSuccess?: () => void +} + +export function ProfileCardModal({ + visible, + onClose, + nickname, + title, + topGenreIconSvg, + averageRating = 0, + topGenreName = '로맨스', + reviewCount = 0, + onSaveSuccess, +}: ProfileCardModalProps) { + const insets = useSafeAreaInsets() + const viewShotRef = useRef(null) + const { saveToGallery, shareImage, shareToTwitter, isSaving, isSharing } = useCardShare() + const tintedTopGenreIconSvg = useMemo( + () => tintSvg(topGenreIconSvg, Magenta[300]), + [topGenreIconSvg], + ) + + const captureCard = async (): Promise => { + if (!viewShotRef.current) return null + try { + const uri = await viewShotRef.current.capture?.() + return uri ?? null + } catch (error) { + console.error('Capture error:', error) + return null + } + } + + return ( + + + + + + {/* X 버튼 */} + + + + + + + e.stopPropagation()}> + {/* 상단 영역: 핑크 + 검정 */} + + {/* 왼쪽 핑크 영역 */} + + + + + + {nickname} + + + + + + {title} + + + + + {/* 오른쪽 검정 영역 */} + + {tintedTopGenreIconSvg ? ( + + ) : ( + + )} + + + + {/* 하단 검정 영역 */} + + {/* 별점평균 */} + + {averageRating.toFixed(1)} + 별점평균 + + + + + + {/* 최애장르 */} + + {topGenreName} + 최애장르 + + + + + + {/* 작품 리뷰 */} + + {reviewCount} + 작품 리뷰 + + + + + + + + + {/* 하단 버튼 영역 */} + + {/* 저장 버튼 */} + { + saveToGallery(captureCard, () => { + onClose() + onSaveSuccess?.() + }) + }} + disabled={isSaving} + style={styles.actionButton} + > + + {isSaving ? ( + + ) : ( + + )} + + 저장 + + + {/* 공유 버튼 */} + shareImage(captureCard)} + disabled={isSharing} + style={styles.actionButton} + > + + {isSharing ? ( + + ) : ( + + )} + + 공유 + + + {/* X(트위터) 공유 버튼 */} + shareToTwitter(captureCard)} + style={styles.actionButton} + > + + + + X에 공유 + + + + + + + ) +} + +function tintSvg(svg: string | undefined, color: string) { + if (!svg) return undefined + + return svg + .replace(/fill="(?!none)[^"]*"/g, `fill="${color}"`) + .replace(/stroke="(?!none)[^"]*"/g, `stroke="${color}"`) +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + }, + backdropDim: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(19, 17, 18, 0.60)', + }, + backdropPressable: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + closeButton: { + position: 'absolute', + left: 16, + width: 24, + height: 24, + zIndex: 10, + }, + closeIcon: { + width: 24, + height: 24, + }, + contentWrapper: { + width: 322, + }, + cardContainer: { + // backgroundColor 제거 - 핑크/검정 영역만 보이도록 + }, + topRow: { + flexDirection: 'row', + }, + pinkSection: { + width: 161, + height: 161, + paddingTop: 13, + paddingBottom: 13, + paddingHorizontal: 12, + flexDirection: 'column', + alignItems: 'flex-start', + backgroundColor: Magenta[300], + borderTopLeftRadius: Radius.lg, + borderTopRightRadius: Radius.lg, + borderBottomRightRadius: 0, + borderBottomLeftRadius: Radius.lg, + }, + idCardTitle: { + width: 137, // 161 - 12*2 (좌우 패딩) + height: 72, + }, + nicknameBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 44.722, + backgroundColor: Magenta[200], + marginTop: 8, + }, + nicknameText: { + ...Typography.caption1Semibold, + color: C.card, + }, + titleBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 44.722, + backgroundColor: Magenta[200], + marginTop: 4, + }, + titleText: { + ...Typography.caption1Medium, + color: C.card, + }, + genreIconPlaceholder: { + width: '100%', + height: '100%', + backgroundColor: Gray[800], + }, + blackSectionRight: { + width: 161, + height: 161, + padding: 9.5, + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: Gray[900], + borderTopLeftRadius: Radius.lg, + borderTopRightRadius: Radius.lg, + borderBottomRightRadius: Radius.lg, + borderBottomLeftRadius: 0, + }, + blackSectionBottom: { + width: 322, + height: 161, + paddingTop: 33, + paddingBottom: 33, + paddingHorizontal: 32, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: Gray[900], + borderRadius: Radius.lg, + }, + statItem: { + width: 56, + flexDirection: 'column', + alignItems: 'center', + }, + statValue: { + ...Typography.heading2, + color: Magenta[200], + textAlign: 'center', + }, + statLabel: { + ...Typography.caption1Medium, + color: Magenta[200], + textAlign: 'center', + marginTop: 4, + includeFontPadding: false, + }, + statIconWrap: { + width: 24, + height: 24, + marginTop: 16, + }, + statIcon: { + width: 24, + height: 24, + }, + actionButtons: { + height: 136, + paddingHorizontal: 67, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + actionButton: { + width: 48, + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + actionButtonCircle: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: C.card, + alignItems: 'center', + justifyContent: 'center', + }, + actionIcon: { + width: 24, + height: 24, + }, + twitterIcon: { + width: 48, + height: 48, + }, + actionButtonText: { + ...Typography.caption1Medium, + color: C.card, + textAlign: 'center', + }, +}) diff --git a/src/features/profile/ui/ProfileHashtagSection.tsx b/src/features/profile/ui/ProfileHashtagSection.tsx index d939158..1617d0f 100644 --- a/src/features/profile/ui/ProfileHashtagSection.tsx +++ b/src/features/profile/ui/ProfileHashtagSection.tsx @@ -62,14 +62,13 @@ const styles = StyleSheet.create({ section: { paddingHorizontal: 16, paddingTop: 28, - paddingBottom: 48, + paddingBottom: 100, borderBottomWidth: 6, borderBottomColor: C.bg, backgroundColor: C.card, }, title: { ...Typography.heading3, - lineHeight: 25.2, color: C.text, }, canvas: { @@ -83,10 +82,9 @@ const styles = StyleSheet.create({ emptyOverlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', - justifyContent: 'center', + justifyContent: 'flex-start', }, emptyText: { - marginTop: 40, ...Typography.heading3, color: Gray[500], textAlign: 'center', @@ -95,6 +93,7 @@ const styles = StyleSheet.create({ width: 131, height: 36, marginTop: 12, + marginBottom: 40, }, absolute: { position: 'absolute', diff --git a/src/features/profile/ui/ProfilePreferGenreSection.tsx b/src/features/profile/ui/ProfilePreferGenreSection.tsx index 76661cb..865f402 100644 --- a/src/features/profile/ui/ProfilePreferGenreSection.tsx +++ b/src/features/profile/ui/ProfilePreferGenreSection.tsx @@ -2,12 +2,12 @@ import { Image } from 'expo-image' import { Pressable, StyleSheet, Text, View } from 'react-native' import { SvgXml } from 'react-native-svg' import { useRouter } from 'expo-router' -import { C, Gray } from '../../../theme' +import { C, Gray, Typography } from '../../../theme' import { useProfileGenreStats } from '../hooks/useProfileGenreStats' const findGenreButton = require('../../../../assets/icons/profile/find-genre.svg') -const GENRE_SVG: Record = { +export const GENRE_SVG: Record = { ROMANCE: ``, FANTASY: ``, ROFAN: ``, @@ -20,7 +20,7 @@ const GENRE_SVG: Record = { DAILY: ``, } -const genreLabels: Record = { +export const genreLabels: Record = { FANTASY: '\uD310\uD0C0\uC9C0', ACTION: '\uBB34\uD611', MODERN_FANTASY: '\uD604\uD310', @@ -50,7 +50,7 @@ const genreAliases: Record = { '\uC5ED\uC0AC/\uC0AC\uADF9': 'HISTORICAL', } -function normalizeGenreKey(genre: string) { +export function normalizeGenreKey(genre: string) { const trimmed = genre.trim() const enumLike = trimmed.toUpperCase().replace(/[\s-]+/g, '_') @@ -172,9 +172,7 @@ const styles = StyleSheet.create({ backgroundColor: C.card, }, title: { - fontSize: 18, - fontWeight: '600', - lineHeight: 25, + ...Typography.heading3, color: C.text, }, content: { diff --git a/src/features/profile/ui/ProfilePreferenceSection.tsx b/src/features/profile/ui/ProfilePreferenceSection.tsx index a010861..416111a 100644 --- a/src/features/profile/ui/ProfilePreferenceSection.tsx +++ b/src/features/profile/ui/ProfilePreferenceSection.tsx @@ -127,14 +127,10 @@ const styles = StyleSheet.create({ }, title: { ...Typography.heading3, - lineHeight: 25.2, color: C.text, }, count: { - fontFamily: 'SUIT', - fontSize: 18, - fontWeight: '600', - lineHeight: 25.2, + ...Typography.heading3, color: Gray[300], }, moreIcon: { diff --git a/src/features/profile/ui/ProfilePreferenceTabs.tsx b/src/features/profile/ui/ProfilePreferenceTabs.tsx index 534bbc5..d51828e 100644 --- a/src/features/profile/ui/ProfilePreferenceTabs.tsx +++ b/src/features/profile/ui/ProfilePreferenceTabs.tsx @@ -102,6 +102,6 @@ const styles = StyleSheet.create({ backgroundColor: C.text, }, tabIndicatorInactive: { - backgroundColor: Gray[50], + backgroundColor: Gray[200], }, }) diff --git a/src/features/profile/ui/ProfileRatingSection.tsx b/src/features/profile/ui/ProfileRatingSection.tsx index 3b0334b..2ea92cb 100644 --- a/src/features/profile/ui/ProfileRatingSection.tsx +++ b/src/features/profile/ui/ProfileRatingSection.tsx @@ -184,7 +184,6 @@ const styles = StyleSheet.create({ }, title: { ...Typography.heading3, - lineHeight: 25.2, color: C.text, }, errorText: { diff --git a/src/features/profile/ui/ProfileScreen.tsx b/src/features/profile/ui/ProfileScreen.tsx index c4efee1..889995c 100644 --- a/src/features/profile/ui/ProfileScreen.tsx +++ b/src/features/profile/ui/ProfileScreen.tsx @@ -1,25 +1,133 @@ -import { useState } from 'react' -import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from 'react-native' +import { useState, useMemo } from 'react' +import { ActivityIndicator, Modal, ScrollView, StyleSheet, Text, View } from 'react-native' +import { Image } from 'expo-image' import { Stack, useRouter } from 'expo-router' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { C, Gray } from '../../../theme' +import { C, Gray, Magenta } from '../../../theme' import { useMe } from '../hooks' +import { useProfileRatings } from '../hooks/useProfileRatings' +import { useProfileGenreStats } from '../hooks/useProfileGenreStats' import { ProfileActivityContent } from './ProfileActivityContent' import { ProfileHashtagSection } from './ProfileHashtagSection' -import { ProfilePreferGenreSection } from './ProfilePreferGenreSection' +import { + GENRE_SVG as PROFILE_GENRE_SVG, + ProfilePreferGenreSection, + genreLabels as profileGenreLabels, + normalizeGenreKey, +} from './ProfilePreferGenreSection' import { ProfilePreferenceSection } from './ProfilePreferenceSection' import { ProfilePreferenceTabs, type ProfilePreferenceTab } from './ProfilePreferenceTabs' import { ProfileRatingSection } from './ProfileRatingSection' import { ProfileTopBar } from './ProfileTopBar' import { ProfileUserSummary } from './ProfileUserSummary' +import { LevelProgress } from './LevelProgress' +import { ProfileCardModal } from './ProfileCardModalSimple' import type { ProfileActivityTab } from './ProfileActivityTabs' +const RATING_STEPS = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5] as const +const savedToast = require('../../../../assets/common/cardshare/image-gallery-saved.svg') + +const GENRE_SVG: Record = { + ROMANCE: ``, + // 다른 장르 SVG들은 필요시 추가 +} + +const genreLabels: Record = { + FANTASY: '판타지', + ACTION: '무협', + MODERN_FANTASY: '현판', + ROMANCE: '로맨스', + ROFAN: '로판', + DAILY: '일상', + BL: 'BL', + THRILLER: '스릴러', + DRAMA: '드라마', + HISTORICAL: '사극', +} + +const getLevelTitle = (level: number): string => { + if (level >= 100) return '전설의 독자' + if (level >= 90) return '명예 독자' + if (level >= 80) return '마스터 독자' + if (level >= 70) return '엘리트 독자' + if (level >= 60) return '베테랑 독자' + if (level >= 50) return '숙련 독자' + if (level >= 40) return '중견 독자' + if (level >= 30) return '열정 독자' + if (level >= 20) return '성장 독자' + if (level >= 10) return '활발한 독자' + if (level >= 5) return '새싹 독자' + return '신입 독자' +} + export function ProfileScreen() { const router = useRouter() const insets = useSafeAreaInsets() const { data: me, isLoading, isError } = useMe() const [activeTab, setActiveTab] = useState('analysis') const [activeActivityTab, setActiveActivityTab] = useState('posts') + const [showCardModal, setShowCardModal] = useState(false) + const [showSavedToast, setShowSavedToast] = useState(false) + + const ratingsQuery = useProfileRatings() + const genreStatsQuery = useProfileGenreStats() + + // 평균 별점 및 리뷰 수 계산 + const { averageRating, totalReviews } = useMemo(() => { + const ratingCounts = ratingsQuery.data ?? {} + const parsed: Record = {} + + for (const [key, value] of Object.entries(ratingCounts)) { + const normalized = key.trim().replace(/_/g, '.') + const match = normalized.match(/(\d+(\.\d+)?)/g) + const numericKey = match ? match[match.length - 1] : normalized + const rating = Number.parseFloat(numericKey) + + if (!Number.isNaN(rating)) { + parsed[rating] = (parsed[rating] ?? 0) + (Number.isFinite(value) ? Number(value) : 0) + } + } + + const stepCounts: Record = {} + for (const step of RATING_STEPS) { + stepCounts[step] = parsed[step] ?? 0 + } + + const ratingData = RATING_STEPS.map((rating) => ({ + rating, + count: stepCounts[rating] ?? 0, + })) + + const total = ratingData.reduce((sum, item) => sum + item.count, 0) + if (total === 0) return { averageRating: 0, totalReviews: 0 } + + const weightedSum = ratingData.reduce( + (sum, item) => sum + item.rating * item.count, + 0, + ) + + return { + averageRating: Math.round((weightedSum / total) * 10) / 10, + totalReviews: total, + } + }, [ratingsQuery.data]) + + // 최고 점수 장르 찾기 + const topGenre = useMemo(() => { + const genres = genreStatsQuery.data ?? [] + if (genres.length === 0) return null + + const top = genres.reduce((max, current) => + current.score > max.score ? current : max + , genres[0]) + + const genreKey = normalizeGenreKey(top.genre) + + return { + name: profileGenreLabels[genreKey] ?? genreLabels[genreKey] ?? top.genre, + svg: PROFILE_GENRE_SVG[genreKey] ?? GENRE_SVG[genreKey], + } + }, [genreStatsQuery.data]) if (isLoading) { return ( @@ -42,26 +150,54 @@ export function ProfileScreen() { if (activeTab === 'activity') { return ( - - - - router.push('/profile/settings')} /> - - - - - } - /> + <> + + + + router.push('/profile/settings')} + onPressProfileCard={() => setShowCardModal(true)} + /> + + + + + + } + /> + setShowCardModal(false)} + nickname={me.nickName} + title={me.title ?? getLevelTitle(me.level ?? 0)} + averageRating={averageRating} + topGenreName={topGenre?.name ?? me.topGenre} + reviewCount={totalReviews} + topGenreIconSvg={topGenre?.svg} + onSaveSuccess={() => { + setShowSavedToast(true) + setTimeout(() => setShowSavedToast(false), 1500) + }} + /> + ) } return ( + <> - router.push('/profile/settings')} /> + router.push('/profile/settings')} + onPressProfileCard={() => setShowCardModal(true)} + /> + + setShowCardModal(false)} + nickname={me.nickName} + title={me.title ?? getLevelTitle(me.level ?? 0)} + averageRating={averageRating} + topGenreName={topGenre?.name ?? me.topGenre} + reviewCount={totalReviews} + topGenreIconSvg={topGenre?.svg} + onSaveSuccess={() => { + setShowSavedToast(true) + setTimeout(() => setShowSavedToast(false), 1500) + }} + /> + + {/* 저장 완료 토스트 */} + {showSavedToast && ( + + + + + + )} + ) } @@ -108,4 +279,17 @@ const styles = StyleSheet.create({ lineHeight: 18, color: Gray[500], }, + toastContainer: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + justifyContent: 'center', + zIndex: 100, + elevation: 100, + }, + toastImage: { + width: 320, + height: 82, + }, }) diff --git a/src/features/profile/ui/ProfileTopBar.tsx b/src/features/profile/ui/ProfileTopBar.tsx index e5eff9a..ad81b37 100644 --- a/src/features/profile/ui/ProfileTopBar.tsx +++ b/src/features/profile/ui/ProfileTopBar.tsx @@ -6,15 +6,21 @@ const settingsIcon = require('../../../../assets/icons/common/settings.svg') type Props = { onPressSettings: () => void + onPressProfileCard?: () => void } -export function ProfileTopBar({ onPressSettings }: Props) { +export function ProfileTopBar({ onPressSettings, onPressProfileCard }: Props) { return ( 프로필 - + [styles.profileCardButton, pressed && styles.pressed]} + accessibilityRole="button" + > 프로필 카드 @@ -33,8 +39,8 @@ export function ProfileTopBar({ onPressSettings }: Props) { const styles = StyleSheet.create({ container: { - height: 56, - paddingHorizontal: 20, + paddingHorizontal: 16, + paddingVertical: 16, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', diff --git a/src/features/profile/ui/ProfileUserSummary.tsx b/src/features/profile/ui/ProfileUserSummary.tsx index faf7358..2f22502 100644 --- a/src/features/profile/ui/ProfileUserSummary.tsx +++ b/src/features/profile/ui/ProfileUserSummary.tsx @@ -1,4 +1,4 @@ -import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' import { Image } from 'expo-image' import { useRouter } from 'expo-router' import type { MeProfileResult } from '../../../types/profile' @@ -23,9 +23,11 @@ export function ProfileUserSummary({ me }: { me: MeProfileResult }) { - - Lv.{me.level} - + {me.title ? ( + + {me.title} + + ) : null} {me.nickName} @@ -38,7 +40,7 @@ export function ProfileUserSummary({ me }: { me: MeProfileResult }) { onPress={() => router.push('/profile/fix')} style={({ pressed }) => [styles.editButton, pressed && styles.pressed]} accessibilityRole="button" - accessibilityLabel={'\ud504\ub85c\ud544 \uc218\uc815'} + accessibilityLabel={'프로필 수정'} > @@ -72,6 +74,7 @@ const styles = StyleSheet.create({ }, textWrap: { width: 200, + alignItems: 'flex-start', gap: 6, }, levelBadge: { @@ -87,17 +90,15 @@ const styles = StyleSheet.create({ color: Magenta[300], }, nickname: { - fontFamily: 'SUIT', - fontSize: 18, - fontWeight: '600', - lineHeight: 25.2, + ...Typography.heading3, + width: '100%', + textAlign: 'left', color: C.text, }, bio: { - fontFamily: 'SUIT', - fontSize: 12, - fontWeight: '500', - lineHeight: 16.8, + ...Typography.caption1Medium, + width: '100%', + textAlign: 'left', maxWidth: 200, color: Gray[600], }, diff --git a/src/features/profile/ui/TitleAchievementModal.tsx b/src/features/profile/ui/TitleAchievementModal.tsx new file mode 100644 index 0000000..03e2dd3 --- /dev/null +++ b/src/features/profile/ui/TitleAchievementModal.tsx @@ -0,0 +1,128 @@ +import { Modal, Pressable, StyleSheet, Text, View } from 'react-native' +import { C, Gray, Magenta, Typography } from '../../../theme' + +type TitleAchievementModalProps = { + visible: boolean + onClose: () => void + title: string + genre: string +} + +export function TitleAchievementModal({ + visible, + onClose, + title, + genre, +}: TitleAchievementModalProps) { + return ( + + + e.stopPropagation()}> + {/* 칭호명 */} + {title} + + {/* "칭호를 획득하였습니다!" */} + 칭호를 획득하였습니다! + + {/* 장르 아이콘 (80x80) */} + + {/* TODO: 장르별 SVG 아이콘 추가 */} + + {genre} + + + + {/* 설명 */} + + 모든 칭호는 활동 점수가{'\n'}가장 높은 장르에 기반해 부여됩니다. + + + {/* 확인 버튼 */} + [ + styles.confirmButton, + pressed && styles.confirmButtonPressed, + ]} + onPress={onClose} + > + 확인 + + + + + ) +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(19, 17, 18, 0.60)', + justifyContent: 'center', + alignItems: 'center', + }, + modal: { + width: 306, + backgroundColor: C.card, + borderRadius: 8, + paddingTop: 28, + paddingBottom: 16, + paddingHorizontal: 16, + alignItems: 'center', + }, + title: { + fontSize: 20, + fontWeight: '800', + lineHeight: 28, + color: Magenta[300], + textAlign: 'center', + }, + subtitle: { + ...Typography.heading2, + color: Gray[900], + textAlign: 'center', + marginTop: 2, + }, + iconContainer: { + marginTop: 16, + alignItems: 'center', + }, + iconPlaceholder: { + width: 80, + height: 80, + backgroundColor: Gray[100], + borderRadius: 40, + justifyContent: 'center', + alignItems: 'center', + }, + genreText: { + ...Typography.body2Bold, + color: '#010101', + }, + description: { + ...Typography.body2Medium, + color: Gray[500], + textAlign: 'center', + marginTop: 16, + }, + confirmButton: { + height: 49, + width: '100%', + marginTop: 28, + backgroundColor: Gray[900], + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + }, + confirmButtonPressed: { + opacity: 0.8, + }, + confirmButtonText: { + ...Typography.body1Medium, + color: C.card, + }, +}) diff --git a/src/features/profile/ui/TitleInfoModal.tsx b/src/features/profile/ui/TitleInfoModal.tsx new file mode 100644 index 0000000..edee555 --- /dev/null +++ b/src/features/profile/ui/TitleInfoModal.tsx @@ -0,0 +1,137 @@ +import { Modal, Pressable, StyleSheet, Text, View } from 'react-native' +import { C, Gray, Magenta, Typography } from '../../../theme' + +type TitleInfoModalProps = { + visible: boolean + onClose: () => void + topGenre: string | null + title: string | null +} + +export function TitleInfoModal({ visible, onClose, topGenre, title }: TitleInfoModalProps) { + return ( + + + e.stopPropagation()}> + {/* 타이틀 */} + 칭호는 어떻게 정해지나요? + + {/* 설명 */} + + 칭호는 활동점수가 가장 높은 장르를 기준으로 정해져요. + + + {/* 대표 장르 */} + + + 대표 장르 + + + {topGenre || '아직 대표 장르가 정해지지 않았어요!'} + + + + {/* 적용 칭호 */} + + + 적용 칭호 + + {title || '-'} + + + {/* 안내 문구 */} + + 대표 장르 점수가 높아지면 칭호 단계도 함께 올라갑니다.{'\n'} + 다음 칭호는 획득 시 공개돼요. + + + {/* 확인 버튼 */} + [ + styles.confirmButton, + pressed && styles.confirmButtonPressed, + ]} + onPress={onClose} + > + 확인 + + + + + ) +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(19, 17, 18, 0.60)', + justifyContent: 'center', + alignItems: 'center', + }, + modal: { + width: 306, + backgroundColor: C.card, + borderRadius: 8, + paddingTop: 28, + paddingBottom: 16, + paddingHorizontal: 16, + }, + title: { + ...Typography.heading2, + color: Gray[900], + textAlign: 'left', + }, + description: { + ...Typography.caption1Medium, + color: Gray[500], + marginTop: 4, + textAlign: 'left', + }, + infoRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 16, + gap: 8, + }, + labelChip: { + paddingVertical: 4, + paddingHorizontal: 8, + borderRadius: 13, + backgroundColor: Magenta[300], + }, + labelText: { + ...Typography.caption1Extrabold, + color: C.card, // white + }, + valueText: { + ...Typography.caption1Semibold, + color: Magenta[300], + flex: 1, + }, + notice: { + ...Typography.caption1Medium, + color: Gray[500], + marginTop: 16, + textAlign: 'left', + }, + confirmButton: { + height: 49, + marginTop: 28, + backgroundColor: Gray[900], + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + }, + confirmButtonPressed: { + opacity: 0.8, + }, + confirmButtonText: { + ...Typography.body1Medium, + color: C.card, // white + }, +}) diff --git a/src/features/users/api/users.api.ts b/src/features/users/api/users.api.ts new file mode 100644 index 0000000..2da9b69 --- /dev/null +++ b/src/features/users/api/users.api.ts @@ -0,0 +1,6 @@ +import { apiClient } from '../../../lib/api/axios-instance' + +// POST /api/v1/users/{targetUserId}/block +export const blockUser = async (targetUserId: number): Promise => { + await apiClient.post(`/api/v1/users/${targetUserId}/block`) +} diff --git a/src/lib/auth/social/native.ts b/src/lib/auth/social/native.ts index c83ba3b..1fc9590 100644 --- a/src/lib/auth/social/native.ts +++ b/src/lib/auth/social/native.ts @@ -126,13 +126,21 @@ export const nativeSocialAuthProvider: NativeSocialAuthProvider = { } const accessToken = response.successResponse?.accessToken + const refreshToken = response.successResponse?.refreshToken + if (!accessToken) { throw new Error( '[NaverLogin] isSuccess was true but successResponse.accessToken is missing.', ) } - return { accessToken } + if (!refreshToken) { + throw new Error( + '[NaverLogin] isSuccess was true but successResponse.refreshToken is missing.', + ) + } + + return { accessToken, refreshToken } }, logoutNaver: async (): Promise => { diff --git a/src/lib/auth/social/types.ts b/src/lib/auth/social/types.ts index d729823..b5b69bf 100644 --- a/src/lib/auth/social/types.ts +++ b/src/lib/auth/social/types.ts @@ -18,6 +18,7 @@ export interface KakaoNativeTokens { export interface NaverNativeTokens { accessToken: string + refreshToken: string } // 네이티브 플랫폼(SDK 기반)의 로그인 계약 diff --git a/src/lib/utils/formatCreatedAtLabel.ts b/src/lib/utils/formatCreatedAtLabel.ts new file mode 100644 index 0000000..0636996 --- /dev/null +++ b/src/lib/utils/formatCreatedAtLabel.ts @@ -0,0 +1,14 @@ +export function formatCreatedAtLabel(value?: string | null) { + const text = value?.trim() + if (!text) return '' + + const isoDate = text.match(/^(\d{4}-\d{1,2}-\d{1,2})T\d{1,2}:\d{2}/) + if (isoDate) return isoDate[1] + + const separatedDate = text.match( + /^(\d{4}[./-]\d{1,2}[./-]\d{1,2}\.?)(?:\s+\d{1,2}:\d{2})/, + ) + if (separatedDate) return separatedDate[1] + + return text +} diff --git a/src/theme/global.ts b/src/theme/global.ts index 8166428..0998a4d 100644 --- a/src/theme/global.ts +++ b/src/theme/global.ts @@ -97,26 +97,26 @@ export const FontFamily = { // lineHeight = round(fontSize × 1.4) (2.0 --line-height-tight: 140%) export const Typography = { - heading1: { fontFamily: FontFamily.bold, fontSize: 24, lineHeight: 34 } as TextStyle, + heading1: { fontFamily: FontFamily.bold, fontSize: 24, lineHeight: 33.6 } as TextStyle, heading2: { fontFamily: FontFamily.bold, fontSize: 20, lineHeight: 28 } as TextStyle, - heading3: { fontFamily: FontFamily.semibold, fontSize: 18, lineHeight: 26 } as TextStyle, - heading4: { fontFamily: FontFamily.semibold, fontSize: 16, lineHeight: 22 } as TextStyle, + heading3: { fontFamily: FontFamily.semibold, fontSize: 18, lineHeight: 25.2 } as TextStyle, + heading4: { fontFamily: FontFamily.semibold, fontSize: 16, lineHeight: 22.4 } as TextStyle, - body1Medium: { fontFamily: FontFamily.medium, fontSize: 16, lineHeight: 22 } as TextStyle, - body1Semibold: { fontFamily: FontFamily.semibold, fontSize: 16, lineHeight: 22 } as TextStyle, - body1Bold: { fontFamily: FontFamily.bold, fontSize: 16, lineHeight: 22 } as TextStyle, + body1Medium: { fontFamily: FontFamily.medium, fontSize: 16, lineHeight: 22.4 } as TextStyle, + body1Semibold: { fontFamily: FontFamily.semibold, fontSize: 16, lineHeight: 22.4 } as TextStyle, + body1Bold: { fontFamily: FontFamily.bold, fontSize: 16, lineHeight: 22.4 } as TextStyle, - body2Medium: { fontFamily: FontFamily.medium, fontSize: 14, lineHeight: 20 } as TextStyle, - body2Bold: { fontFamily: FontFamily.bold, fontSize: 14, lineHeight: 20 } as TextStyle, + body2Medium: { fontFamily: FontFamily.medium, fontSize: 14, lineHeight: 19.6 } as TextStyle, + body2Bold: { fontFamily: FontFamily.bold, fontSize: 14, lineHeight: 19.6 } as TextStyle, - caption1Medium: { fontFamily: FontFamily.medium, fontSize: 12, lineHeight: 17 } as TextStyle, - caption1Semibold: { fontFamily: FontFamily.semibold, fontSize: 12, lineHeight: 17 } as TextStyle, - caption1Extrabold:{ fontFamily: FontFamily.extrabold,fontSize: 12, lineHeight: 17 } as TextStyle, + caption1Medium: { fontFamily: FontFamily.medium, fontSize: 12, lineHeight: 16.8 } as TextStyle, + caption1Semibold: { fontFamily: FontFamily.semibold, fontSize: 12, lineHeight: 16.8 } as TextStyle, + caption1Extrabold:{ fontFamily: FontFamily.extrabold,fontSize: 12, lineHeight: 16.8 } as TextStyle, caption2Medium: { fontFamily: FontFamily.medium, fontSize: 10, lineHeight: 14 } as TextStyle, caption2Extrabold:{ fontFamily: FontFamily.extrabold,fontSize: 10, lineHeight: 14 } as TextStyle, - dateText: { fontFamily: FontFamily.bold, fontSize: 16, lineHeight: 22, color: Gray[500] } as TextStyle, + dateText: { fontFamily: FontFamily.bold, fontSize: 16, lineHeight: 22.4, color: Gray[500] } as TextStyle, } as const; // ─── Spacing scale ──────────────────────────────────────────────────────────── diff --git a/src/types/profile.ts b/src/types/profile.ts index a999058..4426dbc 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -1,11 +1,21 @@ -// Full profile shape returned by GET /api/v1/profile/me -// Sourced from storix-fe/src/lib/api/profile/profile.api.ts in STORIX-FE-2.0. +// Full profile shape returned by GET /api/v2/profile/me +// V2 includes title system and genre progress export type MeProfileResult = { userId: number role: string profileImageUrl: string | null nickName: string - level: number point: number - profileDescription: string + profileDescription: string | null + oauthProvider: string + // V2 fields: title system + topGenre: string + title: string | null + stage: string + nextStage: string + topGenreScore: number + remainingScore: number + progressPercentage: number + // Legacy compatibility + level?: number }