diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9ff1549 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +./backend/.env +./backend/.env.dev \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4d317ab --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,60 @@ +name: CI/CD Docker to Server + +on: + push: + branches: [ "test-deploy" ] # test-deploy 브랜치에 push될 때 실행 + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: 체크아웃 소스코드 + uses: actions/checkout@v4 + + - name: Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: Docker Hub 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker 이미지 빌드 및 푸시 + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/onmyway:test + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: SSH로 서버 접속 및 배포 명령 실행 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_KEY }} + script: | + # 1. Docker Hub 로그인 + echo "${{ secrets.DOCKERHUB_TOKEN }}" | sudo docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + + # 2. 최신 이미지 Pull + sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/onmyway:test + + # 3. 기존에 실행 중인 동일한 이름의 컨테이너 중지 및 삭제 + sudo docker stop test-onmyway || true + sudo docker rm test-onmyway || true + + # 5. ⭐ --env-file 옵션을 사용하여 컨테이너 실행 + sudo docker run -d \ + --name test-onmyway \ + -p 127.0.0.1:8081:8081 \ + --env-file .env.dev \ + ${{ secrets.DOCKERHUB_USERNAME }}/onmyway:test + + # 6. 사용하지 않는 구버전 이미지 청소 + sudo docker image prune -f diff --git a/.gitignore b/.gitignore index 44a9881..7ae49a6 100644 --- a/.gitignore +++ b/.gitignore @@ -170,7 +170,7 @@ bin/ out/ !**/src/main/**/out/ !**/src/test/**/out/ -.env +backend/.env ### NetBeans ### /nbproject/private/ @@ -196,9 +196,10 @@ out/ # ========================= # Env files # ========================= -.env +backend/.env +backend/.env.dev # ========================= # Git 내부 # ========================= -onmyway.git/ \ No newline at end of file +onmyway.git/.env diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 9bf92ab..ff903d3 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -7,20 +7,19 @@ - + - - + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index af7442b..0000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - postgresql - true - org.postgresql.Driver - jdbc:postgresql://localhost:5432/onmyway - - - - - - $ProjectFileDir$ - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 345615c..a5d7fc9 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -4,4 +4,7 @@ + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index 942ad07..ed51d87 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,8 +2,8 @@ - + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b84f89c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive", + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d4af24e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# --- 1단계: Frontend 빌드 --- +FROM node:24 AS frontend-builder +WORKDIR /build-fe +COPY frontend/ganeungil/package*.json ./ +RUN npm install +COPY frontend/ganeungil ./ +RUN npm run build + +# --- 2단계: Backend 빌드 (FE 결과물 포함) --- +FROM gradle:7.6-jdk17 AS backend-builder +WORKDIR /build-be +# 1. 설정 파일만 먼저 복사 +COPY ./backend/gradlew . +COPY ./backend/gradle/ gradle/ +COPY ./backend/build.gradle ./backend/settings.gradle ./ + +# 2. 의존성 미리 다운로드 (이 단계가 캐싱되어 다음엔 1초만에 넘어감) +RUN ./gradlew dependencies --no-daemon + +# 3. 그 다음 소스 복사 및 빌드 +COPY . . +# 위에서 빌드한 FE 정적 파일들을 BE의 static 폴더로 복사 +COPY --from=frontend-builder /build-fe/dist ./backend/src/main/resources/static +COPY backend/src ./src + +RUN ./gradlew -p ./backend bootJar --no-daemon + +# --- 3단계: 최종 실행 이미지 --- +FROM eclipse-temurin:17-jdk-alpine +WORKDIR /app +RUN ls -R /app +# 빌드된 jar 파일만 가져오기 +COPY --from=backend-builder /build-be/backend/build/libs/*.jar app.jar + +EXPOSE 8081 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/Onboard_past.jsx b/Onboard_past.jsx new file mode 100644 index 0000000..a93bd1e --- /dev/null +++ b/Onboard_past.jsx @@ -0,0 +1,490 @@ +import { useState, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import api from "../../api/api"; +import Sidebar20 from "./Sidebar_2.0"; + +// ── 에셋 (헤더 + 장소 상세 카드용) ── +import imgPlace from "@/assets/img-place.jpg"; +import iconDrink from "@/assets/icon-drink.svg"; +import iconFood from "@/assets/icon-food.svg"; +import iconRest from "@/assets/icon-rest.svg"; +import iconShop from "@/assets/icon-shop.svg"; +import iconView from "@/assets/icon-view.svg"; +import iconHeart from "@/assets/icon-heart.svg"; +import iconDestPin from "@/assets/icon-destination-pin.svg"; + +const CATEGORY_LABEL_TO_ID = { + "한 잔": 1, + "한 입": 2, + "한 숨": 3, + "한 판": 4, + "한 눈": 5, + "한 끼": 6, +}; + +const CAT_ICON = { + "한잔": iconDrink, + "한입": iconFood, + "한숨": iconRest, + "한판": iconShop, + "한눈": iconView, + "한끼": iconFood, +}; + +// 위치 미허용 시 기본 중심점: 부산대학교 정문 +const PUSAN_UNIV = { lat: 35.2316, lng: 129.0839 }; + +async function loadRecommendations(lat, lng) { + const res = await api.get("/places/recommend", { params: { lat, lng } }); + return res.data; // { categories: [ { categoryId, categoryName, places, featured } ] } +} + +function toPlaceList(raw, startId = 0) { + if (!Array.isArray(raw)) return []; + return raw.map((p, i) => ({ + id: p.id ?? startId + i + 1, + name: p.name, + category: p.category, + walkMin: p.walkingMinutes, + lat: p.lat, + lng: p.lng, + isOpen: p.isOpen ?? p.open ?? true, + closeTime: p.closeTime ?? null, + openTime: p.openTime ?? null, + imageURL: p.imageURL ?? null, + desc: "", + tags: [], + })); +} + +// 사이드바용: 카테고리 전체 places +function mapToRecs(categories, categoryLabel) { + const targetId = CATEGORY_LABEL_TO_ID[categoryLabel]; + const raw = + categoryLabel === "전체" + ? categories.flatMap((c) => c.places ?? []) + : (categories.find((c) => c.categoryId === targetId)?.places ?? []); + return toPlaceList(raw); +} + +// 지도 마커용: featured (없으면 places[0] 폴백) +function mapToFeatured(categories, categoryLabel) { + const targetId = CATEGORY_LABEL_TO_ID[categoryLabel]; + const raw = + categoryLabel === "전체" + ? categories.flatMap((c) => c.featured ?? c.places?.[0] ?? []) + : (() => { + const c = categories.find((c) => c.categoryId === targetId); + return c?.featured ?? c?.places?.[0] ?? []; + })(); + return toPlaceList(raw); +} + +const fmt = (t) => t?.slice(0, 5) ?? null; + +function HoursLabel({ place }) { + if (place.isOpen) { + if (!place.closeTime) return 영업 중; + return 영업 중 ({fmt(place.closeTime)}에 종료); + } + if (!place.openTime) return 영업 종료; + return 영업 종료 ({fmt(place.openTime)}에 시작); +} + +export default function Onboard20() { + const navigate = useNavigate(); + const [activeCategory, setActiveCategory] = useState("전체"); + const [locStatus, setLocStatus] = useState("pending"); // pending | granted | denied + const [userCoords, setUserCoords] = useState(null); // { lat, lng } + const [allCategories, setAllCategories] = useState([]); // API 전체 응답 + const [recs, setRecs] = useState([]); // 사이드바: 전체 places + const [featuredRecs, setFeaturedRecs] = useState([]); // 지도 마커: featured만 + const [selectedPlace, setSelectedPlace] = useState(null); + const [recsState, setRecsState] = useState("visible"); // visible | fading | hidden + const [isOffline, setIsOffline] = useState(!navigator.onLine); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [mapReady, setMapReady] = useState(false); + + // 카카오맵 관련 refs + const mapContainerRef = useRef(null); //
DOM 노드 + const kakaoMapRef = useRef(null); // kakao.maps.Map 인스턴스 + const circleRef = useRef(null); // 500m 반경 Circle + const userDotRef = useRef(null); // 내 위치 CustomOverlay + const overlaysRef = useRef([]); // 추천 마커 CustomOverlay[] + const recsRef = useRef([]); // recs 최신값 (window 콜백에서 참조) + const featuredRef = useRef([]); // featuredRecs 최신값 (마커 클릭 콜백에서 참조) + const destMarkerRef = useRef(null); // 목적지 마커 CustomOverlay + + // ref 동기화 + useEffect(() => { recsRef.current = recs; }, [recs]); + useEffect(() => { featuredRef.current = featuredRecs; }, [featuredRecs]); + + // ── 지도 마커 클릭 핸들러 (window에 등록 → CustomOverlay HTML에서 호출) + useEffect(() => { + window.__onMarkerClick = (id) => { + const place = featuredRef.current.find(p => p.id === id); + if (!place) return; + setSelectedPlace(prev => (prev?.id === id ? null : place)); + }; + return () => { delete window.__onMarkerClick; }; + }, []); + + // ── 위치 권한 요청 (네이티브 UI) + useEffect(() => { + if (!navigator.geolocation) { setLocStatus("denied"); return; } + navigator.geolocation.getCurrentPosition( + (pos) => { + const coords = { lat: pos.coords.latitude, lng: pos.coords.longitude }; + console.log("[위치] 허용됨:", coords); + setUserCoords(coords); + setLocStatus("granted"); + loadRecommendations(coords.lat, coords.lng) + .then((data) => { +setAllCategories(data.categories); + setRecs(mapToRecs(data.categories, "전체")); + setFeaturedRecs(mapToFeatured(data.categories, "전체")); + }) + .catch(console.error); + }, + (err) => { console.error("[위치] 거부됨:", err); setLocStatus("denied"); } + ); + }, []); + + // ── 네트워크 상태 감지 + useEffect(() => { + const on = () => setIsOffline(false); + const off = () => setIsOffline(true); + window.addEventListener("online", on); + window.addEventListener("offline", off); + return () => { window.removeEventListener("online", on); window.removeEventListener("offline", off); }; + }, []); + + // ── 카카오맵 초기화 (SDK 로드 대기 후 실행) + useEffect(() => { + const init = () => { + if (!window.kakao?.maps || !mapContainerRef.current) { + setTimeout(init, 100); + return; + } + if (kakaoMapRef.current) return; // 이미 초기화됨 (StrictMode 이중 실행 방지) + try { + const center = new window.kakao.maps.LatLng(PUSAN_UNIV.lat, PUSAN_UNIV.lng); + kakaoMapRef.current = new window.kakao.maps.Map(mapContainerRef.current, { + center, + level: 4, + }); + window.kakao.maps.event.addListener(kakaoMapRef.current, "click", () => { + setSidebarOpen(false); + }); + setMapReady(true); + console.log("카카오맵 초기화 성공"); + } catch (e) { + console.error("카카오맵 초기화 실패:", e); + } + }; + init(); + }, []); + + // ── 위치 확정 시 지도 중심 이동 + 반경 원 + 내 위치 마커 + useEffect(() => { + console.log("[위치 effect]", { locStatus, userCoords, mapReady, map: !!kakaoMapRef.current }); + const map = kakaoMapRef.current; + if (!map) return; + + // 기존 원/마커 제거 + if (circleRef.current) { circleRef.current.setMap(null); circleRef.current = null; } + if (userDotRef.current) { userDotRef.current.setMap(null); userDotRef.current = null; } + + if (locStatus === "denied") { + map.setCenter(new window.kakao.maps.LatLng(PUSAN_UNIV.lat, PUSAN_UNIV.lng)); + return; + } + if (locStatus !== "granted" || !userCoords) return; + + const pos = new window.kakao.maps.LatLng(userCoords.lat, userCoords.lng); + map.setCenter(pos); + + // 250m 반경 원 + circleRef.current = new window.kakao.maps.Circle({ + center: pos, + radius: 250, + strokeWeight: 2, + strokeColor: "#c8873a", + strokeOpacity: 0.5, + fillColor: "#c8873a", + fillOpacity: 0.08, + map, + }); + + // 내 위치 점 + userDotRef.current = new window.kakao.maps.CustomOverlay({ + position: pos, + content: `
`, + map, + yAnchor: 0.5, + xAnchor: 0.5, + }); + }, [locStatus, userCoords, mapReady]); + + // ── 지도 마커 갱신: featured 장소만 표시 + useEffect(() => { + const map = kakaoMapRef.current; + if (!map) return; + + // 기존 마커 제거 + overlaysRef.current.forEach(o => o.setMap(null)); + overlaysRef.current = []; + + if (recsState === "hidden" || locStatus !== "granted") return; + + // 겹치는 마커 분리: 20m(~0.00018°) 이내 좌표는 나선형으로 오프셋 + const SPREAD = 0.00018; + const spiralOffsets = [ + [0, 0], [SPREAD, 0], [-SPREAD, 0], + [0, SPREAD], [0, -SPREAD], [SPREAD, SPREAD], [-SPREAD, -SPREAD], + ]; + const placed = []; + + featuredRecs.forEach(place => { + let lat = place.lat; + let lng = place.lng; + let offsetIdx = 0; + while ( + placed.some(p => Math.abs(p.lat - lat) < SPREAD * 0.9 && Math.abs(p.lng - lng) < SPREAD * 0.9) && + offsetIdx < spiralOffsets.length - 1 + ) { + offsetIdx++; + lat = place.lat + spiralOffsets[offsetIdx][0]; + lng = place.lng + spiralOffsets[offsetIdx][1]; + } + placed.push({ lat, lng }); + + const content = ` +
+ `; + const overlay = new window.kakao.maps.CustomOverlay({ + position: new window.kakao.maps.LatLng(lat, lng), + content, + map, + yAnchor: 1, + }); + overlaysRef.current.push(overlay); + }); + }, [featuredRecs, recsState, locStatus, mapReady]); + + // ── 핸들러 ── + const handleCategoryChange = (label) => { + if (label === activeCategory) return; + setActiveCategory(label); + setSelectedPlace(null); + setRecs(mapToRecs(allCategories, label)); + }; + + const handleRecsHide = () => { + if (recsState !== "visible") return; + setRecsState("fading"); + setTimeout(() => setRecsState("hidden"), 280); + }; + + const handleRecsShow = () => { + setRecsState("visible"); + setSelectedPlace(null); + }; + + // 목적지 마커 제거 + const clearDestMarker = () => { + if (destMarkerRef.current) { + destMarkerRef.current.setMap(null); + destMarkerRef.current = null; + } + }; + + // 카카오 장소 검색 결과 선택 → 지도 이동 + 마커 표시 + const handleDestinationSelect = (place) => { + const map = kakaoMapRef.current; + if (!map) return; + const pos = new window.kakao.maps.LatLng(parseFloat(place.y), parseFloat(place.x)); + map.setCenter(pos); + map.setLevel(3); + + clearDestMarker(); + + destMarkerRef.current = new window.kakao.maps.CustomOverlay({ + position: pos, + content: ` +
+ +
+ `, + map, + yAnchor: 1, + xAnchor: 0, + }); + }; + + // 현재 위치 보정 + const handleRecalibrate = () => { + if (!navigator.geolocation) return; + navigator.geolocation.getCurrentPosition( + (pos) => { + const coords = { lat: pos.coords.latitude, lng: pos.coords.longitude }; + setUserCoords(coords); + setLocStatus("granted"); + setRecsState("visible"); + setSelectedPlace(null); + loadRecommendations(coords.lat, coords.lng) + .then((data) => { + setAllCategories(data.categories); + setRecs(mapToRecs(data.categories, activeCategory)); + setFeaturedRecs(mapToFeatured(data.categories, "전체")); + }) + .catch(console.error); + }, + () => {} + ); + }; + + const granted = locStatus === "granted"; + const showOverlay = granted && recsState !== "hidden"; + const overlayFading = recsState === "fading"; + + + return ( +
+ + + {/* ── 메인 ── */} +
+ + {/* ── 카카오맵 컨테이너 (항상 렌더링 → 초기화 가능) ── */} +
+
+
+ + {/* ── 사이드바 ── */} + setSidebarOpen(prev => !prev)} + onRecalibrate={handleRecalibrate} + onRecsHide={handleRecsHide} + onRecsShow={handleRecsShow} + onDestinationSelect={handleDestinationSelect} + onDestinationClear={clearDestMarker} + /> + + {/* ── 장소 상세 카드 ── */} + {selectedPlace && ( +
+
+ {selectedPlace.name} + + + {selectedPlace.category} + +
+
+
+

{selectedPlace.name}

+ +
+

+ 내 위치로부터 도보 {selectedPlace.walkMin}분 +

+
+ + {selectedPlace.isOpen ? "영업 중" : "영업 종료"} + + {selectedPlace.isOpen && selectedPlace.closeTime && ( + {selectedPlace.closeTime}에 종료 + )} + {!selectedPlace.isOpen && selectedPlace.openTime && ( + {selectedPlace.openTime}에 시작 + )} +
+

+ {selectedPlace.desc} +

+
+ {selectedPlace.tags.map(tag => ( + + {tag} + + ))} +
+
+
+ )} + + {/* ── 인터넷 불안정 오류 ── */} + {isOffline && ( +
+ + 인터넷 연결이 불안정합니다 +
+ )} +
+
+ ); +} diff --git a/backend/.idea/compiler.xml b/backend/.idea/compiler.xml index f78527c..bffbcb3 100644 --- a/backend/.idea/compiler.xml +++ b/backend/.idea/compiler.xml @@ -13,6 +13,10 @@ - \ No newline at end of file diff --git a/backend/.idea/dataSources.xml b/backend/.idea/dataSources.xml index 6e7923b..a6dec3a 100644 --- a/backend/.idea/dataSources.xml +++ b/backend/.idea/dataSources.xml @@ -25,5 +25,17 @@ $ProjectFileDir$ + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://onmyway.cb0u0uogmu8b.ap-northeast-2.rds.amazonaws.com:5432/postgres + + + + + + $ProjectFileDir$ + \ No newline at end of file diff --git a/backend/.idea/gradle.xml b/backend/.idea/gradle.xml index 7d3b3e8..64a3575 100644 --- a/backend/.idea/gradle.xml +++ b/backend/.idea/gradle.xml @@ -9,6 +9,8 @@ diff --git a/backend/.idea/modules.xml b/backend/.idea/modules.xml deleted file mode 100644 index ff77779..0000000 --- a/backend/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties index 37f78a6..1af9e09 100644 --- a/backend/gradle/wrapper/gradle-wrapper.properties +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/backend/settings.gradle b/backend/settings.gradle index 753a729..5b7ad0e 100644 --- a/backend/settings.gradle +++ b/backend/settings.gradle @@ -1 +1,3 @@ rootProject.name = 'onmyway' +include 'frontend' +include 'backend' \ No newline at end of file diff --git a/backend/src/main/java/_team/onmyway/config/ErrorConfig.java b/backend/src/main/java/_team/onmyway/config/ErrorConfig.java new file mode 100644 index 0000000..a04e655 --- /dev/null +++ b/backend/src/main/java/_team/onmyway/config/ErrorConfig.java @@ -0,0 +1,16 @@ +package _team.onmyway.config; + +import org.springframework.boot.web.server.ErrorPage; +import org.springframework.boot.web.server.ErrorPageRegistrar; +import org.springframework.boot.web.server.ErrorPageRegistry; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; + +@Configuration +public class ErrorConfig implements ErrorPageRegistrar { + @Override + public void registerErrorPages(ErrorPageRegistry registry) { + ErrorPage error404 = new ErrorPage(HttpStatus.NOT_FOUND, "/index.html"); + registry.addErrorPages(error404); + } +} diff --git a/backend/src/main/java/_team/onmyway/config/SecurityConfig.java b/backend/src/main/java/_team/onmyway/config/SecurityConfig.java index d47d09e..b914482 100644 --- a/backend/src/main/java/_team/onmyway/config/SecurityConfig.java +++ b/backend/src/main/java/_team/onmyway/config/SecurityConfig.java @@ -1,5 +1,6 @@ package _team.onmyway.config; +import _team.onmyway.repository.cookie.CookieAuthorizationRequestRepository; import _team.onmyway.security.OAuthFailureHandler; import _team.onmyway.security.OAuthSuccessHandler; import _team.onmyway.security.JwtAuthenticationFilter; @@ -25,6 +26,7 @@ public class SecurityConfig { private final OAuthSuccessHandler successHandler; private final OAuthFailureHandler failureHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CookieAuthorizationRequestRepository cookieRepository; @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -32,6 +34,12 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(authorizeRequests -> authorizeRequests .requestMatchers( + "/", + "/find-route", + "/index.html", + "/assets/**", + "/login", + "/error", "/oauth2/authorization/**", "/login/oauth2/code/**", "/api/auth/**", @@ -40,6 +48,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/places/**", "/route/**" ).permitAll() // 요청을 보낸 이가 누구이든 상관없이 통과되는 URL. + .requestMatchers( "/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll() .anyRequest().authenticated() ) .csrf(csrf -> csrf.disable()) @@ -48,6 +57,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .logout(logout -> logout.disable()) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .oauth2Login(oauth -> oauth + // oauth2를 이용할 때 로그인 시 사용할 로그인 페이지 지정(Custom) + .loginPage("/login") + .authorizationEndpoint(authEndpoint -> authEndpoint.authorizationRequestRepository(cookieRepository)) .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(customOAuthUserService)) // 사용자 정보 이용(회원가입 등) .successHandler(successHandler) // 로그인 완료 시 이동할 곳 .failureHandler(failureHandler) // 로그인 실패 시 이동할 곳 @@ -55,7 +67,6 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // JWT Filter 등록 http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - return http.build(); } diff --git a/backend/src/main/java/_team/onmyway/controller/HomeController.java b/backend/src/main/java/_team/onmyway/controller/HomeController.java new file mode 100644 index 0000000..1d049ed --- /dev/null +++ b/backend/src/main/java/_team/onmyway/controller/HomeController.java @@ -0,0 +1,12 @@ +package _team.onmyway.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + @GetMapping("/") + public String home() { + return "index.html"; + } +} diff --git a/backend/src/main/java/_team/onmyway/controller/RecommendationController.java b/backend/src/main/java/_team/onmyway/controller/RecommendationController.java index ba5d741..da5fbd1 100644 --- a/backend/src/main/java/_team/onmyway/controller/RecommendationController.java +++ b/backend/src/main/java/_team/onmyway/controller/RecommendationController.java @@ -4,6 +4,7 @@ import _team.onmyway.service.RecommendationService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; @RestController @RequiredArgsConstructor @@ -14,7 +15,7 @@ public class RecommendationController { // 전체 카테고리 로드: 카테고리별 7개(대표 포함) + 메인용 대표 1개 @GetMapping("/recommend") - public AllCategoryRecommendationsDTO recommendAllCategories( + public Mono recommendAllCategories( @RequestParam double lat, @RequestParam double lng ) { diff --git a/backend/src/main/java/_team/onmyway/controller/RouteController.java b/backend/src/main/java/_team/onmyway/controller/RouteController.java index 36a10dc..94e16c1 100644 --- a/backend/src/main/java/_team/onmyway/controller/RouteController.java +++ b/backend/src/main/java/_team/onmyway/controller/RouteController.java @@ -15,7 +15,10 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + import java.util.List; +import java.util.function.Function; @RestController @RequestMapping("/route") @@ -26,47 +29,34 @@ public class RouteController { private final ObjectMapper objectMapper; @PostMapping("/findOut") - public ResponseEntity getFindOutRoute(@RequestBody List positions) { - if (positions.isEmpty()) return new ResponseEntity<>(HttpStatus.BAD_REQUEST); - PositionDTO start = positions.get(0); - - RouteResponseDTO routing = routeService.findOutRoute(positions).block(); - AllCategoryRecommendationsDTO recommendations = recommendationService.recommendByRoute(routing, start.getLat(), start.getLon()); - - ObjectNode response = objectMapper.createObjectNode(); - response.set("route", objectMapper.valueToTree(routing)); - response.set("recommendations", objectMapper.valueToTree(recommendations)); - - return new ResponseEntity<>(response, HttpStatus.OK); + public Mono> getFindOutRoute(@RequestBody List positions) { + return processRouteAndRecommend(positions, routeService::findOutRoute); } @PostMapping("/right") - public ResponseEntity getRightRoute(@RequestBody List positions) { - if (positions.isEmpty()) return new ResponseEntity<>(HttpStatus.BAD_REQUEST); - PositionDTO start = positions.get(0); - - RouteResponseDTO routing = routeService.rightRoute(positions).block(); - AllCategoryRecommendationsDTO recommendations = recommendationService.recommendByRoute(routing, start.getLat(), start.getLon()); - - ObjectNode response = objectMapper.createObjectNode(); - response.set("route", objectMapper.valueToTree(routing)); - response.set("recommendations", objectMapper.valueToTree(recommendations)); - - return new ResponseEntity<>(response, HttpStatus.OK); + public Mono> getRightRoute(@RequestBody List positions) { + return processRouteAndRecommend(positions, routeService::rightRoute); } @PostMapping("/slow") - public ResponseEntity getRoute(@RequestBody List positions) { - if (positions.isEmpty()) return new ResponseEntity<>(HttpStatus.BAD_REQUEST); - PositionDTO start = positions.get(0); - - RouteResponseDTO routing = routeService.slowRoute(positions).block(); - AllCategoryRecommendationsDTO recommendations = recommendationService.recommendByRoute(routing, start.getLat(), start.getLon()); - - ObjectNode response = objectMapper.createObjectNode(); - response.set("route", objectMapper.valueToTree(routing)); - response.set("recommendations", objectMapper.valueToTree(recommendations)); + public Mono> getRoute(@RequestBody List positions) { + return processRouteAndRecommend(positions, routeService::slowRoute); + } - return new ResponseEntity<>(response, HttpStatus.OK); + // 비동기 로직 한 번에 묶기 + private Mono> processRouteAndRecommend( + List positions, + Function, Mono> processer) { + if (positions.isEmpty()) return Mono.just(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); + PositionDTO start = positions.get(0); + return processer.apply(positions) + .flatMap(routing -> recommendationService.recommendByRoute(routing, start.getLat(), start.getLon()) + .map(recommendations -> { + ObjectNode response = objectMapper.createObjectNode(); + response.set("route", objectMapper.valueToTree(routing)); + response.set("recommendations", objectMapper.valueToTree(recommendations)); + + return new ResponseEntity<>(response, HttpStatus.OK); + })); } } diff --git a/backend/src/main/java/_team/onmyway/dto/PlaceRecommendationDTO.java b/backend/src/main/java/_team/onmyway/dto/PlaceRecommendationDTO.java index 484f81e..0322c36 100644 --- a/backend/src/main/java/_team/onmyway/dto/PlaceRecommendationDTO.java +++ b/backend/src/main/java/_team/onmyway/dto/PlaceRecommendationDTO.java @@ -1,5 +1,6 @@ package _team.onmyway.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Getter; @@ -15,5 +16,9 @@ public class PlaceRecommendationDTO { private int walkingMinutes; private LocalTime openTime; private LocalTime closeTime; + + @JsonProperty("isOpen") private boolean isOpen; + + private String imageURL; } diff --git a/backend/src/main/java/_team/onmyway/dto/RecommendationResponseDTO.java b/backend/src/main/java/_team/onmyway/dto/RecommendationResponseDTO.java index 3357a7d..81add47 100644 --- a/backend/src/main/java/_team/onmyway/dto/RecommendationResponseDTO.java +++ b/backend/src/main/java/_team/onmyway/dto/RecommendationResponseDTO.java @@ -1,6 +1,7 @@ package _team.onmyway.dto; import _team.onmyway.entity.Place; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Getter; @@ -32,8 +33,12 @@ static class PlaceInfo { private int walkingMinutes; private LocalTime open; private LocalTime close; + + @JsonProperty("isOpen") private boolean isOpen; + private String imageURL; + public static PlaceInfo from(PlaceRecommendationDTO p, double userLat, double userLng) { double distance = calculateDistance(userLat, userLng, p.getLat(), p.getLng()); @@ -47,7 +52,8 @@ public static PlaceInfo from(PlaceRecommendationDTO p, double userLat, double us minutes, p.getOpenTime(), p.getCloseTime(), - p.isOpen() + p.isOpen(), + p.getImageURL() ); } diff --git a/backend/src/main/java/_team/onmyway/entity/Photos.java b/backend/src/main/java/_team/onmyway/entity/Photos.java index 6abfc46..d9e6d89 100644 --- a/backend/src/main/java/_team/onmyway/entity/Photos.java +++ b/backend/src/main/java/_team/onmyway/entity/Photos.java @@ -17,4 +17,8 @@ public class Photos { private Place place; private String photoURL; + + public String getPhotoURL() { + return photoURL; + } } diff --git a/backend/src/main/java/_team/onmyway/entity/Place.java b/backend/src/main/java/_team/onmyway/entity/Place.java index be279b4..12b1761 100644 --- a/backend/src/main/java/_team/onmyway/entity/Place.java +++ b/backend/src/main/java/_team/onmyway/entity/Place.java @@ -5,10 +5,13 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Table(indexes = { @@ -53,4 +56,12 @@ public class Place { @UpdateTimestamp private LocalDateTime updatedAt; + + @BatchSize(size = 100) + @OneToMany(mappedBy = "place") // 양방향 연결 + private List workingTimes = new ArrayList<>(); + + @BatchSize(size = 100) + @OneToMany(mappedBy = "place") + private List photos = new ArrayList<>(); } \ No newline at end of file diff --git a/backend/src/main/java/_team/onmyway/repository/CategoryMappingRepository.java b/backend/src/main/java/_team/onmyway/repository/CategoryMappingRepository.java index cf81f40..652e5d9 100644 --- a/backend/src/main/java/_team/onmyway/repository/CategoryMappingRepository.java +++ b/backend/src/main/java/_team/onmyway/repository/CategoryMappingRepository.java @@ -7,6 +7,5 @@ import java.util.Optional; public interface CategoryMappingRepository extends JpaRepository { - Optional findBySourceAndApiCategory(SourceType source, String apiCategory); } \ No newline at end of file diff --git a/backend/src/main/java/_team/onmyway/repository/PhotosRepository.java b/backend/src/main/java/_team/onmyway/repository/PhotosRepository.java new file mode 100644 index 0000000..00eb497 --- /dev/null +++ b/backend/src/main/java/_team/onmyway/repository/PhotosRepository.java @@ -0,0 +1,16 @@ +package _team.onmyway.repository; + +import _team.onmyway.entity.Photos; +import _team.onmyway.entity.Place; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface PhotosRepository extends JpaRepository { + boolean existsByPlaceId(Long placeId); + + Optional findFirstByPlaceId(Long placeId); + public List findByPlace(Place place); +} diff --git a/backend/src/main/java/_team/onmyway/repository/PlaceRepository.java b/backend/src/main/java/_team/onmyway/repository/PlaceRepository.java index b684270..25dca26 100644 --- a/backend/src/main/java/_team/onmyway/repository/PlaceRepository.java +++ b/backend/src/main/java/_team/onmyway/repository/PlaceRepository.java @@ -2,6 +2,7 @@ import _team.onmyway.entity.Place; import _team.onmyway.entity.ServiceCategory; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -40,12 +41,14 @@ List findRandomByCategoryInRadius( int limit ); + @EntityGraph(attributePaths = {"workingTimes", "serviceCategory"}) + // JPQL로 해결 @Query(value = """ - SELECT * FROM place p - WHERE p.service_category_id IN :categoryIds + SELECT p FROM Place p + WHERE p.serviceCategory.id IN :categoryIds AND p.lat BETWEEN :minLat AND :maxLat AND p.lng BETWEEN :minLng AND :maxLng - """, nativeQuery = true) + """) List findByBoundingBox( List categoryIds, double minLat, diff --git a/backend/src/main/java/_team/onmyway/repository/cookie/CookieAuthorizationRequestRepository.java b/backend/src/main/java/_team/onmyway/repository/cookie/CookieAuthorizationRequestRepository.java new file mode 100644 index 0000000..7eaa52b --- /dev/null +++ b/backend/src/main/java/_team/onmyway/repository/cookie/CookieAuthorizationRequestRepository.java @@ -0,0 +1,85 @@ +package _team.onmyway.repository.cookie; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.SerializationUtils; +import org.springframework.web.util.WebUtils; + +import java.util.Base64; + +@Component +public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository { + public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; + public static final String REDIRECT_URI = "redirect-uri"; + public static final String DEFAULT_URI = "/"; // 기본 화면으로 돌아가기. 테스트 사이트에서는 루트로 바꿔야 함. + private static final int COOKIE_EXPIRE_SECONDS = 180; + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + if (cookie != null) { + return deserialize(cookie.getValue(), OAuth2AuthorizationRequest.class); + } + return null; + } + + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { + if (authorizationRequest == null) { + removeAuthorizationRequest(request, response); + return; + } + + String encodedAuthRequest = serialize(authorizationRequest); + Cookie authCookie = new Cookie(OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, encodedAuthRequest); + authCookie.setPath("/"); + authCookie.setHttpOnly(true); + authCookie.setMaxAge(COOKIE_EXPIRE_SECONDS); + response.addCookie(authCookie); + + String redirectUirAfterLogin = request.getParameter(REDIRECT_URI); + + if (redirectUirAfterLogin == null || redirectUirAfterLogin.isBlank()) { + redirectUirAfterLogin = DEFAULT_URI; + } + + Cookie redirectCookie = new Cookie(REDIRECT_URI, redirectUirAfterLogin); + redirectCookie.setPath("/"); + redirectCookie.setHttpOnly(true); + redirectCookie.setMaxAge(COOKIE_EXPIRE_SECONDS); + response.addCookie(redirectCookie); + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { + return this.loadAuthorizationRequest(request); + } + + public void removeAuthorizationCookies(HttpServletRequest request, HttpServletResponse response) { + deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + deleteCookie(request, response, REDIRECT_URI); + } + + private void deleteCookie(HttpServletRequest request, HttpServletResponse response, String cookieName) { + Cookie cookie = WebUtils.getCookie(request, cookieName); + if (cookie != null) { + cookie.setValue(""); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + } + + private String serialize(Object object) { + return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object)); + } + + private T deserialize(String serialized, Class cls) { + byte[] decoded = Base64.getUrlDecoder().decode(serialized); + return cls.cast(SerializationUtils.deserialize(decoded)); + } +} diff --git a/backend/src/main/java/_team/onmyway/security/OAuthFailureHandler.java b/backend/src/main/java/_team/onmyway/security/OAuthFailureHandler.java index 061ce41..904cabe 100644 --- a/backend/src/main/java/_team/onmyway/security/OAuthFailureHandler.java +++ b/backend/src/main/java/_team/onmyway/security/OAuthFailureHandler.java @@ -15,6 +15,7 @@ public class OAuthFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - + System.out.println("OAUTH FAILURE HANDLER"); + System.out.println("에러 메시지: " + exception.getMessage()); } } diff --git a/backend/src/main/java/_team/onmyway/security/OAuthSuccessHandler.java b/backend/src/main/java/_team/onmyway/security/OAuthSuccessHandler.java index c8b0273..1fa0de1 100644 --- a/backend/src/main/java/_team/onmyway/security/OAuthSuccessHandler.java +++ b/backend/src/main/java/_team/onmyway/security/OAuthSuccessHandler.java @@ -2,7 +2,9 @@ import _team.onmyway.entity.Users; import _team.onmyway.repository.UsersRepository; +import _team.onmyway.repository.cookie.CookieAuthorizationRequestRepository; import _team.onmyway.service.JwtService; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.transaction.Transactional; @@ -11,6 +13,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.springframework.web.util.WebUtils; import java.io.IOException; @@ -20,6 +23,7 @@ public class OAuthSuccessHandler implements AuthenticationSuccessHandler { private final JwtService jwtService; private final UsersRepository usersRepository; + private final CookieAuthorizationRequestRepository cookieRepository; @Override @Transactional @@ -36,6 +40,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo usersRepository.save(user); // 쿠키에 담기 (HttpOnly + Secure) + // access Token으로 변경? ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) .secure(false) // 변경 @@ -44,9 +49,12 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo .maxAge(jwtService.getRefreshTokenExpirationMs() / 1000) .build(); + Cookie redirectCookie = WebUtils.getCookie(request, CookieAuthorizationRequestRepository.REDIRECT_URI); + String redirectURI = redirectCookie != null ? redirectCookie.getValue() : "/"; + response.addHeader("Set-Cookie", cookie.toString()); - response.sendRedirect("http://localhost:8080/swagger-ui/index.html"); + response.sendRedirect(redirectURI); // 맞춰야 할 부분 1(로그인 후 리다이렉트는 어디로) } } \ No newline at end of file diff --git a/backend/src/main/java/_team/onmyway/service/ImageService.java b/backend/src/main/java/_team/onmyway/service/ImageService.java new file mode 100644 index 0000000..96ac69e --- /dev/null +++ b/backend/src/main/java/_team/onmyway/service/ImageService.java @@ -0,0 +1,60 @@ +package _team.onmyway.service; + +import _team.onmyway.entity.Photos; +import _team.onmyway.entity.Place; +import _team.onmyway.repository.PhotosRepository; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.beans.factory.annotation.Value; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Service +public class ImageService { + + private final WebClient webClient; + private final PhotosRepository photosRepository; + + @Value("${naver.api.clientId}") + private String naverClientId; + + @Value("${naver.api.clientSecret}") + private String naverClientSecret; + + public ImageService(PhotosRepository photosRepository) { + this.photosRepository = photosRepository; + this.webClient = WebClient.builder() + .baseUrl("https://openapi.naver.com") + .defaultHeader("X-Naver-Client-Id", naverClientId) + .defaultHeader("X-Naver-Client-Secret", naverClientSecret) + .build(); + } + + public Mono getImageURL(Place p) { + boolean hasPhoto = photosRepository.existsById(p.getId()); + if (hasPhoto) { + return Mono.just(photosRepository.findFirstByPlaceId(p.getId()).get().getPhotoURL()); + } else { + return webClient.get() + .uri(uri -> uri + .path("/v1/search/image") + .queryParam("query",p.getName()+" 외관") + .queryParam("sort","sim") + .build()) + .retrieve() + .bodyToMono(Map.class) + .map(response -> { + // items 리스트 추출 + List> list = (List>) response.get("items"); + if (list == null || list.isEmpty()) { + return ""; // 결과 없으면 빈 문자열 + } + return (String) list.get(0).get("thumbnail"); + }) + .defaultIfEmpty("") // 혹시 모를 빈 응답 처리 + .onErrorReturn(""); // API 에러 발생 시 빈 문자열 반환 (서비스 중단 방지) + } + } +} diff --git a/backend/src/main/java/_team/onmyway/service/RecommendationService.java b/backend/src/main/java/_team/onmyway/service/RecommendationService.java index 9cf51f9..7e6cd67 100644 --- a/backend/src/main/java/_team/onmyway/service/RecommendationService.java +++ b/backend/src/main/java/_team/onmyway/service/RecommendationService.java @@ -11,11 +11,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.hibernate.jdbc.Work; import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.ZoneId; import java.util.*; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -28,27 +33,32 @@ public class RecommendationService { private final PlaceRepository placeRepository; private final ServiceCategoryRepository serviceCategoryRepository; private final GeoDistanceService geoDistanceService; - private final WorkingTimeRepository workingTimeRepository; + private final ImageService imageService; private static final double RADIUS_METERS = 250.0; private static final int DEFAULT_LIMIT_PER_CATEGORY = 7; private static final double METERS_PER_DEGREE = 111000.0; private static final List SUPPORTED_CATEGORY_IDS = List.of(1L, 2L, 3L, 4L, 5L, 6L); - public AllCategoryRecommendationsDTO recommend(double lat, double lng) { - List categories = SUPPORTED_CATEGORY_IDS.stream() - .map(categoryId -> recommendSingleCategory(lat, lng, categoryId)) - .toList(); - return new AllCategoryRecommendationsDTO(categories); + public Mono recommend(double lat, double lng) { + + return Flux.fromIterable(SUPPORTED_CATEGORY_IDS) + .flatMap(categoryId -> recommendSingleCategory(lat, lng, categoryId)) + .collectList() + .map(categories -> new AllCategoryRecommendationsDTO(categories)); +// List categories = SUPPORTED_CATEGORY_IDS.stream() +// .map(categoryId -> recommendSingleCategory(lat, lng, categoryId)) +// .toList(); +// return new AllCategoryRecommendationsDTO(categories); } - public AllCategoryRecommendationsDTO recommendByRoute(RouteResponseDTO routeResponse, double userLat, double userLng) { + public Mono recommendByRoute(RouteResponseDTO routeResponse, double userLat, double userLng) { // 1. T-map 응답에서 모든 경로 좌표 추출 List allPoints = extractRoutePoints(routeResponse); // 2. 경로의 총 거리에 비례하여 약 150m 간격으로 샘플링 List sampledPoints = samplePointsByDistance(allPoints, 150.0); - if (sampledPoints.isEmpty()) return new AllCategoryRecommendationsDTO(Collections.emptyList()); + if (sampledPoints.isEmpty()) return Mono.just(new AllCategoryRecommendationsDTO(Collections.emptyList())); // 3. 전체 경로를 포함하는 거대 Bounding Box 계산 (1차 필터링용) double minLat = sampledPoints.stream().mapToDouble(PositionDTO::getLat).min().orElse(0.0); @@ -77,28 +87,72 @@ public AllCategoryRecommendationsDTO recommendByRoute(RouteResponseDTO routeResp Map> groupedByCategoryId = filteredPlaces.stream() .collect(Collectors.groupingBy(p -> p.getServiceCategory().getId())); - List categoryDTOs = SUPPORTED_CATEGORY_IDS.stream() - .map(categoryId -> { - ServiceCategory category = serviceCategoryRepository.findById(categoryId).orElseThrow(); - List categoryPlaces = new ArrayList<>(groupedByCategoryId.getOrDefault(categoryId, Collections.emptyList())); - - Collections.shuffle(categoryPlaces); - int day = LocalDate.now().getDayOfWeek().getValue(); - List placeInfos = categoryPlaces.stream() - .limit(DEFAULT_LIMIT_PER_CATEGORY) - .map(p -> { - List placeWorkingTime = workingTimeRepository.findByPlace(p); - WorkingTime workingTime = placeWorkingTime.get(day); - return toPlaceRecommendationDTO(p, userLat, userLng, workingTime.isClosed(), workingTime.getOpenTime(), workingTime.getCloseTime()); - }) - .toList(); - - PlaceRecommendationDTO featured = placeInfos.isEmpty() ? null : placeInfos.get(0); - return new CategoryRecommendationDTO(categoryId, category.getName(), placeInfos, featured); - }) - .toList(); + int day = LocalDate.now().getDayOfWeek().getValue(); - return new AllCategoryRecommendationsDTO(categoryDTOs); + return Flux.fromIterable(SUPPORTED_CATEGORY_IDS) + .flatMap(categoryId -> + // 1. Optional을 Mono로 변환 + Mono.fromCallable(() -> serviceCategoryRepository.findById(categoryId)) + .subscribeOn(Schedulers.boundedElastic()) // JPA는 블로킹이므로 전용 스레드 할당 + .flatMap(optionalCategory -> Mono.justOrEmpty(optionalCategory)) + .flatMap(category -> { + // 2. 장소 목록 준비 및 셔플 + List categoryPlaces = new ArrayList<>(groupedByCategoryId.getOrDefault(categoryId, Collections.emptyList())); + Collections.shuffle(categoryPlaces); + + // 3. 비동기 스트림 처리 + return Flux.fromIterable(categoryPlaces) + .take(DEFAULT_LIMIT_PER_CATEGORY) + .flatMap(p -> + imageService.getImageURL(p) + .map(imageURL -> { + List workingTimes = p.getWorkingTimes(); + int index = day&7; + + if (workingTimes != null & !workingTimes.isEmpty() && index < workingTimes.size()) { + WorkingTime workingTime = workingTimes.get(index); + return toPlaceRecommendationDTO(p, userLat, userLng, + workingTime.isClosed(), workingTime.getOpenTime(), + workingTime.getCloseTime(), imageURL); + } else { + return toPlaceRecommendationDTO(p, userLat, userLng, true, + null, null, imageURL); + } + }) + ) + .collectList() + .map(placeInfos -> { + PlaceRecommendationDTO featured = placeInfos.isEmpty() ? null : placeInfos.get(0); + return new CategoryRecommendationDTO(categoryId, category.getName(), placeInfos, featured); + }); + }) + ) + .collectList() + .map(categoryDTOs -> new AllCategoryRecommendationsDTO(categoryDTOs)); + +// List categoryDTOs = SUPPORTED_CATEGORY_IDS.stream() +// .map(categoryId -> { +// ServiceCategory category = serviceCategoryRepository.findById(categoryId).orElseThrow(); +// List categoryPlaces = new ArrayList<>(groupedByCategoryId.getOrDefault(categoryId, Collections.emptyList())); +// +// Collections.shuffle(categoryPlaces); +// int day = LocalDate.now().getDayOfWeek().getValue(); +// List placeInfos = categoryPlaces.stream() +// .limit(DEFAULT_LIMIT_PER_CATEGORY) +// .map(p -> { +// List placeWorkingTime = p.getWorkingTimes(); +// WorkingTime workingTime = placeWorkingTime.get(day); +// String imageURL = imageService.getImageURL(p); +// return toPlaceRecommendationDTO(p, userLat, userLng, workingTime.isClosed(), workingTime.getOpenTime(), workingTime.getCloseTime(), imageURL); +// }) +// .toList(); +// +// PlaceRecommendationDTO featured = placeInfos.isEmpty() ? null : placeInfos.get(0); +// return new CategoryRecommendationDTO(categoryId, category.getName(), placeInfos, featured); +// }) +// .toList(); +// +// return new AllCategoryRecommendationsDTO(categoryDTOs); } private boolean isPlaceNearAnySamplePoint(Place place, List samples, double radiusMeters) { @@ -195,21 +249,55 @@ private List extractRoutePoints(RouteResponseDTO response) { } - private CategoryRecommendationDTO recommendSingleCategory(double lat, double lng, Long categoryId) { + private Mono recommendSingleCategory(double lat, double lng, Long categoryId) { ServiceCategory category = serviceCategoryRepository.findById(categoryId) .orElseThrow(); List places = getPlacesInRadius(lat, lng, categoryId, DEFAULT_LIMIT_PER_CATEGORY); int day = LocalDate.now().getDayOfWeek().getValue(); - List placeInfos = places.stream() - .map(place -> { - List placeWorkingTime = workingTimeRepository.findByPlace(place); - WorkingTime workingTime = placeWorkingTime.get(day); - return toPlaceRecommendationDTO(place, lat, lng, workingTime.isClosed(), workingTime.getOpenTime(), workingTime.getCloseTime()); + return Flux.fromIterable(places) + .flatMap(place -> { + List placeWorkingTime = place.getWorkingTimes(); + if (placeWorkingTime.isEmpty()) { + return imageService.getImageURL(place) + .map(imageURL -> toPlaceRecommendationDTO( + place, lat, lng, + true, + null, + null, + imageURL + )); + } + else { + WorkingTime workingTime = placeWorkingTime.get(day%7); + + // 2. imageService.getImageURL(place)가 Mono을 반환한다고 가정 + return imageService.getImageURL(place) + .map(imageURL -> toPlaceRecommendationDTO( + place, lat, lng, + workingTime.isClosed(), + workingTime.getOpenTime(), + workingTime.getCloseTime(), + imageURL + )); + } }) - .toList(); - PlaceRecommendationDTO featured = placeInfos.isEmpty() ? null : placeInfos.get(0); - return new CategoryRecommendationDTO(categoryId, category.getName(), placeInfos, featured); + .collectList() // 3. 비동기로 생성된 DTO들을 다시 List로 모음 + .map(placeInfos -> { + // 4. 리스트가 완성되면 최종 CategoryRecommendationDTO 생성 + PlaceRecommendationDTO featured = placeInfos.isEmpty() ? null : placeInfos.get(0); + return new CategoryRecommendationDTO(categoryId, category.getName(), placeInfos, featured); + }); +// List placeInfos = places.stream() +// .map(place -> { +// List placeWorkingTime = place.getWorkingTimes(); +// WorkingTime workingTime = placeWorkingTime.get(day); +// String imageURL = imageService.getImageURL(place); +// return toPlaceRecommendationDTO(place, lat, lng, workingTime.isClosed(), workingTime.getOpenTime(), workingTime.getCloseTime(), imageURL); +// }) +// .toList(); +// PlaceRecommendationDTO featured = placeInfos.isEmpty() ? null : placeInfos.get(0); +// return new CategoryRecommendationDTO(categoryId, category.getName(), placeInfos, featured); } private List getPlacesInRadius(double lat, double lng, Long categoryId, int limit) { @@ -240,7 +328,7 @@ private List getPlacesInRadius(double lat, double lng, Long categoryId, i ); } - private PlaceRecommendationDTO toPlaceRecommendationDTO(Place place, double userLat, double userLng, boolean isClosed, LocalTime open, LocalTime close) { + private PlaceRecommendationDTO toPlaceRecommendationDTO(Place place, double userLat, double userLng, boolean isClosed, LocalTime open, LocalTime close, String imageURL) { double distance = geoDistanceService.distanceMeters(userLat, userLng, place.getLat(), place.getLng()); int walkingMinutes = geoDistanceService.estimateWalkingMinutes(distance); return new PlaceRecommendationDTO( @@ -251,12 +339,15 @@ private PlaceRecommendationDTO toPlaceRecommendationDTO(Place place, double user walkingMinutes, open, close, - isOpen(isClosed, open, close) + isOpen(isClosed, open, close), + imageURL ); } private boolean isOpen(boolean isClosed, LocalTime open, LocalTime close) { - LocalTime now = LocalTime.now(); + ZoneId koreaZone = ZoneId.of("Asia/Seoul"); + LocalTime now = LocalTime.now(koreaZone); + if (isClosed) { return false; } else if (open == null || close == null) { diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index 98c03a3..8816e59 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -1,5 +1,6 @@ # Dev ?? ?? (DB ??? ?? ?) spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true # ??? ?? ?? ?? ?? ?? logging.level.org.hibernate.SQL=INFO \ No newline at end of file diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 16daaf3..8c0b4b8 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,9 +1,9 @@ spring.application.name=onmyway -spring.config.import=optional:file:../.env[.properties] +spring.config.import=optional:file:./backend/.env.dev[.properties] # ?? ??? ?? (?? ?? ??? dev? ????) -spring.profiles.active=dev +# spring.profiles.active=dev # Kakao Provider ?? spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize @@ -14,20 +14,20 @@ spring.security.oauth2.client.provider.kakao.user-name-attribute=id # Kakao Registration spring.security.oauth2.client.registration.kakao.client-id=${KAKAO_CLIENT_ID} spring.security.oauth2.client.registration.kakao.client-secret=${KAKAO_CLIENT_SECRET} -spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao +spring.security.oauth2.client.registration.kakao.redirect-uri=${KAKAO_REDIRECT} spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code spring.security.oauth2.client.registration.kakao.scope=profile_nickname, profile_image, account_email spring.security.oauth2.client.registration.kakao.client-name=kakao spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post # DB ?? -spring.datasource.url=jdbc:postgresql://localhost:5432/onmyway +spring.datasource.url=${POSTGRESQL_URL} spring.datasource.username=onmyway_user spring.datasource.password=${POSTGRESQL_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver # JPA ?? -spring.jpa.hibernate.ddl-auto=update +spring.jpa.hibernate.ddl-auto=validate spring.jpa.database=postgresql spring.jpa.properties.hibernate.default_schema=public @@ -40,5 +40,12 @@ jwt.secret=${JWT_SECRET} jwt.access-expiration=${ACCESS_TOKEN_EXPIRATION} jwt.refresh-expiration=${REFRESH_TOKEN_EXPIRATION} +# Nginx +server.forward-headers-strategy=framework + #Tmap api -tmap.api.key=${TMAP_API_KEY} \ No newline at end of file +tmap.api.key=${TMAP_API_KEY} + +#Naver api +naver.api.clientId=${NAVER_CLIENT_ID} +naver.api.clientSecret=${NAVER_CLIENT_SECRET} \ No newline at end of file diff --git a/backend/src/test/java/_team/onmyway/OnmywayApplicationTests.java b/backend/src/test/java/_team/onmyway/OnmywayApplicationTests.java index b37fca4..143354f 100644 --- a/backend/src/test/java/_team/onmyway/OnmywayApplicationTests.java +++ b/backend/src/test/java/_team/onmyway/OnmywayApplicationTests.java @@ -5,9 +5,7 @@ @SpringBootTest class OnmywayApplicationTests { - @Test void contextLoads() { } - } diff --git a/frontend/ganeungil/LoginPage.jsx b/frontend/ganeungil/LoginPage.jsx deleted file mode 100644 index aa875a7..0000000 --- a/frontend/ganeungil/LoginPage.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import kakaoLoginImg from "./button/kakaologin-button.png"; - - - -function LoginPage() { - const handleLogin = () => { - window.location.href = "http://localhost:8080/oauth2/authorization/kakao"; - }; - - return ( -
-

카카오 로그인 구현 테스트 페이지

- -
- ); -} - -export default LoginPage; \ No newline at end of file diff --git a/frontend/ganeungil/api/api.js b/frontend/ganeungil/api/api.js index 10ca77a..8865109 100644 --- a/frontend/ganeungil/api/api.js +++ b/frontend/ganeungil/api/api.js @@ -1,7 +1,7 @@ import axios from "axios"; const api = axios.create({ - baseURL: "http://localhost:8080", + baseURL: "/", withCredentials: true, }); diff --git a/frontend/ganeungil/components/Header.jsx b/frontend/ganeungil/components/Header.jsx index f483a30..2ef793d 100644 --- a/frontend/ganeungil/components/Header.jsx +++ b/frontend/ganeungil/components/Header.jsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import iconSearch from "@/assets/Button_dialog.svg"; import iconMenu from "@/assets/header_search.svg"; @@ -11,58 +12,68 @@ const NAV_ITEMS = [ { label: "둘러보기", path: "/explore" }, ]; +const DESIGN_W = 1920; +const DESIGN_H = 1275; + export default function Header() { const navigate = useNavigate(); const { pathname } = useLocation(); + const [scale, setScale] = useState( + () => Math.min(window.innerWidth / DESIGN_W, window.innerHeight / DESIGN_H) + ); + + useEffect(() => { + const update = () => + setScale(Math.min(window.innerWidth / DESIGN_W, window.innerHeight / DESIGN_H)); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, []); + return ( -
- {/* 상단 바: 로그인 | 회원가입 */} -
-
- -
- -
-
+ /* + 헤더 배경은 항상 화면 전체 너비를 채움 (w-full) + 높이만 scale에 따라 줄어듦 + */ +
+ {/* + 내부 콘텐츠: + - transform: scale로 비율 유지 + - width: (1/scale)*100% → scale로 줄어든 너비를 보정해 화면 가득 채움 + - transformOrigin: top left → 왼쪽 상단 기준으로 축소 + */} +
- {/* 메인 헤더 행 */} -
{/* 로고 */} {/* 내비게이션 */} -
); diff --git a/frontend/ganeungil/hooks/useRoute.js b/frontend/ganeungil/hooks/useRoute.js new file mode 100644 index 0000000..ae35f12 --- /dev/null +++ b/frontend/ganeungil/hooks/useRoute.js @@ -0,0 +1,76 @@ +import { useRef } from "react"; +import iconDestPin from "@/assets/icon-destination-pin.svg"; +import IconArrive from "@/assets/iconArrive.svg"; + +export function useRoute(kakaoMapRef) { + const destMarkerRef = useRef(null); + const polylineRef = useRef(null); + const routeMarkersRef = useRef([]); + + const clearDestMarker = () => { + if (destMarkerRef.current) { + destMarkerRef.current.setMap(null); + destMarkerRef.current = null; + } + }; + + const handleDestinationSelect = (place) => { + const map = kakaoMapRef.current; + if (!map) return; + const pos = new window.kakao.maps.LatLng(parseFloat(place.y), parseFloat(place.x)); + map.setCenter(pos); + map.setLevel(3); + clearDestMarker(); + const markerImage = new window.kakao.maps.MarkerImage( + IconArrive, + new window.kakao.maps.Size(40, 40), //피그마에서는 34/34d인데 지도에서 너무 작게 보여서 40/40으로 조정(임의) + { offset: new window.kakao.maps.Point(20, 40) } + ); + destMarkerRef.current = new window.kakao.maps.Marker({ + position: pos, + image: markerImage, + map, + }); + }; + + const displayRoute = (features) => { + const map = kakaoMapRef.current; + if (!map || !features) return; + + if (polylineRef.current) polylineRef.current.setMap(null); + routeMarkersRef.current.forEach(m => m.setMap(null)); + routeMarkersRef.current = []; + + const path = []; + + features.forEach((feature) => { + if (feature.geometry.type === "LineString") { + feature.geometry.coordinates.forEach(coord => { + path.push(new window.kakao.maps.LatLng(coord[1], coord[0])); + }); + } + }); + + const polyline = new window.kakao.maps.Polyline({ + path, + strokeWeight: 5, + strokeColor: "#7BC4A0", + strokeOpacity: 0.8, + }); + polyline.setMap(map); + polylineRef.current = polyline; + routeMarkersRef.current = []; + + const bounds = new window.kakao.maps.LatLngBounds(); + path.forEach(pos => bounds.extend(pos)); + map.setBounds(bounds); + }; + + const clearRoute = () => { + if (polylineRef.current) { polylineRef.current.setMap(null); polylineRef.current = null; } + routeMarkersRef.current.forEach(m => m.setMap(null)); + routeMarkersRef.current = []; + }; + + return { handleDestinationSelect, clearDestMarker, clearRoute, displayRoute }; +} diff --git a/frontend/ganeungil/hooks/useRouteSearch.js b/frontend/ganeungil/hooks/useRouteSearch.js new file mode 100644 index 0000000..f04938e --- /dev/null +++ b/frontend/ganeungil/hooks/useRouteSearch.js @@ -0,0 +1,193 @@ +import { useState, useRef } from "react"; +import api from "../api/api"; + +// 점 → 선분(segment) 최단거리 (미터). 좌표계: equirectangular 근사 +function distPointToSegment(pLat, pLng, aLat, aLng, bLat, bLng) { + const R = 6371000; + const cosLat = Math.cos(pLat * Math.PI / 180); + const toM = (dLat, dLng) => [dLat * R * Math.PI / 180, dLng * cosLat * R * Math.PI / 180]; + const [py, px] = toM(pLat, pLng); + const [ay, ax] = toM(aLat, aLng); + const [by, bx] = toM(bLat, bLng); + const dx = bx - ax, dy = by - ay; + const lenSq = dx * dx + dy * dy; + const t = lenSq === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq)); + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); +} + +function filterPlacesNearRoute(places, routeCoords, threshold = 50) { + return places.filter(p => { + if (!p.lat || !p.lng) return false; + for (let i = 0; i < routeCoords.length - 1; i++) { + const [aLng, aLat] = routeCoords[i]; + const [bLng, bLat] = routeCoords[i + 1]; + if (distPointToSegment(p.lat, p.lng, aLat, aLng, bLat, bLng) <= threshold) return true; + } + return false; + }); +} + +// 경로 좌표에서 균등 간격으로 최대 maxN개 샘플 추출 +function sampleRouteCoords(coords, maxN) { + if (coords.length <= maxN) return coords; + const step = (coords.length - 1) / (maxN - 1); + return Array.from({ length: maxN }, (_, i) => coords[Math.round(i * step)]); +} + +export const ROUTE_MODES = [ + { id: "right", label: "바른 길", endpoint: "/route/right" }, + { id: "slow", label: "여유로운 길", endpoint: "/route/slow" }, + { id: "findOut", label: "발견하는 길", endpoint: "/route/findOut" }, +]; + +export function useRouteSearch({ userCoords, onDestinationSelect, onDrawRoute, onRouteRecs, onRecsHide, onRecsShow, onDestinationClear }) { + const [destText, setDestText] = useState(""); + const [destFocused, setDestFocused] = useState(false); + const [deptText, setDeptText] = useState(""); + const [deptFocused, setDeptFocused] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [selectedResult, setSelectedResult] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [selectedMode, setSelectedMode] = useState(null); + const [routeInfo, setRouteInfo] = useState({}); + + const destInputRef = useRef(null); + const deptInputRef = useRef(null); + + const isSearchMode = destFocused || deptFocused; + const showResults = searchResults.length > 0; + + const handleDestFocus = () => { setDestFocused(true); onRecsHide?.(); }; + const handleDeptFocus = () => { setDeptFocused(true); onRecsHide?.(); }; + + const handleCancel = () => { + setDestText(""); setDeptText(""); + setDestFocused(false); setDeptFocused(false); + setSearchResults([]); setSelectedResult(null); + onRecsShow?.(); onDestinationClear?.(); + }; + + const handleDestSubmit = (e) => { + e?.preventDefault(); + if (!destText.trim() || !window.kakao?.maps?.services) return; + setIsSearching(true); + const ps = new window.kakao.maps.services.Places(); + ps.keywordSearch(destText, (results, status) => { + setIsSearching(false); + if (status === window.kakao.maps.services.Status.OK) setSearchResults(results.slice(0, 5)); + else setSearchResults([]); + }); + }; + + const handleDeptSubmit = (e) => { e?.preventDefault(); }; + + const fetchRouteData = async (modeId, result, { draw = true } = {}) => { + if (!userCoords || !result) { + console.warn("[경로] 중단 - userCoords:", userCoords, "result:", result); + return; + } + const mode = ROUTE_MODES.find(m => m.id === modeId); + console.log(`[경로] ${mode.label} 요청`, { from: userCoords, to: { y: result.y, x: result.x } }); + try { + const response = await api.post(mode.endpoint, [ + { lat: userCoords.lat, lon: userCoords.lng }, + { lat: parseFloat(result.y), lon: parseFloat(result.x) }, + ]); + console.log("[경로] 응답:", response.data); + const features = response.data.route?.features; + console.log("[경로] features:", features?.length, "개"); + if (features && Array.isArray(features)) { + if (draw) onDrawRoute?.(features); + const summary = features[0]?.properties; + if (summary?.totalTime != null) { + setRouteInfo(prev => ({ + ...prev, + [modeId]: { + time: Math.round(summary.totalTime / 60), + distance: (summary.totalDistance / 1000).toFixed(1), + }, + })); + } + const cats = response.data.recommendations?.categories ?? []; + if (modeId === "findOut") { + const routeCoords = features + .filter(f => f.geometry.type === "LineString") + .flatMap(f => f.geometry.coordinates); // [lng, lat] + + // 경로를 따라 최대 6개 지점 샘플 → 각 지점마다 /places/recommend 호출 + const samples = sampleRouteCoords(routeCoords, 6); + const results = await Promise.all( + samples.map(([lng, lat]) => + api.get("/places/recommend", { params: { lat, lng } }) + .then(r => r.data.categories ?? []) + .catch(() => []) + ) + ); + + // 전체 장소 합치기 + lat/lng 기준 중복 제거 + const seen = new Set(); + const allPlaces = results + .flat() + .flatMap(cat => cat.places ?? []) + .filter(p => { + if (!p.lat || !p.lng) return false; + const key = `${p.lat},${p.lng}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + const nearby = filterPlacesNearRoute(allPlaces, routeCoords, 50); + + // 카테고리별 1개씩만 + const seenCat = new Set(); + const onePerCategory = nearby.filter(p => { + if (seenCat.has(p.category)) return false; + seenCat.add(p.category); + return true; + }); + + console.log("[발견하는 길] 수집 장소:", allPlaces.length, "→ 50m 이내:", nearby.length, "→ 카테고리별 1개:", onePerCategory.length); + if (draw) onRouteRecs?.(onePerCategory); + } else { + if (draw) onRouteRecs?.([]); + } + } else { + console.warn("[경로] features 없음. 응답 구조:", JSON.stringify(response.data).slice(0, 200)); + } + } catch (error) { + console.error("[경로] API 에러:", error.response?.status, error.response?.data ?? error.message); + } + }; + + const handleResultClick = (result) => { + setSelectedResult(result); + setSearchResults([]); + setDestText(result.place_name); + onDestinationSelect?.(result); + }; + + const handleModeChange = (modeId) => { + setSelectedMode(modeId); + }; + + const handleSearch = () => { + if (!userCoords) { alert("현재 위치를 먼저 잡아주세요."); return; } + if (!selectedResult) return; + const activeMode = selectedMode ?? "slow"; + ROUTE_MODES.forEach(mode => { + fetchRouteData(mode.id, selectedResult, { draw: mode.id === activeMode }); + }); + }; + + return { + destText, setDestText, destFocused, + deptText, setDeptText, deptFocused, + searchResults, selectedResult, isSearching, selectedMode, routeInfo, handleSearch, + destInputRef, deptInputRef, + isSearchMode, showResults, + handleDestFocus, handleDeptFocus, handleCancel, + handleDestSubmit, handleDeptSubmit, + handleResultClick, handleModeChange, + }; +} diff --git a/frontend/ganeungil/index.css b/frontend/ganeungil/index.css index b88c5cf..7ba1ef7 100644 --- a/frontend/ganeungil/index.css +++ b/frontend/ganeungil/index.css @@ -2,6 +2,17 @@ @tailwind components; @tailwind utilities; +@font-face { font-family: 'Pretendard'; src: url('@/assets/fonts/Pretendard-Regular.woff2') format('woff2'); font-display: swap; } +@font-face { font-family: 'MaruBuriOTF'; src: url('@/assets/fonts/MaruBuri-Regular.otf') format('opentype'); font-display: swap; } +@font-face { font-family: 'Pretendard-Thin'; src: url('@/assets/fonts/Pretendard-Thin.woff2') format('woff2'); font-display: swap; } +@font-face { font-family: 'Pretendard-ExtraLight'; src: url('@/assets/fonts/Pretendard-ExtraLight.woff2') format('woff2'); font-display: swap; } +@font-face { font-family: 'Pretendard-Light'; src: url('@/assets/fonts/Pretendard-Light.woff2') format('woff2'); font-display: swap; } +@font-face { font-family: 'Pretendard-Medium'; src: url('@/assets/fonts/Pretendard-Medium.woff2') format('woff2'); font-display: swap; } +@font-face { font-family: 'Pretendard-SemiBold'; src: url('@/assets/fonts/Pretendard-SemiBold.woff2') format('woff2'); font-display: swap; } +@font-face { font-family: 'Pretendard-Bold'; src: url('@/assets/fonts/Pretendard-Bold.woff2') format('woff2'); font-display: swap; } +@font-face { font-family: 'Pretendard-ExtraBold'; src: url('@/assets/fonts/Pretendard-ExtraBold.woff2') format('woff2'); font-display: swap; } +@font-face { font-family: 'Pretendard-Black'; src: url('@/assets/fonts/Pretendard-Black.woff2') format('woff2'); font-display: swap; } + body { margin: 0; font-family: 'Noto Serif KR', serif; diff --git a/frontend/ganeungil/index.html b/frontend/ganeungil/index.html index fe90917..a53dd8f 100644 --- a/frontend/ganeungil/index.html +++ b/frontend/ganeungil/index.html @@ -14,7 +14,7 @@ diff --git a/frontend/ganeungil/pages/find-route/Onboard_2.0.jsx b/frontend/ganeungil/pages/find-route/Onboard_2.0.jsx index 85915c7..30f6c10 100644 --- a/frontend/ganeungil/pages/find-route/Onboard_2.0.jsx +++ b/frontend/ganeungil/pages/find-route/Onboard_2.0.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import api from "../../api/api"; import Sidebar20 from "./Sidebar_2.0"; +import { useRoute } from "../../hooks/useRoute"; // ── 에셋 (헤더 + 장소 상세 카드용) ── import imgPlace from "@/assets/img-place.jpg"; @@ -11,7 +12,23 @@ import iconRest from "@/assets/icon-rest.svg"; import iconShop from "@/assets/icon-shop.svg"; import iconView from "@/assets/icon-view.svg"; import iconHeart from "@/assets/icon-heart.svg"; -import iconDestPin from "@/assets/icon-destination-pin.svg"; + +import markerSip from "@/assets/map/iconsip.svg?url"; +import markerBite from "@/assets/map/iconbite.svg?url"; +import markerFight from "@/assets/map/iconfight.svg?url"; +import markerMeal from "@/assets/map/iconmeal.svg?url"; +import markerSee from "@/assets/map/iconsee.svg?url"; +import markerHansoom from "@/assets/map/icon_한숨.svg?url"; +import iconArrive2 from "@/assets/iconArrive2.svg?url"; + +const MARKER_ICON = { + "한잔": markerSip, + "한입": markerBite, + "한판": markerFight, + "한끼": markerMeal, + "한눈": markerSee, + "한숨": markerHansoom, +}; const CATEGORY_LABEL_TO_ID = { "한 잔": 1, @@ -51,6 +68,7 @@ function toPlaceList(raw, startId = 0) { isOpen: p.isOpen ?? p.open ?? true, closeTime: p.closeTime ?? null, openTime: p.openTime ?? null, + imageURL: p.imageURL ?? null, desc: "", tags: [], })); @@ -103,6 +121,9 @@ export default function Onboard20() { const [isOffline, setIsOffline] = useState(!navigator.onLine); const [sidebarOpen, setSidebarOpen] = useState(true); const [mapReady, setMapReady] = useState(false); + const [scale, setScale] = useState( + () => Math.min(window.innerWidth / 1920, window.innerHeight / 1275) + ); // 카카오맵 관련 refs const mapContainerRef = useRef(null); //
DOM 노드 @@ -110,9 +131,52 @@ export default function Onboard20() { const circleRef = useRef(null); // 500m 반경 Circle const userDotRef = useRef(null); // 내 위치 CustomOverlay const overlaysRef = useRef([]); // 추천 마커 CustomOverlay[] + const routeRecsOverlaysRef = useRef([]); // 경로 추천 마커 const recsRef = useRef([]); // recs 최신값 (window 콜백에서 참조) const featuredRef = useRef([]); // featuredRecs 최신값 (마커 클릭 콜백에서 참조) - const destMarkerRef = useRef(null); // 목적지 마커 CustomOverlay + + const { handleDestinationSelect, clearDestMarker, clearRoute, displayRoute } = useRoute(kakaoMapRef); + + const handleDestSelect = (place) => { + handleDestinationSelect(place); + circleRef.current?.setMap(null); + }; + + const handleDestClear = () => { + clearDestMarker(); + clearRoute(); + routeRecsOverlaysRef.current.forEach(o => o.setMap(null)); + routeRecsOverlaysRef.current = []; + if (circleRef.current && kakaoMapRef.current) circleRef.current.setMap(kakaoMapRef.current); + }; + + const handleRouteRecs = (places) => { + const map = kakaoMapRef.current; + if (!map) return; + routeRecsOverlaysRef.current.forEach(o => o.setMap(null)); + routeRecsOverlaysRef.current = []; + places.filter(p => p.lat && p.lng).forEach(place => { + const safeUrl = iconArrive2.startsWith('data:image/svg+xml') + ? iconArrive2.replace(/#/g, '%23') + : iconArrive2; + const container = document.createElement('div'); + container.style.width = '40px'; + container.style.height = '40px'; + container.style.backgroundImage = `url("${safeUrl}")`; + container.style.backgroundSize = 'contain'; + container.style.backgroundRepeat = 'no-repeat'; + container.style.backgroundPosition = 'center'; + container.style.cursor = 'pointer'; + const overlay = new window.kakao.maps.CustomOverlay({ + position: new window.kakao.maps.LatLng(place.lat, place.lng), + content: container, + map, + yAnchor: 1, + zIndex: 3, + }); + routeRecsOverlaysRef.current.push(overlay); + }); + }; // ref 동기화 useEffect(() => { recsRef.current = recs; }, [recs]); @@ -157,6 +221,14 @@ setAllCategories(data.categories); return () => { window.removeEventListener("online", on); window.removeEventListener("offline", off); }; }, []); + // ── 화면 크기 변경 시 scale 갱신 + useEffect(() => { + const update = () => + setScale(Math.min(window.innerWidth / 1920, window.innerHeight / 1275)); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, []); + // ── 카카오맵 초기화 (SDK 로드 대기 후 실행) useEffect(() => { const init = () => { @@ -171,9 +243,6 @@ setAllCategories(data.categories); center, level: 4, }); - window.kakao.maps.event.addListener(kakaoMapRef.current, "click", () => { - setSidebarOpen(false); - }); setMapReady(true); console.log("카카오맵 초기화 성공"); } catch (e) { @@ -205,18 +274,16 @@ setAllCategories(data.categories); circleRef.current = new window.kakao.maps.Circle({ center: pos, radius: 250, - strokeWeight: 2, - strokeColor: "#c8873a", - strokeOpacity: 0.5, - fillColor: "#c8873a", - fillOpacity: 0.08, + strokeWeight: 7, + strokeColor: "#FFEDA1", + shadow: "0px 25.5px 63.751px 0px rgba(0,0,0,0.30)", map, }); // 내 위치 점 userDotRef.current = new window.kakao.maps.CustomOverlay({ position: pos, - content: `
`, + content: `
`, map, yAnchor: 0.5, xAnchor: 0.5, @@ -242,7 +309,9 @@ setAllCategories(data.categories); ]; const placed = []; - featuredRecs.forEach(place => { + console.log("[마커] featuredRecs:", featuredRecs.map(p => ({ id: p.id, name: p.name, lat: p.lat, lng: p.lng, category: p.category }))); + + featuredRecs.filter(p => p.lat && p.lng).forEach(place => { let lat = place.lat; let lng = place.lng; let offsetIdx = 0; @@ -255,25 +324,34 @@ setAllCategories(data.categories); lng = place.lng + spiralOffsets[offsetIdx][1]; } placed.push({ lat, lng }); - - const content = ` -
- `; + console.log(`[마커 생성 중] ID: ${place.id}, 최종 좌표: ${lat}, ${lng}`); + + const iconUrl = MARKER_ICON[place.category] || markerSip; + const safeIconUrl = iconUrl.startsWith('data:image/svg+xml') + ? iconUrl.replace(/#/g, '%23') + : iconUrl; + const container = document.createElement('div'); + container.style.width = '40px'; + container.style.height = '40px'; + container.style.backgroundImage = `url("${safeIconUrl}")`; + container.style.backgroundSize = 'contain'; + container.style.backgroundRepeat = 'no-repeat'; + container.style.backgroundPosition = 'center'; + container.style.cursor = 'pointer'; + container.style.transition = 'transform 0.15s'; + container.onmouseover = () => { container.style.transform = 'scale(1.2)'; }; + container.onmouseout = () => { container.style.transform = 'scale(1)'; }; + container.onclick = () => { + if (window.__onMarkerClick) { + window.__onMarkerClick(place.id); + } + }; const overlay = new window.kakao.maps.CustomOverlay({ position: new window.kakao.maps.LatLng(lat, lng), - content, + content: container, map, yAnchor: 1, + zIndex: 3, }); overlaysRef.current.push(overlay); }); @@ -298,44 +376,6 @@ setAllCategories(data.categories); setSelectedPlace(null); }; - // 목적지 마커 제거 - const clearDestMarker = () => { - if (destMarkerRef.current) { - destMarkerRef.current.setMap(null); - destMarkerRef.current = null; - } - }; - - // 카카오 장소 검색 결과 선택 → 지도 이동 + 마커 표시 - const handleDestinationSelect = (place) => { - const map = kakaoMapRef.current; - if (!map) return; - const pos = new window.kakao.maps.LatLng(parseFloat(place.y), parseFloat(place.x)); - map.setCenter(pos); - map.setLevel(3); - - clearDestMarker(); - - destMarkerRef.current = new window.kakao.maps.CustomOverlay({ - position: pos, - content: ` -
- -
- `, - map, - yAnchor: 1, - xAnchor: 0, - }); - }; - // 현재 위치 보정 const handleRecalibrate = () => { if (!navigator.geolocation) return; @@ -365,8 +405,8 @@ setAllCategories(data.categories); return (
{/* ── 메인 ── */} -
+
{/* ── 카카오맵 컨테이너 (항상 렌더링 → 초기화 가능) ── */}
@@ -420,21 +460,28 @@ setAllCategories(data.categories); onRecalibrate={handleRecalibrate} onRecsHide={handleRecsHide} onRecsShow={handleRecsShow} - onDestinationSelect={handleDestinationSelect} - onDestinationClear={clearDestMarker} + onDestinationSelect={handleDestSelect} + onDestinationClear={handleDestClear} + userCoords={userCoords} + onDrawRoute={displayRoute} + onRouteRecs={handleRouteRecs} /> - {/* ── 장소 상세 카드 ── */} + {/* ── 장소 상세 카드 ──지도 클릭 시 사라짐 + 사이드바에서 장소 선택 시 나타남 */} {selectedPlace && ( -
+
{selectedPlace.name} - + {selectedPlace.category} @@ -474,13 +521,7 @@ setAllCategories(data.categories);
)} - {/* ── 인터넷 불안정 오류 ── */} - {isOffline && ( -
- - 인터넷 연결이 불안정합니다 -
- )} +
); diff --git a/frontend/ganeungil/pages/find-route/RouteInputSection.jsx b/frontend/ganeungil/pages/find-route/RouteInputSection.jsx new file mode 100644 index 0000000..30acfc6 --- /dev/null +++ b/frontend/ganeungil/pages/find-route/RouteInputSection.jsx @@ -0,0 +1,98 @@ +import iconSearchNew from "@/assets/icon-search-new.svg"; +import iconex from "@/assets/icon-ex.svg"; + +export default function RouteInputSection({ + locStatus, + destText, setDestText, destFocused, + deptText, setDeptText, deptFocused, + destInputRef, deptInputRef, + handleDestFocus, handleDeptFocus, handleCancel, + handleDestSubmit, handleDeptSubmit, +}) { + const granted = locStatus === "granted"; + + return ( +
+ + {/* 인디케이터 */} +
+
+
+
+
+ + {/* 입력 박스 */} +
+
+ + {/* 출발지 */} +
+
+
+
+ {granted && !deptFocused ? ( + + ) : ( + setDeptText(e.target.value)} + onFocus={handleDeptFocus} + placeholder={granted ? "현재 위치" : locStatus === "pending" ? "위치 확인 중…" : "출발지를 입력하세요"} + className="w-full text-[20px] tracking-[-0.54px] font-normal bg-transparent outline-none leading-tight placeholder:text-[#afafaf] text-[#3e2722] [font-family:'Pretendard']" + autoFocus={deptFocused} + /> + )} +
+ {deptFocused && deptText && ( + + )} +
+
+
+ + {/* 목적지 */} +
+
+
+
+ setDestText(e.target.value)} + onFocus={handleDestFocus} + placeholder="어디로 가시나요?" + className="w-full text-[20px] tracking-[-0.54px] font-normal bg-transparent outline-none leading-tight placeholder:text-[#afafaf] text-[#3e2722] [font-family:'Pretendard']" + autoFocus={destFocused} + /> +
+ {!destFocused && } + {destFocused && ( + + )} + {destText && ( + + )} +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/ganeungil/pages/find-route/Sidebar_2.0.jsx b/frontend/ganeungil/pages/find-route/Sidebar_2.0.jsx index a7d5c04..5987dbe 100644 --- a/frontend/ganeungil/pages/find-route/Sidebar_2.0.jsx +++ b/frontend/ganeungil/pages/find-route/Sidebar_2.0.jsx @@ -1,64 +1,70 @@ -import { useState, useRef, useEffect } from "react"; - -import iconGPS from "@/assets/icon-gps.svg"; -import iconCurrentLocation from "@/assets/icon-current-location.svg"; -import iconArrow from "@/assets/icon-arrow.svg"; -import iconSearch from "@/assets/icon-search.svg"; -import iconSearchNew from "@/assets/icon-search-new.svg"; -import iconAll from "@/assets/icon-all.svg"; -import iconDrink from "@/assets/icon-drink.svg"; -import iconFood from "@/assets/icon-food.svg"; -import iconRest from "@/assets/icon-rest.svg"; -import iconShop from "@/assets/icon-shop.svg"; -import iconView from "@/assets/icon-view.svg"; -import iconAllActive from "@/assets/icon-all-active.svg"; -import iconDrinkActive from "@/assets/icon-drink-active.svg"; -import iconFoodActive from "@/assets/icon-food-active.svg"; -import iconRestActive from "@/assets/icon-rest-active.svg"; -import iconShopActive from "@/assets/icon-shop-active.svg"; -import iconViewActive from "@/assets/icon-view-active.svg"; -import iconHeart from "@/assets/icon-heart.svg"; -import imgPlace from "@/assets/img-place.jpg"; +import { useEffect, useState } from "react"; +import RouteInputSection from "./RouteInputSection"; +import { useRouteSearch, ROUTE_MODES } from "../../hooks/useRouteSearch"; + +import iconGPS from "@/assets/icon-gps.svg"; +import iconLocation from "@/assets/iconlocation.svg"; +import iconArrow from "@/assets/icon-arrow.svg"; +import iconUnion from "@/assets/iconUnion.svg"; +import iconLeisure from "@/assets/icon-leisure.svg"; +import iconFind from "@/assets/icon-find.svg"; +import iconClock from "@/assets/icon-route.svg"; +import iconAll from "@/assets/all.svg"; +import iconWhiteAll from "@/assets/whiteall.svg"; +import iconSip from "@/assets/sip.svg"; +import iconOrgSip from "@/assets/org_sip.svg"; +import iconBite from "@/assets/bite.svg"; +import iconOrgBite from "@/assets/org_bite.svg"; +import iconFight from "@/assets/fight.svg"; +import iconOrgFight from "@/assets/org_fight.svg"; +import iconSee from "@/assets/see.svg"; +import iconOrgSee from "@/assets/org_see.svg"; +import iconMeal from "@/assets/meal.svg"; +import iconOrgMeal from "@/assets/org_meal.svg"; +import iconHeart from "@/assets/icon-heart.svg"; +import iconQuiet from "@/assets/iconquiet.svg"; +import iconRoasting from "@/assets/iconroasting.svg"; +import imgPlace from "@/assets/img-place.jpg"; + +// 피그마 기준 프레임 크기 +const DESIGN_W = 1920; +const DESIGN_H = 1275; + +const MODE_CONFIG = { + right: { icon: iconUnion, iconBg: "#FAE3CE", cardClass: "bg-[#fdfdfd] hover:bg-[#fffbec]" }, + slow: { icon: iconLeisure, iconBg: "#E0E4D8", cardClass: "bg-[#fdfdfd] hover:bg-[#fffbec]" }, + findOut: { icon: iconUnion, iconBg: "#FDFAEB", cardClass: "bg-[#fdfdfd] hover:bg-[#fffbec]" }, +}; const CATEGORIES = [ - { label: "전체", icon: iconAll, iconActive: iconAllActive }, - { label: "한 잔", icon: iconDrink, iconActive: iconDrinkActive }, - { label: "한 입", icon: iconFood, iconActive: iconFoodActive }, - { label: "한 숨", icon: iconRest, iconActive: iconRestActive }, - { label: "한 판", icon: iconShop, iconActive: iconShopActive }, - { label: "한 눈", icon: iconView, iconActive: iconViewActive }, - { label: "한 끼", icon: iconFood, iconActive: iconFoodActive }, + { label: "전체", icon: iconWhiteAll, iconActive: iconAll }, + { label: "한 잔", icon: iconSip, iconActive: iconOrgSip }, + { label: "한 입", icon: iconBite, iconActive: iconOrgBite }, + { label: "한 끼", icon: iconMeal, iconActive: iconOrgMeal }, + { label: "한 눈", icon: iconSee, iconActive: iconOrgSee }, + { label: "한 판", icon: iconFight, iconActive: iconOrgFight }, ]; +const CATEGORY_ICON_MAP = { + "한잔": iconOrgSip, + "한입": iconOrgBite, + "한숨": iconAll, + "한판": iconOrgFight, + "한눈": iconOrgSee, + "한끼": iconOrgMeal, +}; + const fmt = (t) => t?.slice(0, 5) ?? null; function HoursLabel({ place }) { if (place.isOpen) { - if (!place.closeTime) return 영업 중; - return 영업 중 ({fmt(place.closeTime)}에 종료); + if (!place.closeTime) return 영업 중; + return 영업 중 ({fmt(place.closeTime)}에 종료); } - if (!place.openTime) return 영업 종료; - return 영업 종료 ({fmt(place.openTime)}에 시작); + if (!place.openTime) return 영업 종료; + return 영업 종료 ({fmt(place.openTime)}에 시작); } -/** - * Props - * ───── - * locStatus : "pending" | "granted" | "denied" - * recs : 추천 장소 배열 - * activeCategory : 현재 선택된 카테고리 - * recsState : "visible" | "fading" | "hidden" - * selectedPlace : 현재 선택된 장소 객체 | null - * sidebarOpen : boolean - * - * onCategoryChange(label) : 카테고리 변경 - * onPlaceSelect(place|null) : 장소 선택/해제 - * onSidebarToggle() : 사이드바 열기/닫기 - * onRecalibrate() : 위치 보정 - * onRecsHide() : 추천 목록 숨기기 (검색 포커스 시) - * onRecsShow() : 추천 목록 다시 보이기 (검색 취소 시) - * onSearchSubmit(query) : 검색어 제출 → TODO: 백엔드 API 연결 - */ export default function Sidebar20({ locStatus, recs, @@ -74,392 +80,295 @@ export default function Sidebar20({ onRecsShow, onDestinationSelect, onDestinationClear, + userCoords, + onDrawRoute, + onRouteRecs, }) { - const [destText, setDestText] = useState(""); - const [destFocused, setDestFocused] = useState(false); - const [deptText, setDeptText] = useState(""); - const [deptFocused, setDeptFocused] = useState(false); - const [searchResults, setSearchResults] = useState([]); - const [selectedResult, setSelectedResult] = useState(null); - const [isSearching, setIsSearching] = useState(false); + const [scale, setScale] = useState( + () => Math.min(window.innerWidth / DESIGN_W, window.innerHeight / DESIGN_H) + ); + + useEffect(() => { + const update = () => + setScale(Math.min(window.innerWidth / DESIGN_W, window.innerHeight / DESIGN_H)); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, []); - const destInputRef = useRef(null); - const deptInputRef = useRef(null); + const routeSearch = useRouteSearch({ + userCoords, + onDestinationSelect, + onDrawRoute, + onRouteRecs, + onRecsHide, + onRecsShow, + onDestinationClear, + }); + + const { + showResults, isSearchMode, selectedMode, + searchResults, selectedResult, isSearching, routeInfo, handleSearch, + handleResultClick, handleModeChange, + } = routeSearch; const granted = locStatus === "granted"; const showRecs = granted && recsState !== "hidden"; const overlayFading = recsState === "fading"; - const showResults = searchResults.length > 0; - - const handleDestFocus = () => { - setDestFocused(true); - onRecsHide(); - }; - - const handleDeptFocus = () => { - setDeptFocused(true); - onRecsHide(); - }; - - const handleCancel = () => { - setDestText(""); - setDeptText(""); - setDestFocused(false); - setDeptFocused(false); - setSearchResults([]); - setSelectedResult(null); - onRecsShow(); - onDestinationClear?.(); - }; - - // 카카오 장소 검색 - const handleDestSubmit = (e) => { - e?.preventDefault(); - if (!destText.trim() || !window.kakao?.maps?.services) return; - setIsSearching(true); - const ps = new window.kakao.maps.services.Places(); - ps.keywordSearch(destText, (results, status) => { - setIsSearching(false); - if (status === window.kakao.maps.services.Status.OK) { - setSearchResults(results.slice(0, 5)); - } else { - setSearchResults([]); - } - }); - }; - - const handleDeptSubmit = (e) => { - e?.preventDefault(); - }; - - // 검색 결과 선택 → 지도 이동 - const handleResultClick = (result) => { - setSelectedResult(result); - setDestText(result.place_name); - onDestinationSelect?.(result); - }; - - const isSearchMode = destFocused || deptFocused; return ( <> - {/* ── 위치 보정 버튼 (사이드바 바로 옆) ── */} + {/* ── 위치 보정 버튼 ── */} - {/* ── 사이드바 ── */} - + +
); } diff --git a/frontend/ganeungil/pages/home/Onboard_1.0.jsx b/frontend/ganeungil/pages/home/Onboard_1.0.jsx index faf6412..eebee83 100644 --- a/frontend/ganeungil/pages/home/Onboard_1.0.jsx +++ b/frontend/ganeungil/pages/home/Onboard_1.0.jsx @@ -19,7 +19,7 @@ export default function OnboardNew() { style={{ fontFamily: "'Noto Serif KR', serif" }} > {/* ── 히어로 섹션 ── */} -
+
골목길 배경 -
+
@@ -121,7 +121,7 @@ export default function OnboardNew() {
{/* 큰 카드 (왼쪽) */} -
+
망원동의 숨은 골목 산책 { - window.location.href = "http://localhost:8080/oauth2/authorization/kakao"; + const params = new URLSearchParams(window.location.search); + const redirectUri = params.get('redirect_uri') || '/'; + window.location.href = `/oauth2/authorization/kakao?redirect_uri=${encodeURIComponent(redirectUri)}`; }; - + const handleLogin = (e) => { e.preventDefault(); // TODO: 이메일/비밀번호 로그인 API 연결 @@ -26,11 +28,11 @@ export default function LoginPage() { {/* 로고 */}
- 가는길 + 가는길
{/* 메인 컨테이너 800px */} -
+
{/* 타이틀 */}

setEmail(e.target.value)} placeholder="이메일" - className="w-full bg-[#fffbec] border border-[#d9d9d9] rounded-[20px] px-[20px] py-[9px] text-[10px] text-[#3e2722] placeholder:text-[#afafaf] outline-none focus:border-[#ed7a13] transition-colors tracking-[-0.378px]" + className="w-full bg-[#fffbec] border border-[#d9d9d9] rounded-full px-[min(20px,2.6vw)] py-[min(9px,1.2vw)] text-[10px] text-[#3e2722] placeholder:text-[#afafaf] outline-none focus:border-[#ed7a13] transition-colors tracking-[-0.378px]" style={{ fontFamily: "'Pretendard', sans-serif" }} /> @@ -65,13 +67,13 @@ export default function LoginPage() { value={password} onChange={(e) => setPassword(e.target.value)} placeholder="비밀번호" - className="w-full bg-[#fffbec] border border-[#d9d9d9] rounded-[20px] px-[20px] pr-[48px] py-[9px] text-[10px] text-[#3e2722] placeholder:text-[#afafaf] outline-none focus:border-[#ed7a13] transition-colors tracking-[-0.378px]" + className="w-full bg-[#fffbec] border border-[#d9d9d9] rounded-full px-[min(20px,2.6vw)] pr-[min(48px,6.3vw)] py-[min(9px,1.2vw)] text-[10px] text-[#3e2722] placeholder:text-[#afafaf] outline-none focus:border-[#ed7a13] transition-colors tracking-[-0.378px]" style={{ fontFamily: "'Pretendard', sans-serif" }} />