From 05c9fabf60955a385b396639b7ce22a313cf8e36 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 12 Aug 2026 21:38:43 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20iOS=20=EC=9D=B8=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=EA=B0=80=20=EB=AF=B8=EC=A7=80=EC=9B=90=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EB=A1=9C=20=EB=A7=89=ED=9E=88=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일반 이미지 복사 + App ID 없는 딥링크로는 인스타그램이 요청을 거부한다. 안드로이드는 인텐트 경로라 영향이 없어 그동안 드러나지 않았다. - 전용 pasteboard 키(com.instagram.sharedSticker.backgroundImage)로 이미지를 넘기는 네이티브 모듈 추가 (expo-clipboard 로는 지정 불가) - Facebook App ID 를 Info.plist 와 딥링크에 주입 (2023-01 부터 필수) - App ID 는 app.json 플러그인 설정에 두고 런타임이 같은 값을 읽어 중복 제거 --- apps/app/app.json | 6 +++ .../instagram-story/expo-module.config.json | 6 +++ .../ios/InstagramStory.podspec | 20 ++++++++ .../ios/InstagramStoryModule.swift | 46 +++++++++++++++++++ apps/app/modules/instagram-story/package.json | 7 +++ apps/app/modules/instagram-story/src/index.ts | 45 ++++++++++++++++++ apps/app/package.json | 1 + apps/app/plugins/withInstagramStoryShare.js | 26 +++++++++++ apps/app/utils/handleInstagramStory.ts | 25 +++++----- pnpm-lock.yaml | 3 ++ 10 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 apps/app/modules/instagram-story/expo-module.config.json create mode 100644 apps/app/modules/instagram-story/ios/InstagramStory.podspec create mode 100644 apps/app/modules/instagram-story/ios/InstagramStoryModule.swift create mode 100644 apps/app/modules/instagram-story/package.json create mode 100644 apps/app/modules/instagram-story/src/index.ts create mode 100644 apps/app/plugins/withInstagramStoryShare.js diff --git a/apps/app/app.json b/apps/app/app.json index 456114a8..52964cb1 100644 --- a/apps/app/app.json +++ b/apps/app/app.json @@ -188,6 +188,12 @@ "nativeAppKey": "326734abdf8de5c7e090a2fc72e1dce6" } ], + [ + "./plugins/withInstagramStoryShare.js", + { + "facebookAppId": "2060093567931900" + } + ], [ "@sentry/react-native", { diff --git a/apps/app/modules/instagram-story/expo-module.config.json b/apps/app/modules/instagram-story/expo-module.config.json new file mode 100644 index 00000000..59de73b7 --- /dev/null +++ b/apps/app/modules/instagram-story/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios"], + "ios": { + "modules": ["InstagramStoryModule"] + } +} diff --git a/apps/app/modules/instagram-story/ios/InstagramStory.podspec b/apps/app/modules/instagram-story/ios/InstagramStory.podspec new file mode 100644 index 00000000..eba40e7a --- /dev/null +++ b/apps/app/modules/instagram-story/ios/InstagramStory.podspec @@ -0,0 +1,20 @@ +Pod::Spec.new do |s| + s.name = 'InstagramStory' + s.version = '1.0.0' + s.summary = 'iOS 인스타그램 스토리 공유 (전용 pasteboard 키)' + s.description = '인스타그램이 요구하는 com.instagram.sharedSticker.* pasteboard 키로 이미지를 전달한다.' + s.author = '' + s.homepage = 'https://github.com/TeamPiKi/client' + s.platforms = { :ios => '15.1' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/app/modules/instagram-story/ios/InstagramStoryModule.swift b/apps/app/modules/instagram-story/ios/InstagramStoryModule.swift new file mode 100644 index 00000000..61db82d3 --- /dev/null +++ b/apps/app/modules/instagram-story/ios/InstagramStoryModule.swift @@ -0,0 +1,46 @@ +import ExpoModulesCore +import UIKit + +/** + * 인스타그램 스토리 공유 (iOS). + * + * 인스타그램은 일반 이미지 붙여넣기가 아니라 `com.instagram.sharedSticker.*` 전용 + * pasteboard 키를 읽는다. expo-clipboard 로는 이 키를 지정할 수 없어 직접 구현한다. + * https://developers.facebook.com/docs/instagram-platform/sharing-to-stories/ + */ +public class InstagramStoryModule: Module { + private static let backgroundImageKey = "com.instagram.sharedSticker.backgroundImage" + private static let scheme = "instagram-stories://share" + /** 붙여넣기 데이터가 남지 않도록 짧게 만료시킨다 */ + private static let pasteboardExpirySeconds: TimeInterval = 60 * 5 + + public func definition() -> ModuleDefinition { + Name("InstagramStory") + + AsyncFunction("shareBackgroundImage") { (base64: String, facebookAppId: String) -> String in + guard let imageData = Data(base64Encoded: base64) else { + return "error" + } + + // App ID 를 붙이지 않으면 인스타그램이 미지원 안내를 띄운다 (2023-01 이후 필수) + guard let url = URL(string: "\(Self.scheme)?source_application=\(facebookAppId)") else { + return "error" + } + + return await MainActor.run { + guard UIApplication.shared.canOpenURL(url) else { + return "notInstalled" + } + + UIPasteboard.general.setItems( + [[Self.backgroundImageKey: imageData]], + options: [.expirationDate: Date().addingTimeInterval(Self.pasteboardExpirySeconds)] + ) + + UIApplication.shared.open(url, options: [:], completionHandler: nil) + + return "success" + } + } + } +} diff --git a/apps/app/modules/instagram-story/package.json b/apps/app/modules/instagram-story/package.json new file mode 100644 index 00000000..d22b5273 --- /dev/null +++ b/apps/app/modules/instagram-story/package.json @@ -0,0 +1,7 @@ +{ + "name": "instagram-story", + "version": "1.0.0", + "description": "iOS 인스타그램 스토리 공유 (전용 pasteboard 키)", + "main": "src/index.ts", + "private": true +} diff --git a/apps/app/modules/instagram-story/src/index.ts b/apps/app/modules/instagram-story/src/index.ts new file mode 100644 index 00000000..4f52fa90 --- /dev/null +++ b/apps/app/modules/instagram-story/src/index.ts @@ -0,0 +1,45 @@ +import type { ShareInstagramStoryStatusT } from '@piki/core'; +import Constants from 'expo-constants'; +import { requireOptionalNativeModule } from 'expo-modules-core'; + +const PLUGIN_PATH = './plugins/withInstagramStoryShare.js'; + +/** + * app.json 에 등록한 withInstagramStoryShare 플러그인의 facebookAppId 를 읽는다. + * Info.plist 와 딥링크가 같은 값을 써야 해서 단일 출처로 둔다. + */ +export const getFacebookAppId = (): string => { + const plugins = Constants.expoConfig?.plugins ?? []; + + const entry = plugins.find( + (plugin): plugin is [string, { facebookAppId?: string }] => + Array.isArray(plugin) && plugin[0] === PLUGIN_PATH + ); + + return entry?.[1]?.facebookAppId ?? ''; +}; + +type InstagramStoryModuleT = { + shareBackgroundImage: ( + base64: string, + facebookAppId: string + ) => Promise; +}; + +/** iOS 전용 모듈 — 안드로이드/미포함 빌드에서는 null */ +const InstagramStoryModule = requireOptionalNativeModule('InstagramStory'); + +export const isInstagramStoryModuleAvailable = InstagramStoryModule !== null; + +/** + * 인스타그램 스토리 편집 화면으로 배경 이미지를 전달한다 (iOS). + * 모듈이 없거나 App ID 가 비어 있으면 'error' 를 돌려준다. + */ +export const shareInstagramStoryBackground = async ( + base64: string, + facebookAppId: string +): Promise => { + if (!InstagramStoryModule || !facebookAppId) return 'error'; + + return InstagramStoryModule.shareBackgroundImage(base64, facebookAppId); +}; diff --git a/apps/app/package.json b/apps/app/package.json index ddec6c43..05532e23 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -35,6 +35,7 @@ "expo-image-picker": "^17.0.11", "expo-intent-launcher": "~13.0.8", "expo-linking": "~8.0.11", + "expo-modules-core": "3.0.29", "expo-notifications": "^0.32.17", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", diff --git a/apps/app/plugins/withInstagramStoryShare.js b/apps/app/plugins/withInstagramStoryShare.js new file mode 100644 index 00000000..fa557aed --- /dev/null +++ b/apps/app/plugins/withInstagramStoryShare.js @@ -0,0 +1,26 @@ +const { withInfoPlist } = require('@expo/config-plugins'); + +/** + * iOS 인스타그램 스토리 공유에 필요한 Info.plist 설정. + * + * 2023-01 부터 인스타그램이 스토리 공유에 Facebook App ID 를 요구한다. + * 없으면 인스타그램이 "이 앱은 스토리 공유를 지원하지 않는다" 안내만 띄운다. + * https://developers.facebook.com/docs/instagram-platform/sharing-to-stories/ + * + * `instagram-stories` 스킴은 app.json 의 LSApplicationQueriesSchemes 에 이미 있다. + */ +const withInstagramStoryShare = (config, { facebookAppId } = {}) => { + if (!facebookAppId) { + // 값이 없으면 조용히 건너뛴다 — 빌드는 되고 스토리 공유만 동작하지 않는다. + // (환경변수 미설정인 CI/로컬에서 빌드 자체가 깨지지 않도록) + return config; + } + + return withInfoPlist(config, config => { + config.modResults.FacebookAppID = facebookAppId; + + return config; + }); +}; + +module.exports = withInstagramStoryShare; diff --git a/apps/app/utils/handleInstagramStory.ts b/apps/app/utils/handleInstagramStory.ts index 9be8edf1..05cce1ad 100644 --- a/apps/app/utils/handleInstagramStory.ts +++ b/apps/app/utils/handleInstagramStory.ts @@ -3,15 +3,21 @@ import { type ShareInstagramStoryStatusT, WEBBRIDGE_MESSAGE_TYPE, } from '@piki/core'; -import { setImageAsync } from 'expo-clipboard'; import { Directory, File, Paths } from 'expo-file-system'; /** content:// URI 변환은 아직 legacy 엔트리에만 있다 (루트 export 는 런타임에 throw) */ import { getContentUriAsync } from 'expo-file-system/legacy'; import { startActivityAsync } from 'expo-intent-launcher'; import { Linking, Platform } from 'react-native'; +import { getFacebookAppId, shareInstagramStoryBackground } from '@/modules/instagram-story/src'; import { WebBridge } from '@/utils/webBridge'; +/** + * 인스타그램이 스토리 공유 요청자를 식별하는 값 — 없으면 미지원 안내가 뜬다. + * app.json 의 withInstagramStoryShare 플러그인 설정을 그대로 읽어 값 중복을 피한다. + */ +const FACEBOOK_APP_ID = getFacebookAppId(); + /** 이 스킴이 열리면 인스타그램이 설치돼 있다는 뜻 */ const INSTAGRAM_STORIES_SCHEME = 'instagram-stories://share'; const ANDROID_ADD_TO_STORY_ACTION = 'com.instagram.share.ADD_TO_STORY'; @@ -33,16 +39,13 @@ const writeStoryImageFile = (base64: string) => { return file; }; -/** iOS — 인스타그램이 pasteboard 에서 이미지를 읽어가므로 딥링크보다 먼저 복사해야 한다 */ -const shareToStoryOnIos = async (base64: string): Promise => { - const canOpen = await Linking.canOpenURL(INSTAGRAM_STORIES_SCHEME); - if (!canOpen) return 'notInstalled'; - - await setImageAsync(base64); - await Linking.openURL(INSTAGRAM_STORIES_SCHEME); - - return 'success'; -}; +/** + * iOS — 전용 pasteboard 키(com.instagram.sharedSticker.*)로 넘겨야 인스타그램이 읽는다. + * 일반 이미지 복사로는 "이 앱은 스토리 공유를 지원하지 않는다" 안내만 뜬다. + * Facebook App ID 도 2023-01 부터 필수라 네이티브 모듈에서 함께 처리한다. + */ +const shareToStoryOnIos = async (base64: string): Promise => + shareInstagramStoryBackground(base64, FACEBOOK_APP_ID); /** Android — FileProvider 로 노출한 content:// URI 여야 인스타그램이 읽을 수 있다 */ const shareToStoryOnAndroid = async ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b87dc62..49d2071d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: expo-linking: specifier: ~8.0.11 version: 8.0.12(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-modules-core: + specifier: 3.0.29 + version: 3.0.29(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-notifications: specifier: ^0.32.17 version: 0.32.17(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2) From 43734c99e1bfcf3f1fa436c7557f6b02078a5990 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 12 Aug 2026 21:45:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=98=81=EC=88=98=EC=A6=9D=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=82=AC=EC=A7=84=EC=9D=B4=20=EB=B9=A0?= =?UTF-8?q?=EC=A7=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit html-to-image 가 이미지를 다시 fetch 할 때 mode:'cors' 로 요청하는데 /_next/image 프록시 응답에는 CORS 헤더가 없어 브라우저가 거부했다. 브라우저 캐시에 우연히 맞는 항목이 있을 때만 보여 산발적으로 재현됐다. 동일 origin 이라 CORS 가 불필요하고, img 태그도 crossOrigin 없이 로드되므로 mode 를 빼면 같은 캐시 항목을 재사용한다. --- .../app/tournament/[id]/result/_utils/shareReceiptImage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts b/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts index 49bb2fce..4ac912ef 100644 --- a/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts +++ b/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts @@ -77,7 +77,9 @@ export const captureReceiptImage = async (element: HTMLElement): Promise = // 상품 이미지는 /_next/image?url=... 로 쿼리에만 차이가 있다. 기본 캐시 키는 쿼리를 잘라내 // 모든 상품이 첫 번째 이미지로 그려지므로 쿼리까지 키에 포함시킨다. includeQueryParams: true, - fetchRequestInit: { cache: 'force-cache', mode: 'cors' }, + // mode 를 지정하지 않는다 — 상품 이미지는 /_next/image 프록시라 same-origin 이고, + // 'cors' 로 보내면 CORS 헤더가 없는 프록시 응답이 거부돼 캐시 미스일 때만 사진이 빠진다. + fetchRequestInit: { cache: 'force-cache' }, }); if (!blob) throw new Error('영수증 이미지 변환 실패'); From 2e81a572bb4417b80234438976bd097cf5ffd89e Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 12 Aug 2026 21:55:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=98=81=EC=88=98=EC=A6=9D=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=8B=9C=ED=8A=B8=EC=9D=98=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=B2=84=ED=8A=BC=EC=9D=B4=20=EB=92=A4?= =?UTF-8?q?=EB=8A=A6=EA=B2=8C=20=EB=82=98=ED=83=80=EB=82=98=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 여부를 useSyncExternalStore 로만 판정해 SSR 스냅샷이 항상 false 였다. hydration 이 끝나야 true 가 되어, 시트가 열린 뒤 스토리 공유 버튼이 갑자기 추가되며 버튼 줄이 다시 배치됐다. 서버에서 User-Agent 로 판정해 내려주고, UA 가 없는 구버전 앱을 위해 클라 판정을 fallback 으로 남긴다. --- .../[id]/result/_components/ResultClient.tsx | 12 ++++++++++-- .../receipt-share-dialog/ReceiptShareDialog.tsx | 13 +++++++++++-- apps/web/src/app/tournament/[id]/result/page.tsx | 5 +++-- apps/web/src/utils/getIsApp.ts | 15 +++++++++++++++ 4 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/utils/getIsApp.ts diff --git a/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx b/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx index 2bd840c5..c60da6fe 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx @@ -23,9 +23,11 @@ import ReceiptShareDialog from './receipt-share-dialog/ReceiptShareDialog'; type ResultClientProps = { tournamentId: number; isGuest?: boolean; + /** 서버가 UA 로 판정한 앱 여부 — hydration 전에도 앱 전용 UI 를 그리기 위해 받는다 */ + isApp?: boolean; }; -function ResultClient({ tournamentId, isGuest = false }: ResultClientProps) { +function ResultClient({ tournamentId, isGuest = false, isApp = false }: ResultClientProps) { const router = useRouter(); const { tournamentData } = useGetTournament(tournamentId); const [date] = useState(() => new Date()); @@ -70,7 +72,12 @@ function ResultClient({ tournamentId, isGuest = false }: ResultClientProps) { const mainPb = isGuest && !tournamentData.isRoot ? 'pb-[145px]' : 'pb-40'; return ( -
+
@@ -156,6 +163,7 @@ function ResultClient({ tournamentId, isGuest = false }: ResultClientProps) { tournamentName={tournamentName} result={result} date={date} + isApp={isApp} />
); diff --git a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx index a114cc33..aa60b781 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx @@ -29,6 +29,8 @@ type ReceiptShareDialogProps = { tournamentName: string; result: RankedProductT[]; date: Date; + /** 서버가 UA 로 판정한 앱 여부 — 스토리 공유 버튼 노출에 쓴다 */ + isApp?: boolean; }; type ShareActionProps = { @@ -74,18 +76,25 @@ function ReceiptShareDialog({ tournamentName, result, date, + isApp = false, }: ReceiptShareDialogProps) { const captureLayerRef = useRef(null); const [imageBlob, setImageBlob] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const { shareToStory, isSharing } = useInstagramStoryShare(); - /** 스토리 공유는 네이티브 전용 — SSR 은 false 로 두어 hydration mismatch 를 피한다 */ - const isAppEnvironment = useSyncExternalStore( + /** + * 스토리 공유는 네이티브 전용. + * 서버가 UA 로 판정한 값을 우선 쓴다 — 클라에서만 판정하면 hydration 후에야 정해져 + * 시트가 열린 뒤 버튼이 하나 늘며 뒤늦게 나타난다. + * UA 가 없는 구버전 앱을 위해 클라 판정(window 객체)을 fallback 으로 둔다. + */ + const isWebviewByClient = useSyncExternalStore( () => () => {}, () => isWebview(), () => false ); + const isAppEnvironment = isApp || isWebviewByClient; /** 시트가 열릴 때 한 번만 캡처하고, 그 blob 을 모든 액션이 재사용한다 */ useEffect(() => { diff --git a/apps/web/src/app/tournament/[id]/result/page.tsx b/apps/web/src/app/tournament/[id]/result/page.tsx index 38518006..1478e11a 100644 --- a/apps/web/src/app/tournament/[id]/result/page.tsx +++ b/apps/web/src/app/tournament/[id]/result/page.tsx @@ -2,6 +2,7 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { redirect } from 'next/navigation'; import { ROUTES } from '@/consts/route'; +import { getIsApp } from '@/utils/getIsApp'; import { getIsGuest } from '@/utils/getIsGuest'; import { getQueryClient } from '@/utils/queryClient'; @@ -27,11 +28,11 @@ async function TournamentResultPage({ params }: TournamentResultPageProps) { redirect(ROUTES.TOURNAMENT_MATCH(tournamentId)); } - const isGuest = await getIsGuest(); + const [isGuest, isApp] = await Promise.all([getIsGuest(), getIsApp()]); return ( - + ); } diff --git a/apps/web/src/utils/getIsApp.ts b/apps/web/src/utils/getIsApp.ts new file mode 100644 index 00000000..924c54c1 --- /dev/null +++ b/apps/web/src/utils/getIsApp.ts @@ -0,0 +1,15 @@ +import { headers } from 'next/headers'; + +import { isWebview } from '@/utils/webBridge'; + +/** + * RSC 전용. User-Agent 로 앱(웹뷰) 여부를 판정한다. + * + * 클라이언트에서 `useSyncExternalStore` 로 판정하면 hydration 후에야 값이 정해져 + * 앱 전용 UI 가 뒤늦게 나타난다. 서버에서 미리 내려주면 첫 렌더부터 확정된다. + */ +export const getIsApp = async (): Promise => { + const userAgent = (await headers()).get('user-agent'); + + return isWebview(userAgent); +}; From 496f171dd121929cbdf45e91ccdcb4088bcb8e6d Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Thu, 13 Aug 2026 01:13:32 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=98=81=EC=88=98=EC=A6=9D=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EC=9D=98=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=C2=B7=EC=8D=B8=EB=84=A4=EC=9D=BC=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=91=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 종이 폭은 그대로인데 내용이 시안보다 작았다. 시안 실측 기준 썸네일이 종이 폭의 23.1% 여야 하는데 16.2% 였다. - zoom 1.28 → 1.42 (렌더 폭이 줄어 종이 폭 370px 은 유지) - fitZoom 축소 시 종이 폭까지 좁아지던 문제도 함께 보정 --- .../receipt-share-dialog/ReceiptShareCaptureLayer.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareCaptureLayer.tsx b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareCaptureLayer.tsx index 3d850e66..8410ad60 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareCaptureLayer.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareCaptureLayer.tsx @@ -5,8 +5,10 @@ import PikiLogoCart from '@/assets/images/piki-logo-cart.svg'; import type { RankedProductT } from '../../../_common/_types/tournament'; import ReceiptPaper from '../ReceiptPaper'; -const RECEIPT_ZOOM = 1.28; -const RECEIPT_RENDER_WIDTH_PX = 370 / RECEIPT_ZOOM; +/** 종이 폭은 고정하고 zoom 으로 내용만 키운다 — 렌더 폭이 줄어 결과 폭은 동일하다 */ +const RECEIPT_PAPER_WIDTH_PX = 370; +const RECEIPT_ZOOM = 1.42; +const RECEIPT_RENDER_WIDTH_PX = RECEIPT_PAPER_WIDTH_PX / RECEIPT_ZOOM; const RECEIPT_BOX_HEIGHT_PX = 748; @@ -22,7 +24,7 @@ const ReceiptShareCaptureLayer = forwardRef(null); const zoomRef = useRef(null); - /** 내용이 고정 영역을 넘치면 zoom 을 낮춰 맞춘다 */ + /** 내용이 넘치면 zoom 을 낮춘다. 종이 폭이 좁아지지 않게 렌더 폭도 함께 보정한다. */ useLayoutEffect(() => { const paper = paperRef.current; const zoomWrap = zoomRef.current; @@ -30,11 +32,13 @@ const ReceiptShareCaptureLayer = forwardRef