Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Mingling 프론트엔드 작업 지침

## 1. 적용 범위와 우선순위

- 이 지침은 저장소 전체에 적용한다.
- 현재 UI와 서비스 기능을 기본적으로 보존한다.
- 사용자가 UI 또는 기능 변경을 명시적으로 요청한 경우에만 해당 요청 범위 내에서 예외를 허용한다.
- 사용자 승인 없이 하위 디렉터리에 `AGENTS.md` 또는 `AGENTS.override.md`를 생성하지 않는다.
- 작업을 완료하려면 이 지침을 위반해야 한다고 판단되는 경우, 파일을 수정하지 말고 충돌 내용과 필요한 결정을 사용자에게 설명한다.

## 2. UI 불변 조건

- 사용자가 명시적으로 요청하지 않은 문구, 라벨, 아이콘, 색상, 폰트, 글자 크기, 굵기, 줄 높이, 자간을 변경하지 않는다.
- 여백, 크기, 정렬, 테두리, 그림자, 애니메이션, 반응형 분기점, 이미지 크롭, 페이지 구성을 변경하지 않는다.
- 화면에 보이는 요소를 임의로 추가, 제거, 재배치하거나 스타일을 변경하지 않는다.
- 375px, 768px, 1024px, 1440px 뷰포트에서 현재 화면의 시각적 결과를 보존한다.
- 최종 렌더링 결과가 시각적으로 동일한 경우에만 내부 DOM, 컴포넌트 구조, 이미지 렌더링 방식, 리소스 로딩 방식을 변경할 수 있다.
- 사용자 승인 없이 폰트 패밀리, 등록된 폰트 굵기, 타이포그래피 메트릭, 폰트 preload 정책을 변경하지 않는다.
- 이미지, 지도, 다이얼로그, 로딩 화면, 동적 콘텐츠의 크기와 종횡비를 유지하고 새로운 레이아웃 이동을 발생시키지 않는다.

## 3. 기능 불변 조건

- 사용자가 요청하지 않은 기능을 추가, 제거하거나 의미를 변경하지 않는다.
- 라우트, 리다이렉트, 링크 목적지, 쿼리 파라미터를 변경하지 않는다.
- API 엔드포인트, 요청 및 응답 구조, 환경 변수 이름을 변경하지 않는다.
- localStorage 및 sessionStorage 키, 로그인 판정과 인증 동작을 변경하지 않는다.
- 폼 검증, 제출 순서, 오류 처리, 로딩 상태, 모달 동작을 유지한다.
- 지도, 장소 추천, 모임 생성·참여·공유 흐름과 반응형 상호작용을 유지한다.
- 분석 이벤트 이름, payload, 전송 순서와 GTM 로드 전 `dataLayer` 큐 동작을 유지한다.
- 성능 최적화로 인해 해당 화면의 필수 기능이 늦게 활성화되거나 비활성화되어서는 안 된다.
- 사용자가 명시적으로 요청하지 않은 공개 TypeScript 인터페이스나 공용 데이터 계약을 변경하지 않는다.

## 4. 변경 범위 관리

- 파일을 수정하기 전에 `git status`와 관련 파일을 확인한다.
- 사용자가 이미 작성한 변경 사항을 보존하며, 관련 없는 변경을 되돌리거나 덮어쓰지 않는다.
- 요청을 충족하는 데 필요한 최소한의 파일과 코드만 수정한다.
- 관련 없는 코드 정리, 대규모 리팩터링, 파일 이동, 이름 변경, 저장소 전체 포매팅을 수행하지 않는다.
- 자동 생성 파일을 직접 수정하지 않는다.
- 저장소의 lockfile과 CI 설정에 맞춰 `pnpm`을 사용한다.
- 새로운 production 의존성을 추가하려면 사용자 승인을 먼저 받는다.
- 사용되지 않는 것으로 확인된 의존성은 제거할 수 있으며, `package.json`과 `pnpm-lock.yaml`을 함께 갱신한다.
- 사용자가 요청하지 않은 패키지 버전 업그레이드는 수행하지 않는다.
- `.env*` 파일의 비밀값을 출력하거나 코드와 문서에 포함하거나 커밋하지 않는다.

## 5. 성능 최적화 제약조건

