diff --git a/README.md b/README.md index 83dea10..493b340 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ pnpm dev `.env` 파일 혹은 환경변수에 `NEXT_PUBLIC_DEPLOY_TYPE`을 `dev` | `live`로 설정하여 연결되는 엔드포인트를 변경할 수 있습니다. +```sh +# live 서버로 연결하기 +NEXT_PUBLIC_DEPLOY_TYPE=live pnpm dev +``` + ### 로컬 실행 `pnpm dev`를 통해 로컬에서 실행한 경우, 3066 포트를 사용합니다. [http://localhost:3066](http://localhost:3066) diff --git a/app/(public)/public/quest/[id]/page.tsx b/app/(public)/public/quest/[id]/page.tsx index 0a97e02..660491c 100644 --- a/app/(public)/public/quest/[id]/page.tsx +++ b/app/(public)/public/quest/[id]/page.tsx @@ -77,7 +77,19 @@ export default function QuestDetail() { <> {/* public page 이므로 메뉴를 제공하지 않는 커스텀 헤더 사용 */}
+ {quest?.name} +
+ + {buildingCount}개 건물 / {placeCount}개 장소 + + + ) : ( + "" + ) + } hidden={isHeaderHidden} hideMenu > diff --git a/app/components/Map/components/QuestMarker.tsx b/app/components/Map/components/QuestMarker.tsx index a19c385..9f037db 100644 --- a/app/components/Map/components/QuestMarker.tsx +++ b/app/components/Map/components/QuestMarker.tsx @@ -28,20 +28,27 @@ export default function QuestMarker({ building, buildingIndex, questId, markerSt if (!building) return const latlng = new kakao.maps.LatLng(building.location.lat, building.location.lng) - const buildingImage = building.places.every((p) => p.isConquered || p.isClosed || p.isNotAccessible) - ? `/marker_sprite_done.png` - : `/marker_sprite.png` - marker.current = new kakao.maps.Marker({ - map, + const conqueredMarker = new kakao.maps.Marker({ position: latlng, - image: new kakao.maps.MarkerImage(buildingImage, new kakao.maps.Size(24, 36), { + image: new kakao.maps.MarkerImage("/marker_conquered.png", new kakao.maps.Size(32, 32), { + offset: new kakao.maps.Point(8, 32), + }), + }) + const notConqueredMarker = new kakao.maps.Marker({ + position: latlng, + image: new kakao.maps.MarkerImage(`/marker_sprite.png`, new kakao.maps.Size(24, 36), { offset: new kakao.maps.Point(12, 36), spriteOrigin: new kakao.maps.Point(24 * (buildingIndex % 10), 36 * Math.floor(buildingIndex / 10)), spriteSize: new kakao.maps.Size(24 * 10, 36 * 4), }), - ...markerStyle, }) + const buildingMarker = building.places.every((p) => p.isConquered || p.isClosed || p.isNotAccessible) + ? conqueredMarker + : notConqueredMarker + + buildingMarker.setMap(map) + marker.current = buildingMarker kakao.maps.event.addListener(marker.current, "click", () => onMarkerClick(building)) diff --git a/app/components/layout/Header.style.ts b/app/components/layout/Header.style.ts index 4d14497..0331b34 100644 --- a/app/components/layout/Header.style.ts +++ b/app/components/layout/Header.style.ts @@ -9,7 +9,7 @@ export const DesktopHeader = styled("header", { flexShrink: 0, display: "flex", width: "full", - height: "48px", + minHeight: "48px", alignItems: "center", justifyContent: "space-between", paddingInline: "16px", diff --git a/app/components/layout/Header.tsx b/app/components/layout/Header.tsx index f161e58..2690a47 100644 --- a/app/components/layout/Header.tsx +++ b/app/components/layout/Header.tsx @@ -11,7 +11,7 @@ import { Flex } from "@/styles/jsx" import * as S from "./Header.style" interface Props { - title: string | undefined + title: React.ReactNode | undefined hidden?: boolean hideMenu?: boolean } diff --git a/app/modals/BuildingDetailSheet/BuildingDetailSheet.desktop.tsx b/app/modals/BuildingDetailSheet/BuildingDetailSheet.desktop.tsx index 0843948..93612bd 100644 --- a/app/modals/BuildingDetailSheet/BuildingDetailSheet.desktop.tsx +++ b/app/modals/BuildingDetailSheet/BuildingDetailSheet.desktop.tsx @@ -3,8 +3,7 @@ import { BasicModalProps } from "@reactleaf/modal" import { QuestBuilding } from "@/lib/models/quest" import RightSheet from "../_template/RightSheet" -import * as S from "./BuildingDetailSheet.style" -import PlaceRow from "./PlaceRow" +import PlaceCard from "./PlaceCard" interface Props extends BasicModalProps { building: QuestBuilding @@ -13,31 +12,25 @@ interface Props extends BasicModalProps { export const defaultOverlayOptions = { closeDelay: 200, dim: false } export default function BuildingDetailSheet({ building, questId, visible, close }: Props) { + const conquered = building.places.filter((place) => place.isConquered || place.isClosed || place.isNotAccessible) + const notConquered = building.places.filter( + (place) => !place.isConquered && !place.isClosed && !place.isNotAccessible, + ) + const title = ( + <> + {building.name} +
+ + 정복 완료 {conquered.length} / {building.places.length} + + + ) + return ( - - - - - - - - - - - - - 업체명 - 정복 - 폐업 추정 - 폐업 - 접근 불가 - - {building.places.map((place) => ( - - ))} - - - + + {[...notConquered, ...conquered].map((place) => ( + + ))} ) } diff --git a/app/modals/BuildingDetailSheet/BuildingDetailSheet.mobile.tsx b/app/modals/BuildingDetailSheet/BuildingDetailSheet.mobile.tsx index d1a9b97..8bec841 100644 --- a/app/modals/BuildingDetailSheet/BuildingDetailSheet.mobile.tsx +++ b/app/modals/BuildingDetailSheet/BuildingDetailSheet.mobile.tsx @@ -7,8 +7,7 @@ import { QuestBuilding } from "@/lib/models/quest" import BottomSheet from "@/modals/_template/BottomSheet" -import * as S from "./BuildingDetailSheet.style" -import PlaceRow from "./PlaceRow" +import PlaceCard from "./PlaceCard" interface Props extends BasicModalProps { building: QuestBuilding @@ -26,31 +25,23 @@ export default function BuildingDetailSheet({ building, questId, visible, close } }, []) + const conquered = building.places.filter((place) => place.isConquered) + const notConquered = building.places.filter((place) => !place.isConquered) + const title = ( + <> + {building.name} +
+ + 정복 완료 {conquered.length} / {building.places.length} + + + ) + return ( - - - - - - - - - - - - - 업체명 - 정복 - 폐업 추정 - 폐업 - 접근 불가 - - {building.places.map((place) => ( - - ))} - - - + + {[...notConquered, ...conquered].map((place) => ( + + ))} ) } diff --git a/app/modals/BuildingDetailSheet/BuildingDetailSheet.style.ts b/app/modals/BuildingDetailSheet/BuildingDetailSheet.style.ts deleted file mode 100644 index 2825e44..0000000 --- a/app/modals/BuildingDetailSheet/BuildingDetailSheet.style.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { styled } from "@/styles/jsx" - -export const TableWrapper = styled("div", { - base: { - padding: "0 20px", - marginBottom: 64, - }, -}) -export const PlaceTable = styled("table", { - base: { - width: "full", - }, -}) - -export const HeaderRow = styled("tr", { - base: { - position: "sticky", - top: 0, - zIndex: 1, - backgroundColor: "white", - }, -}) -export const HeaderCell = styled("th", { - base: { - padding: "8px 4px 12px", - fontWeight: "bold", - }, -}) - -export const Cell = styled("td", { - base: { - padding: "8px 4px", - textAlign: "center", - lineHeight: 0, - }, -}) diff --git a/app/modals/BuildingDetailSheet/PlaceCard.style.ts b/app/modals/BuildingDetailSheet/PlaceCard.style.ts new file mode 100644 index 0000000..8a25d86 --- /dev/null +++ b/app/modals/BuildingDetailSheet/PlaceCard.style.ts @@ -0,0 +1,128 @@ +import { styled } from "@/styles/jsx" + +export const PlaceCard = styled("div", { + base: { + margin: "8px 16px", + padding: "16px 20px", + boxShadow: "0px 0px 4px 1px rgba(0,0,0,0.2)", + borderRadius: 4, + }, +}) + +export const Header = styled("div", { + base: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + }, +}) + +export const PlaceName = styled("h3", { + base: { + fontSize: 18, + fontWeight: 700, + }, +}) + +export const PlaceStatusBadge = styled("span", { + base: { + display: "inline-block", + marginLeft: 4, + padding: "2px 4px", + fontSize: 12, + color: "white", + borderRadius: 4, + verticalAlign: "bottom", + }, + variants: { + status: { + good: { + backgroundColor: "var(--leaf-primary-60)", + }, + bad: { + backgroundColor: "#cf3c3b", + }, + warn: { + backgroundColor: "#da952e", + }, + unknown: { + backgroundColor: "#6dd1ad", + }, + }, + }, +}) + +export const Buttons = styled("div", { + base: { + display: "flex", + justifyContent: "flex-end", + gap: 4, + flexShrink: 0, + }, +}) + +export const Button = styled("button", { + base: { + border: "1px solid #ccc", + borderRadius: 4, + background: "white", + overflow: "hidden", + cursor: "pointer", + }, +}) + +export const Body = styled("div", { + base: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + paddingTop: 16, + }, +}) + +export const ActionButton = styled("button", { + base: { + width: "50%", + height: 36, + borderRadius: 4, + fontSize: 14, + cursor: "pointer", + fontWeight: 500, + }, +}) + +export const ClosedConfirm = styled(ActionButton, { + base: { + border: "1px solid #861500", + color: "#861500", + background: "white", + fontSize: 12, + }, +}) + +export const NotAccessible = styled(ActionButton, { + base: { + border: "1px solid #861500", + color: "#861500", + background: "white", + fontSize: 12, + }, +}) + +export const ConquerButton = styled(ActionButton, { + base: { + background: "var(--leaf-primary-60)", + color: "white", + }, +}) + +export const RevertButton = styled(ActionButton, { + base: { + width: "100%", + background: "white", + border: "1px solid var(--leaf-grey-70)", + color: "var(--leaf-grey-10)", + }, +}) diff --git a/app/modals/BuildingDetailSheet/PlaceCard.tsx b/app/modals/BuildingDetailSheet/PlaceCard.tsx new file mode 100644 index 0000000..a759318 --- /dev/null +++ b/app/modals/BuildingDetailSheet/PlaceCard.tsx @@ -0,0 +1,110 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import Image from "next/image" +import { useState } from "react" +import { toast } from "react-toastify" + +import { updateQuestStatus } from "@/lib/apis/api" +import { QuestPlace } from "@/lib/models/quest" + +import naverMapIcon from "../../../public/naver_map.jpg" +import * as S from "./PlaceCard.style" + +interface Props { + place: QuestPlace + questId: string +} +export default function PlaceCard({ place, questId }: Props) { + const [isClosed, setClosed] = useState(place.isClosed) + const [isNotAccessible, setNotAccessible] = useState(place.isNotAccessible) + const noInfo = !place.isConquered && !isClosed && !isNotAccessible + const visited = place.isConquered || isClosed || isNotAccessible + const isReversible = !place.isConquered && (isClosed || isNotAccessible) + + const queryClient = useQueryClient() + const updateStatus = useMutation({ + mutationFn: updateQuestStatus, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["@quests", questId] }) + }, + }) + + function openNaverMap() { + const isMobile = false + if (isMobile) { + window.open(`nmap://search?query=${place.name}`) + } else { + window.open(`https://map.naver.com/p/search/${place.name}`) + } + } + + async function copyPlaceName() { + await navigator.clipboard.writeText(place.name) + toast.success("장소명을 복사했습니다.") + } + + function openInApp() { + toast.info("앱에서 열기 : 준비중입니다.") + } + + const updateClosed = async (isClosed: boolean) => { + await updateStatus.mutateAsync({ + questId, + buildingId: place.buildingId, + placeId: place.placeId, + isClosed, + }) + setClosed(isClosed) + } + + const updateNotAccessible = async (isNotAccessible: boolean) => { + await updateStatus.mutate({ + questId, + buildingId: place.buildingId, + placeId: place.placeId, + isNotAccessible, + }) + setNotAccessible(isNotAccessible) + } + + const revertUpdate = () => { + if (isNotAccessible) { + updateNotAccessible(false) + } + if (isClosed) { + updateClosed(false) + } + } + + return ( + + + + {place.name} + {place.isConquered && 정복} + {!isClosed && place.isClosedExpected && 폐업추정} + {isClosed && 폐업확인} + {isNotAccessible && 접근불가} + + + + 네이버 지도 + + + + + + + + + + {(isReversible || !visited) && ( + + {isReversible && 다시 입력할게요} + {!visited && 정복하기} + {!visited && updateClosed(true)}>폐업했어요} + {!visited && updateNotAccessible(true)}>접근불가} + + )} + + ) +} diff --git a/app/modals/BuildingDetailSheet/PlaceRow.style.ts b/app/modals/BuildingDetailSheet/PlaceRow.style.ts deleted file mode 100644 index 65d4bb2..0000000 --- a/app/modals/BuildingDetailSheet/PlaceRow.style.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { styled } from "@/styles/jsx" - -export const PlaceRow = styled("tr") - -export const Cell = styled("td", { - base: { - padding: "8px 4px", - textAlign: "center", - lineHeight: 1, - }, -}) - -export const ExternalMap = styled("button", { - base: { - width: "20px", - height: "20px", - border: "1px solid #ccc", - verticalAlign: "middle", - marginLeft: 4, - cursor: "pointer", - }, -}) diff --git a/app/modals/BuildingDetailSheet/PlaceRow.tsx b/app/modals/BuildingDetailSheet/PlaceRow.tsx deleted file mode 100644 index dc3adf3..0000000 --- a/app/modals/BuildingDetailSheet/PlaceRow.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query" -import Image from "next/image" -import { useEffect } from "react" -import { Controller, useForm } from "react-hook-form" - -import { updateQuestStatus } from "@/lib/apis/api" -import { QuestPlace } from "@/lib/models/quest" - -import Checkbox from "@/components/Checkbox" - -import * as S from "./PlaceRow.style" -import naverMapIcon from "../../../public/naver_map.jpg" - -interface Props { - place: QuestPlace - questId: string -} - -export default function PlaceRow({ place, questId }: Props) { - const form = useForm({ defaultValues: { isClosed: place.isClosed, isNotAccessible: place.isNotAccessible } }) - const [isClosed, isNotAccessible] = form.watch(["isClosed", "isNotAccessible"]) - const visited = place.isConquered || isClosed || isNotAccessible - const isBadPlace = place.isClosed || place.isNotAccessible - const queryClient = useQueryClient() - const updateStatus = useMutation({ - mutationFn: updateQuestStatus, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["@quests", questId] }) - }, - }) - - const updateClosed = (isClosed: boolean) => { - updateStatus.mutateAsync({ - questId, - buildingId: place.buildingId, - placeId: place.placeId, - isClosed, - }) - } - - const updateNotAccessible = (isNotAccessible: boolean) => { - updateStatus.mutate({ - questId, - buildingId: place.buildingId, - placeId: place.placeId, - isNotAccessible, - }) - } - - function openNaverMap() { - const isMobile = false - if (isMobile) { - window.open(`nmap://search?query=${place.name}`) - } else { - window.open(`https://map.naver.com/p/search/${place.name}`) - } - } - - return ( - - - {place.name} - - 네이버 지도 - - - - - - - - - - ( - { - field.onChange(isClosed) - updateClosed(isClosed) - }} - /> - )} - /> - - - ( - { - field.onChange(isNotAccessible) - updateNotAccessible(isNotAccessible) - }} - /> - )} - /> - - - ) -} diff --git a/app/modals/_template/BottomSheet/BottomSheet.style.ts b/app/modals/_template/BottomSheet/BottomSheet.style.ts index 9d30a61..7a1e4fc 100644 --- a/app/modals/_template/BottomSheet/BottomSheet.style.ts +++ b/app/modals/_template/BottomSheet/BottomSheet.style.ts @@ -34,21 +34,31 @@ export const BottomSheetHeader = styled("div", { alignItems: "center", justifyContent: "center", width: "100%", - flex: "0 0 64px", + flex: "1 0 fit-content", + minHeight: 64, + padding: "8px 0", }, }) export const SheetTitle = styled("h5", { base: { + textAlign: "center", fontSize: 20, fontWeight: 700, + "& small": { + display: "block", + fontWeight: 400, + fontSize: 14, + lineHeight: 18 / 14, + }, }, }) export const CloseButton = styled("button", { base: { position: "absolute", - top: 16, + top: "50%", + transform: "translateY(-50%)", left: 16, display: "flex", alignItems: "center", @@ -71,6 +81,7 @@ export const ActionButtonWrapper = styled("div", { export const BottomSheetBody = styled("div", { base: { height: "100%", + paddingBottom: 48, overflow: "auto", }, }) diff --git a/app/modals/_template/BottomSheet/BottomSheet.tsx b/app/modals/_template/BottomSheet/BottomSheet.tsx index 2f5351e..63c6a66 100644 --- a/app/modals/_template/BottomSheet/BottomSheet.tsx +++ b/app/modals/_template/BottomSheet/BottomSheet.tsx @@ -6,7 +6,7 @@ import * as S from "./BottomSheet.style" export interface BottomSheetProps extends BasicModalProps { children: React.ReactNode - title?: string + title?: React.ReactNode style?: React.CSSProperties actionButton?: React.ReactNode } diff --git a/app/modals/_template/RightSheet/RightSheet.style.ts b/app/modals/_template/RightSheet/RightSheet.style.ts index 6239659..cc9b0a7 100644 --- a/app/modals/_template/RightSheet/RightSheet.style.ts +++ b/app/modals/_template/RightSheet/RightSheet.style.ts @@ -34,14 +34,24 @@ export const RightSheetHeader = styled("div", { alignItems: "center", justifyContent: "center", width: "100%", - flex: "0 0 64px", + flex: "1 0 fit-content", + minHeight: 64, + padding: "8px 0", + borderBottom: "1px solid #ccc", }, }) export const SheetTitle = styled("h5", { base: { + textAlign: "center", fontSize: 20, fontWeight: 700, + "& small": { + display: "block", + fontWeight: 400, + fontSize: 14, + lineHeight: 18 / 14, + }, }, }) @@ -71,6 +81,7 @@ export const ActionButtonWrapper = styled("div", { export const RightSheetBody = styled("div", { base: { height: "100%", + paddingBottom: 48, overflow: "auto", }, }) diff --git a/app/modals/_template/RightSheet/RightSheet.tsx b/app/modals/_template/RightSheet/RightSheet.tsx index efa7afc..733aed1 100644 --- a/app/modals/_template/RightSheet/RightSheet.tsx +++ b/app/modals/_template/RightSheet/RightSheet.tsx @@ -6,7 +6,7 @@ import * as S from "./RightSheet.style" export interface RightSheetProps extends BasicModalProps { children: React.ReactNode - title?: string + title?: React.ReactNode style?: React.CSSProperties actionButton?: React.ReactNode } diff --git a/public/marker_conquered.png b/public/marker_conquered.png new file mode 100644 index 0000000..1c44aae Binary files /dev/null and b/public/marker_conquered.png differ