diff --git a/generateSitemap.mjs b/generateSitemap.mjs index 2f3855b9..e86521dd 100644 --- a/generateSitemap.mjs +++ b/generateSitemap.mjs @@ -16,7 +16,6 @@ const generateSitemap = async () => { { url: '/open-consult', changefreq: 'always', priority: 1.0 }, { url: '/open-consult/likes', changefreq: 'always', priority: 0.8 }, { url: '/open-consult/recents', changefreq: 'always', priority: 0.8 }, - { url: '/categorySearch', changefreq: 'daily', priority: 0.7 }, { url: '/service', changefreq: 'never', priority: 0.8 }, { url: '/service-unavailable', changefreq: 'monthly', priority: 0.5 }, ]; diff --git a/package-lock.json b/package-lock.json index d3d1153e..944130aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react-cookie": "^7.0.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-helmet": "^6.1.0", "react-microsoft-clarity": "^1.2.0", "react-router-dom": "^6.21.1", "react-scripts": "5.0.1", @@ -44,6 +45,7 @@ }, "devDependencies": { "@types/react-dom": "^18.2.18", + "@types/react-helmet": "^6.1.11", "husky": "4" } }, @@ -4713,6 +4715,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-helmet": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.11.tgz", + "integrity": "sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -15486,6 +15497,25 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, + "node_modules/react-helmet": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz", + "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==", + "dependencies": { + "object-assign": "^4.1.1", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.1.1", + "react-side-effect": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.3.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -15614,6 +15644,14 @@ "node": ">=10" } }, + "node_modules/react-side-effect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz", + "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==", + "peerDependencies": { + "react": "^16.3.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-textarea-autosize": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz", diff --git a/package.json b/package.json index fc04ea91..4d02293f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react-cookie": "^7.0.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-helmet": "^6.1.0", "react-microsoft-clarity": "^1.2.0", "react-router-dom": "^6.21.1", "react-scripts": "5.0.1", @@ -69,6 +70,7 @@ }, "devDependencies": { "@types/react-dom": "^18.2.18", + "@types/react-helmet": "^6.1.11", "husky": "4" } } diff --git a/public/android-icon-144x144.png b/public/android-icon-144x144.png deleted file mode 100644 index 7e9e9be0..00000000 Binary files a/public/android-icon-144x144.png and /dev/null differ diff --git a/public/android-icon-192x192.png b/public/android-icon-192x192.png deleted file mode 100644 index 12424a35..00000000 Binary files a/public/android-icon-192x192.png and /dev/null differ diff --git a/public/android-icon-36x36.png b/public/android-icon-36x36.png deleted file mode 100644 index 71f760a4..00000000 Binary files a/public/android-icon-36x36.png and /dev/null differ diff --git a/public/android-icon-48x48.png b/public/android-icon-48x48.png deleted file mode 100644 index c4b1ed95..00000000 Binary files a/public/android-icon-48x48.png and /dev/null differ diff --git a/public/android-icon-72x72.png b/public/android-icon-72x72.png deleted file mode 100644 index fc39c887..00000000 Binary files a/public/android-icon-72x72.png and /dev/null differ diff --git a/public/android-icon-96x96.png b/public/android-icon-96x96.png deleted file mode 100644 index fa3cb613..00000000 Binary files a/public/android-icon-96x96.png and /dev/null differ diff --git a/public/android/android-icon-144x144.png b/public/android/android-icon-144x144.png new file mode 100644 index 00000000..7e81ba4b Binary files /dev/null and b/public/android/android-icon-144x144.png differ diff --git a/public/android/android-icon-192x192.png b/public/android/android-icon-192x192.png new file mode 100644 index 00000000..164fb98a Binary files /dev/null and b/public/android/android-icon-192x192.png differ diff --git a/public/android/android-icon-36x36.png b/public/android/android-icon-36x36.png new file mode 100644 index 00000000..5b8790d2 Binary files /dev/null and b/public/android/android-icon-36x36.png differ diff --git a/public/android/android-icon-48x48.png b/public/android/android-icon-48x48.png new file mode 100644 index 00000000..f9b13f46 Binary files /dev/null and b/public/android/android-icon-48x48.png differ diff --git a/public/android/android-icon-512x512.png b/public/android/android-icon-512x512.png new file mode 100644 index 00000000..508b24e2 Binary files /dev/null and b/public/android/android-icon-512x512.png differ diff --git a/public/android/android-icon-72x72.png b/public/android/android-icon-72x72.png new file mode 100644 index 00000000..fd22bc08 Binary files /dev/null and b/public/android/android-icon-72x72.png differ diff --git a/public/android/android-icon-96x96.png b/public/android/android-icon-96x96.png new file mode 100644 index 00000000..da0addd9 Binary files /dev/null and b/public/android/android-icon-96x96.png differ diff --git a/public/apple-icon-114x114.png b/public/apple-icon-114x114.png deleted file mode 100644 index a95f2cfb..00000000 Binary files a/public/apple-icon-114x114.png and /dev/null differ diff --git a/public/apple-icon-120x120.png b/public/apple-icon-120x120.png deleted file mode 100644 index 93c6ceee..00000000 Binary files a/public/apple-icon-120x120.png and /dev/null differ diff --git a/public/apple-icon-144x144.png b/public/apple-icon-144x144.png deleted file mode 100644 index 7e9e9be0..00000000 Binary files a/public/apple-icon-144x144.png and /dev/null differ diff --git a/public/apple-icon-152x152.png b/public/apple-icon-152x152.png deleted file mode 100644 index e0df9c44..00000000 Binary files a/public/apple-icon-152x152.png and /dev/null differ diff --git a/public/apple-icon-180x180.png b/public/apple-icon-180x180.png deleted file mode 100644 index 70a142dc..00000000 Binary files a/public/apple-icon-180x180.png and /dev/null differ diff --git a/public/apple-icon-57x57.png b/public/apple-icon-57x57.png deleted file mode 100644 index 9855c166..00000000 Binary files a/public/apple-icon-57x57.png and /dev/null differ diff --git a/public/apple-icon-60x60.png b/public/apple-icon-60x60.png deleted file mode 100644 index a28ce16b..00000000 Binary files a/public/apple-icon-60x60.png and /dev/null differ diff --git a/public/apple-icon-72x72.png b/public/apple-icon-72x72.png deleted file mode 100644 index fc39c887..00000000 Binary files a/public/apple-icon-72x72.png and /dev/null differ diff --git a/public/apple-icon-76x76.png b/public/apple-icon-76x76.png deleted file mode 100644 index 492aa09a..00000000 Binary files a/public/apple-icon-76x76.png and /dev/null differ diff --git a/public/apple-icon-precomposed.png b/public/apple-icon-precomposed.png deleted file mode 100644 index 2debf969..00000000 Binary files a/public/apple-icon-precomposed.png and /dev/null differ diff --git a/public/apple-icon.png b/public/apple-icon.png deleted file mode 100644 index 2debf969..00000000 Binary files a/public/apple-icon.png and /dev/null differ diff --git a/public/index.html b/public/index.html index f466b1fd..2244527f 100644 --- a/public/index.html +++ b/public/index.html @@ -22,6 +22,56 @@ + + + + + + + + + + + + + + ; + + /** + * Returns a Promise that resolves to a DOMString containing either "accepted" or "dismissed". + */ + readonly userChoice: Promise<{ + outcome: 'accepted' | 'dismissed'; + platform: string; + }>; + + /** + * Allows a developer to show the install prompt at a time of their own choosing. + * This method returns a Promise. + */ + prompt(): Promise; +} diff --git a/src/components/Buyer/BuyerOpenConsultDetail/MainQuestionSection.tsx b/src/components/Buyer/BuyerOpenConsultDetail/MainQuestionSection.tsx index 2140cf88..be6cf971 100644 --- a/src/components/Buyer/BuyerOpenConsultDetail/MainQuestionSection.tsx +++ b/src/components/Buyer/BuyerOpenConsultDetail/MainQuestionSection.tsx @@ -18,6 +18,8 @@ import { deletePostLikes, deletePostScraps } from 'api/delete'; import { formattedMessage } from 'utils/formattedMessage'; import { Flex } from 'components/Common/Flex'; +import { Helmet } from 'react-helmet'; + // // // @@ -128,40 +130,46 @@ function MainQuestionSection() { // return ( - - - - {card?.title} - {!card?.isPublic && ( - - - 비공개 - - )} - - - {formattedMessage(card?.content)} - - - {card?.updatedAt} - - {card?.consultCategory} - - - - - - {isLike ? : } - - {card?.totalLike} - - - {isSave ? : } - - {card?.totalScrap} - - - + <> + + {`${card?.title} | 셰어마인드 공개 상담`} + + + + + + {card?.title} + {!card?.isPublic && ( + + + 비공개 + + )} + + + {formattedMessage(card?.content)} + + + {card?.updatedAt} + + {card?.consultCategory} + + + + + + {isLike ? : } + + {card?.totalLike} + + + {isSave ? : } + + {card?.totalScrap} + + + + > ); } diff --git a/src/components/Common/Wrapper/PWAInstallWrapper.tsx b/src/components/Common/Wrapper/PWAInstallWrapper.tsx new file mode 100644 index 00000000..7b04dd12 --- /dev/null +++ b/src/components/Common/Wrapper/PWAInstallWrapper.tsx @@ -0,0 +1,168 @@ +import { Button } from 'components/Common/Button'; +import { Flex } from 'components/Common/Flex'; +import { useEffect, useState } from 'react'; + +import styled, { keyframes } from 'styled-components'; + +import { Black, Grey5, White } from 'styles/color'; +import { Body4 } from 'styles/font'; +import { getPWAInstallCase } from 'utils/device'; + +const slideDown = keyframes` + from { + transform: translateX(-50%) translateY(-100%); + } + to { + transform: translateX(-50%) translateY(0); + } +`; + +const slideUp = keyframes` + from { + transform: translateX(-50%) translateY(0); + } + to { + transform: translateX(-50%) translateY(-100%); + } +`; + +const ToastContainer = styled.div<{ isClosing: boolean }>` + position: fixed; + width: fit-content; + box-sizing: border-box; + left: 50%; + top: 1rem; + background-color: ${White}; + color: ${Black}; + padding: 1rem 1.5rem; + border-radius: 0.5rem; + box-shadow: 0 0 6px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.05); + z-index: 1000; + + display: flex; + flex-direction: column; + gap: 0.75rem; + + animation: ${({ isClosing }) => (isClosing ? slideUp : slideDown)} 0.3s ease + forwards; +`; + +const PWAInstallWrapper = ({ children }: { children: React.ReactNode }) => { + const [installPromptEvent, setInstallPromptEvent] = + useState(null); + + const installCase = getPWAInstallCase(); + + const getPWAInstallToastContent = () => { + switch (installCase) { + case 'webview': + return null; + case 'direct': + return '앱을 홈 화면에 추가하여 더 편리하게 사용하세요'; + // 모바일 safari만 처리 + case 'guide': + return '공유 버튼 > 홈 화면에 추가를 눌러 더 편리하게 사용하세요'; + case 'unsupported': + return null; + } + }; + + const content = getPWAInstallToastContent(); + + const [isToastVisible, setToastVisible] = useState(false); // Toast 표시 여부 + const [isClosing, setIsClosing] = useState(false); // Toast 닫히는 애니메이션 상태 + + // `beforeinstallprompt` 이벤트 감지 + useEffect(() => { + console.log( + "BeforeInstallPromptEvent' in window", + 'BeforeInstallPromptEvent' in window, + ); + + const handleBeforeInstallPrompt = (event: Event) => { + event.preventDefault(); // 기본 동작 방지 + + setInstallPromptEvent(event as BeforeInstallPromptEvent); // 이벤트 저장 + setToastVisible(true); // Toast 표시 + }; + + if (installCase === 'direct') { + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + } else if (installCase === 'guide') { + setToastVisible(true); + } + + return () => { + if (installCase === 'direct') { + window.removeEventListener( + 'beforeinstallprompt', + handleBeforeInstallPrompt, + ); + } + }; + }, []); + + // 설치 실행 + const handleInstall = async () => { + console.log('installPromptEvent', installPromptEvent); + if (installPromptEvent) { + installPromptEvent.prompt(); // 설치 프롬프트 표시 + const choice = await installPromptEvent.userChoice; + console.log(`User choice: ${choice.outcome}`); // "accepted" 또는 "dismissed" + + setInstallPromptEvent(null); // 이벤트 초기화 + handleCloseToast(); // Toast 닫기 + } + }; + + // Toast 닫기 + const handleCloseToast = () => { + setIsClosing(true); // 닫히는 애니메이션 시작 + setTimeout(() => setToastVisible(false), 300); // 애니메이션 끝난 뒤 완전히 제거 + }; + + if (installCase === 'unsupported' || installCase === 'webview') { + return; + } + + return ( + <> + {children} + + {/* Toast UI */} + {isToastVisible && ( + + + {content} + + + + + {installCase === 'direct' ? ( + + ) : null} + + + + + )} + > + ); +}; + +export default PWAInstallWrapper; diff --git a/src/global.d.ts b/src/global.d.ts index 4684f627..acab1ff3 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -9,6 +9,15 @@ declare global { interface Window { PayApp: PayApp; } + + interface BeforeInstallPromptEvent extends Event { + readonly platforms: Array; + readonly userChoice: Promise<{ + outcome: 'accepted' | 'dismissed'; + platform: string; + }>; + prompt(): Promise; + } } export {}; diff --git a/src/index.tsx b/src/index.tsx index 5a578ab9..d388bbe3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,6 +12,8 @@ import AppLayout from 'App.Layout'; import { clarity } from 'react-microsoft-clarity'; +import * as serviceWorkerRegistration from './serviceWorkerRegistration'; + axios.defaults.withCredentials = true; const root = ReactDOM.createRoot( @@ -30,13 +32,17 @@ root.render( + {/* */} + {/* */} , ); + +serviceWorkerRegistration.register(); diff --git a/src/pages/Buyer/BuyerCounselorProfile.tsx b/src/pages/Buyer/BuyerCounselorProfile.tsx index 7452c4e0..40c9371b 100644 --- a/src/pages/Buyer/BuyerCounselorProfile.tsx +++ b/src/pages/Buyer/BuyerCounselorProfile.tsx @@ -12,6 +12,7 @@ import { import { Space } from 'components/Common/Space'; import { useLayoutEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import { useNavigate, useParams } from 'react-router-dom'; import styled from 'styled-components'; import { AppendCategoryType } from 'utils/AppendCategoryType'; @@ -102,52 +103,60 @@ export const BuyerCounselorProfile = () => { if (id !== undefined) { const counselorId = parseInt(id, 10); return ( - - - - - + + {`${profileData.nickname} | 셰어마인드 상담사 프로필`} + + + + + + + + {isInfo ? ( + <> + + + + + > + ) : ( + + )} + + - {isInfo ? ( - <> - - - - - > - ) : ( - - )} - - - + + > ); } else { return <>404 error>; diff --git a/src/serviceWorkerRegistration.ts b/src/serviceWorkerRegistration.ts new file mode 100644 index 00000000..dc75f78a --- /dev/null +++ b/src/serviceWorkerRegistration.ts @@ -0,0 +1,120 @@ +// src/serviceWorkerRegistration.ts + +// This file is used to register the service worker for PWA functionality. +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, + ), +); + +type Config = { + onUpdate?: (registration: ServiceWorkerRegistration) => void; + onSuccess?: (registration: ServiceWorkerRegistration) => void; +}; + +export function register(config?: Config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + const publicUrl = new URL( + process.env.PUBLIC_URL || window.location.origin, + window.location.href, + ); + if (publicUrl.origin !== window.location.origin) { + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + checkValidServiceWorker(swUrl, config); + + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker.', + ); + }); + } else { + registerValidSW(swUrl, config); + } + }); + } +} + +function registerValidSW(swUrl: string, config?: Config) { + navigator.serviceWorker + .register(swUrl) + .then((registration) => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed.', + ); + + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + console.log('Content is cached for offline use.'); + + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch((error) => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker(swUrl: string, config?: Config) { + fetch(swUrl, { + headers: { 'Service-Worker': 'script' }, + }) + .then((response) => { + const contentType = response.headers.get('content-type'); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + navigator.serviceWorker.ready.then((registration) => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.', + ); + }); +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then((registration) => { + registration.unregister(); + }) + .catch((error) => { + console.error(error.message); + }); + } +} diff --git a/src/utils/device.ts b/src/utils/device.ts new file mode 100644 index 00000000..b392e28c --- /dev/null +++ b/src/utils/device.ts @@ -0,0 +1,40 @@ +type InstallCase = 'webview' | 'direct' | 'guide' | 'unsupported'; + +export function detectEnvironment() { + const userAgent = navigator.userAgent.toLowerCase(); + + const isMobile = /iphone|ipad|android|mobile/.test(userAgent); + const isIos = /iphone|ipad/.test(userAgent); + const isAndroid = /android/.test(userAgent); + const isWebview = /(wv|webview)/.test(userAgent) || /; wv\)/.test(userAgent); + + const isChrome = /chrome/.test(userAgent) && !/edge|edg/.test(userAgent); + const isEdge = /edg/.test(userAgent); + const isFirefox = /firefox/.test(userAgent); + const isOpera = /opera|opr/.test(userAgent); + const isSafari = /safari/.test(userAgent) && !isChrome && !isEdge && !isOpera; + + return { + isMobile, + isIos, + isAndroid, + isWebview, + isChrome, + isEdge, + isFirefox, + isOpera, + isSafari, + }; +} + +export function getPWAInstallCase(): InstallCase { + const env = detectEnvironment(); + + if (env.isWebview) return 'webview'; // Webview 환경 + if (env.isChrome || env.isEdge || env.isOpera) return 'direct'; // ✅ 설치 가능 + // if (env.isFirefox || env.isSafari || env.isIos) return 'guide'; // 🚨 설치 안내 + // direct하지 않은 경우, 일단 safari 모바일만 guide로 처리 + if (env.isSafari && env.isMobile) return 'guide'; // 🚨 설치 안내 + + return 'unsupported'; // 기타 환경 +}