- 화면을 변경하지 않는 범위에서 리소스 발견 시점, 우선순위, 이미지 포맷, 캐시, 번들 경계, 스크립트 실행 시점을 최적화한다.
- Above the Fold 리소스 중 실제 LCP 후보로 확인된 항목만 높은 우선순위를 부여한다.
- Below the Fold 이미지는 지연 로딩을 유지한다.
- CSS로 숨겨진 반응형 이미지까지 preload하거나 eager loading하지 않는다.
- 반응형 이미지 preload의 `srcset`, `sizes`, media query와 실제 이미지 요청 URL이 일치해야 한다.
- 모든 페이지에서 사용하지 않는 리소스 힌트는 해당 라우트에만 적용한다.
- 서드파티 스크립트는 기존 기능과 이벤트 큐가 보존되는 경우에만 지연시킨다.
- 최적화 전 기준값을 기록하고 동일한 라우트, 뷰포트, 네트워크 제한, 캐시 상태에서 결과를 비교한다.
- 성능 목표를 달성하려면 UI 또는 기능 불변 조건을 위반해야 하는 경우, 제약조건을 임의로 완화하지 말고 남은 병목을 사용자에게 보고한다.

## 6. 필수 검증

런타임 코드, 렌더링, 리소스 또는 의존성을 변경한 경우 다음 명령을 실행한다.

1. `pnpm exec tsc --noEmit`
2. `pnpm run lint`
3. `pnpm run build`

다음 항목도 확인한다.

- `pnpm run start`로 로컬 프로덕션 서버를 실행하고 영향받은 흐름을 테스트한다.
- 새로운 브라우저 콘솔 오류, 요청 실패, hydration 경고, 접근성 회귀가 없어야 한다.
- 375px, 768px, 1024px, 1440px에서 변경 전후 화면을 비교한다.
- 문구, 레이아웃, 이미지 크롭, 버튼, 폼, 모달, 지도와 반응형 동작이 동일해야 한다.
- Network 패널에서 preload, 요청 우선순위, 이미지 포맷, 서드파티 로딩 시점이 의도대로 동작하는지 확인한다.

랜딩 페이지 성능 작업은 cold cache와 Lighthouse Mobile 기본 환경에서 3회 측정하고 중앙값을 기록한다.

- Performance 점수: 90점 이상
- LCP: 1.5초 이하
- TBT: 50ms 이하
- CLS: 0.01 이하
- FCP: 기록된 기준값에서 유의미하게 악화되지 않을 것

필수 검증이 실패한 상태에서는 작업을 완료한 것으로 간주하지 않는다. 범위 안에서 실패를 해결하거나 정확한 차단 원인을 보고한다.

## 7. 작업 완료 보고

- 변경한 모든 파일을 나열한다.
- 주요 변경 사항의 Before/After 차이를 설명한다.
- 실행한 검증 명령과 결과를 보고한다.
- 수행한 화면 및 기능 검증 내용을 보고한다.
- 성능 작업은 Lighthouse와 네트워크 측정의 변경 전후 결과를 제공한다.
- 실행하지 못한 검증, 남아 있는 위험과 완료를 막은 제약조건을 명시한다.
10 changes: 10 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
@import 'tailwindcss';
@import 'tw-animate-css';

@font-face {
font-family: 'Pretendard Fallback';
src: local('Arial');
ascent-override: 93.76%;
descent-override: 23.75%;
line-gap-override: 0%;
size-adjust: 101.55%;
}

/* 사용자 정의 컬러 시스템 */
@theme {
--color-gray-1: #f2f3f8;
Expand Down Expand Up @@ -59,6 +68,7 @@
}

:root {
--font-pretendard: 'Pretendard', 'Pretendard Fallback';
--radius: 0.625rem;

/* 흰색 배경, 검은색 글씨 */
Expand Down
40 changes: 19 additions & 21 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,12 @@
import './globals.css';
import type { Metadata } from 'next';
import Script from 'next/script';
import localFont from 'next/font/local';
import Header from '../components/header';
import Footer from '../components/footer';
import GlobalModal from '@/components/modal/globalModal';
import QueryProvider from '@/components/providers/queryProvider';
import { GoogleTagManager } from '@next/third-parties/google';

const pretendard = localFont({
src: [
{ path: '../public/fonts/Pretendard-Thin.woff2', weight: '100' },
{ path: '../public/fonts/Pretendard-ExtraLight.woff2', weight: '200' },
{ path: '../public/fonts/Pretendard-Light.woff2', weight: '300' },
{ path: '../public/fonts/Pretendard-Regular.woff2', weight: '400' },
{ path: '../public/fonts/Pretendard-Medium.woff2', weight: '500' },
{ path: '../public/fonts/Pretendard-SemiBold.woff2', weight: '600' },
{ path: '../public/fonts/Pretendard-Bold.woff2', weight: '700' },
{ path: '../public/fonts/Pretendard-ExtraBold.woff2', weight: '800' },
{ path: '../public/fonts/Pretendard-Black.woff2', weight: '900' },
],
variable: '--font-pretendard',
display: 'swap',
});
const GTM_ID = 'GTM-MSQ45TJD';

export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || 'https://www.mingling.kr'),
Expand All @@ -37,18 +21,32 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="ko" className={pretendard.variable}>
<GoogleTagManager gtmId="GTM-MSQ45TJD" />
<html lang="ko">
<body className="flex min-h-screen flex-col">
<QueryProvider>
<Header />
<main className="flex-1">{children}</main>
<Footer />
<GlobalModal />
</QueryProvider>
<Script id="gtm-data-layer" strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });`}
</Script>
<Script id="load-pretendard" strategy="lazyOnload">
{`const fontStylesheet = document.createElement('link');
fontStylesheet.rel = 'stylesheet';
fontStylesheet.href = '/fonts/pretendard.css';
document.head.appendChild(fontStylesheet);`}
</Script>
<noscript>
{/* eslint-disable-next-line @next/next/no-css-tags -- JavaScript 비활성 환경의 폰트 fallback */}
<link rel="stylesheet" href="/fonts/pretendard.css" />
</noscript>
<Script
src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_API_KEY}&libraries=services,clusterer&autoload=false`}
strategy="beforeInteractive"
id="gtm-script"
src={`https://www.googletagmanager.com/gtm.js?id=${GTM_ID}`}
strategy="lazyOnload"
/>
</body>
</html>
Expand Down
163 changes: 113 additions & 50 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,44 @@
import Link from 'next/link';
import Image from 'next/image';
import Image, { getImageProps } from 'next/image';
import { preload } from 'react-dom';
import { Tooltip, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';

const HERO_IMAGE_MEDIA = {
mobile: '(max-width: 767px)',
tablet: '(min-width: 768px) and (max-width: 1023px)',
desktop: '(min-width: 1024px)',
} as const;

const HERO_IMAGE_SIZES = {
mobile: '217px',
tablet: 'min(658px, calc(100vw - 361px))',
desktop: 'min(860px, calc(100vw - 361px))',
} as const;

const { props: mobileHeroImage } = getImageProps({
src: '/images/iphone.jpg',
alt: 'mobile preview',
width: 867,
height: 1752,
sizes: HERO_IMAGE_SIZES.mobile,
});

const { props: tabletHeroImage } = getImageProps({
src: '/images/tablet.jpg',
alt: 'tablet preview',
width: 2866,
height: 2106,
sizes: HERO_IMAGE_SIZES.tablet,
});

const { props: desktopHeroImage } = getImageProps({
src: '/images/desktop.jpg',
alt: 'desktop preview',
width: 3746,
height: 2752,
sizes: HERO_IMAGE_SIZES.desktop,
});

// --- Data & Constants ---
const CATEGORIES = [
{ id: '식당', label: '식당', icon: '/icon/place/restaurant' },
Expand Down Expand Up @@ -74,59 +111,85 @@ const FeatureCard = ({ title, desc, desc2 }: { title: string; desc: string; desc

// --- Sections ---

const HeroSection = () => (
<section className="mx-[71.73px] mt-30 flex flex-col items-center justify-center md:px-[101px]">
<div className="flex flex-col items-center">
<h1 className="text-gray-8 max-w-[209px] text-center text-[28px] leading-[1.3] font-bold tracking-[-1.128px] break-keep md:max-w-full md:text-[36px] xl:text-[40px]">
모임 장소 선정, 출발역만 넣으면 끝!
</h1>
const HeroSection = () => {
preload(mobileHeroImage.src, {
as: 'image',
fetchPriority: 'high',
imageSrcSet: mobileHeroImage.srcSet,
imageSizes: mobileHeroImage.sizes,
media: HERO_IMAGE_MEDIA.mobile,
});
preload(tabletHeroImage.src, {
as: 'image',
fetchPriority: 'high',
imageSrcSet: tabletHeroImage.srcSet,
imageSizes: tabletHeroImage.sizes,
media: HERO_IMAGE_MEDIA.tablet,
});
preload(desktopHeroImage.src, {
as: 'image',
fetchPriority: 'high',
imageSrcSet: desktopHeroImage.srcSet,
imageSizes: desktopHeroImage.sizes,
media: HERO_IMAGE_MEDIA.desktop,
});

<div className="mt-4 flex flex-col flex-nowrap items-center">
<p className="text-gray-5 text-center text-[16px] leading-[1.364] font-semibold tracking-[-0.4268px] md:text-[18px] md:leading-[1.444]">
참석자들이 지하철 출발역을 입력하면,
<br className="block md:hidden lg:block" />
<span className="hidden md:inline lg:hidden">&nbsp;</span>
이동시간과 편차를 고려해
<br className="block lg:hidden" />
<span className="hidden lg:inline">&nbsp;</span>
서울 내 최적의 번화가를 추천합니다
</p>
</div>
return (
<section className="mx-[71.73px] mt-30 flex flex-col items-center justify-center md:px-[101px]">
<div className="flex flex-col items-center">
<h1 className="text-gray-8 max-w-[209px] text-center text-[28px] leading-[1.3] font-bold tracking-[-1.128px] break-keep md:max-w-full md:text-[36px] xl:text-[40px]">
모임 장소 선정, 출발역만 넣으면 끝!
</h1>

<Link
href="/create"
className="bg-blue-5 hover:bg-blue-8 mt-7.5 flex h-12 w-[196px] items-center justify-center rounded-[8px] text-[18px] leading-[1.445] font-semibold tracking-[-0.0036px] text-white transition-colors"
>
모임 만들기
</Link>
<div className="mt-4 flex flex-col flex-nowrap items-center">
<p className="text-gray-5 text-center text-[16px] leading-[1.364] font-semibold tracking-[-0.4268px] md:text-[18px] md:leading-[1.444]">
참석자들이 지하철 출발역을 입력하면,
<br className="block md:hidden lg:block" />
<span className="hidden md:inline lg:hidden">&nbsp;</span>
이동시간과 편차를 고려해
<br className="block lg:hidden" />
<span className="hidden lg:inline">&nbsp;</span>
서울 내 최적의 번화가를 추천합니다
</p>
</div>

{/* Responsive Images (기존 코드 유지) */}
<div className="mt-[74px] md:mt-[74px] lg:mt-10">
<Image
src="/images/iphone.jpg"
alt="mobile preview"
width={860}
height={635}
className="block h-auto w-[216.54px] md:hidden"
/>
<Image
src="/images/tablet.jpg"
alt="tablet preview"
width={860}
height={635}
className="hidden h-auto md:block md:w-[658px] lg:hidden lg:w-[860px]"
/>
<Image
src="/images/desktop.jpg"
alt="desktop preview"
width={860}
height={635}
className="hidden h-auto md:w-[658px] lg:block lg:w-[860px]"
/>
<Link
href="/create"
className="bg-blue-5 hover:bg-blue-8 mt-7.5 flex h-12 w-[196px] items-center justify-center rounded-[8px] text-[18px] leading-[1.445] font-semibold tracking-[-0.0036px] text-white transition-colors"
>
모임 만들기
</Link>

<div className="mt-[74px] md:mt-[74px] lg:mt-10">
<picture>
<source
media={HERO_IMAGE_MEDIA.desktop}
srcSet={desktopHeroImage.srcSet}
sizes={desktopHeroImage.sizes}
width={3746}
height={2752}
/>
<source
media={HERO_IMAGE_MEDIA.tablet}
srcSet={tabletHeroImage.srcSet}
sizes={tabletHeroImage.sizes}
width={2866}
height={2106}
/>
<img
{...mobileHeroImage}
alt="mobile preview"
className="block h-auto w-[216.54px] md:w-[658px] lg:w-[860px]"
loading="eager"
fetchPriority="high"
decoding="async"
/>
</picture>
</div>
</div>
</div>
</section>
);
</section>
);
};

const PainPointsSection = () => (
<section
Expand Down
2 changes: 1 addition & 1 deletion components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const Footer = () => {
return (
<footer className="bg-gray-1 flex h-59 items-center md:h-35">
<div className="flex w-full flex-col items-start gap-5 px-5 md:flex-row md:justify-between md:gap-0">
<Image src="/logo.svg" alt="Mingling Logo" width={112} height={40} priority />
<Image src="/logo.svg" alt="Mingling Logo" width={112} height={40} />
<nav className="flex flex-col items-start gap-6 md:items-end">
<div className="flex flex-col gap-3 md:flex-row md:gap-8">
<div className="flex gap-4 md:gap-8">
Expand Down
3 changes: 3 additions & 0 deletions components/map/kakaoMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState } from 'react';
import { Map, CustomOverlayMap } from 'react-kakao-maps-sdk';
import { useKakaoMapsLoader } from '@/hooks/useKakaoMapsLoader';
import ZoomControl from './zoomControl';

interface Participant {
Expand All @@ -20,6 +21,8 @@ interface KakaoMapProps {
}

export default function KakaoMap({ className, participants = [] }: KakaoMapProps) {
useKakaoMapsLoader();

// 지도 객체를 담을 state (범위 재설정용)
const [map, setMap] = useState<kakao.maps.Map | null>(null);

Expand Down
Loading
Loading