diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 7410b67..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "axis-dev", - "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], - "port": 3000, - "autoPort": true, - "env": { - "AXIS_PRICE_SOURCE": "seed" - }, - "_comment": "seed는 로컬 데모 전용. 프로덕션/Vercel에는 넣지 말 것." - - } - ] -} diff --git a/.conductor/settings.toml b/.conductor/settings.toml deleted file mode 100644 index aaa6043..0000000 --- a/.conductor/settings.toml +++ /dev/null @@ -1,13 +0,0 @@ -"$schema" = "https://conductor.build/schemas/settings.repo.schema.json" -file_include_globs = ".env*\n" - -[scripts] -setup = "mkdir -p .context && touch .context/handoff.md .context/fe-notes.md .context/be-notes.md .context/security-report.md && (npm install || true)" -run = "npm run dev -- -p $CONDUCTOR_PORT" -run_mode = "concurrent" - -[prompts] -general = "Follow AGENTS.md. Work only in this repo. FE/BE implement and summarize their own work; Security performs first-pass verification and fixes all found issues after PM says 검증 or 검증 단계로 넘겨; Lead performs second-pass verification and final synthesis after Security finishes." -code_review = "Focus on correctness, FE/BE API contract, auth/permission, secrets, file-boundary violations, and lint/typecheck/test/build evidence." -fix_errors = "Fix the smallest scoped issue within this repo and record failed commands or blockers in .context role notes." -create_pr = "Base PRs on master. Use 한것/검증/남은것 format. Summarize changed files, verification commands, Security result, and any PM approvals needed." diff --git a/.gitignore b/.gitignore index 7ccecd1..3555329 100644 --- a/.gitignore +++ b/.gitignore @@ -2,20 +2,28 @@ node_modules .env .env.local +.env* .DS_Store -.context/ -.omc/ .idea -.claude/settings.local.json *.log *.tsbuildinfo + +# Vercel / local link .vercel # Supabase CLI local state supabase/.temp supabase/.branches -.env* -# Local agent tooling (never upload to Vercel) +# Agent / local tooling (not for public GitHub) +.claude/ +.conductor/ +.context/ +.omc/ .omo/ +.repowise/ .codegraph +.codebase-memory/ + +# Internal working notes (local only) +docs/inbox/ diff --git a/.vercelignore b/.vercelignore index 64de243..9b1fd22 100644 --- a/.vercelignore +++ b/.vercelignore @@ -1,2 +1,13 @@ .codegraph .omo/ +.omc/ +.repowise/ +.claude/ +.conductor/ +.context/ +docs/ +DEV_NOTES.md +PROMPT.md +DESIGN.md +AGENTS.md +CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 8389434..408235a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,93 +7,10 @@ - DB: Supabase. - PM만 최종 승인과 배포를 한다. -## Conductor 역할 흐름 - -1. PM이 Codex FE/BE에게 작업 지시. -2. Codex FE와 Codex BE가 각자 영역에서 구현, todo 관리, lint/typecheck/test/build 실행. -3. FE/BE는 각자 작업 결과를 종합해 수정 파일, 실행 명령, 실패/남은 이슈를 역할 노트에 기록한다. -4. PM이 "검증 단계로 넘겨" 또는 "검증" 지시 전까지 Codex Security는 개입하지 않는다. -5. Security가 1차 검증을 수행한다: 전체 diff, FE/BE 연결, 보안, 권한, 타입, 테스트, 런타임 위험. -6. Security는 발견한 문제를 Security 선에서 전부 수정하고, 수정 내용과 남은 리스크를 `security-report.md`에 기록한다. -7. Security 완료 후 Claude Lead가 2차 검증과 최종 종합을 수행한다: security-report, diff, test 결과, 구조 리스크, 남은 이슈, 다음 작업, merge 가능 여부. - -## 파일 경계 - -### Codex FE - -담당: -- UI/UX, 컴포넌트, 화면, 스타일, 반응형, 클라이언트 상태, API 연결부, 프론트 테스트 - -금지: -- `api/`, `server/`, `src/server/`, `supabase/`, `data/`, `engine/`, `config/`, `lib/specs/`, `lib/pricing/`, `lib/ai/` 직접 수정 -- API 스펙 임의 변경 -- DB schema/migration/auth 임의 변경 - -### Codex BE - -담당: -- API, 서버 로직, `lib/specs/`, `lib/pricing/`, `lib/ai/`, DB 연결, migration, auth, permission, validation, backend security, backend test - -금지: -- `components/`, `pages/`, `screens/`, `ui/`, `styles/`, 프론트 화면 파일 직접 수정 -- 응답 형식 임의 변경 -- package/lockfile 임의 변경 - -### Codex Security - -- PM 검증 지시 후에만 실행. -- 검증: FE/BE API 불일치, request/response 타입, auth/session, permission 우회, DB/migration, secret/env 노출, webhook/결제/파일업로드, lint/typecheck/test/build, 런타임 에러, 파일 범위 침범. -- 발견한 보안/타입/설정/구조 문제는 Security 선에서 전부 수정한다. -- 수정한 내용, 수정하지 못한 리스크, 검증 결과를 `.context/security-report.md`에 기록한다. - -### Claude Lead - -- Security 통과 뒤 마지막에만 실행. -- 담당: 2차 검증, 최종 종합, 큰 구조 리뷰, 변경 요약, 남은 이슈, 다음 task 설계, merge 가능 여부 판단. -- 2차 검증에서는 `security-report.md`, FE/BE 노트, 전체 diff, lint/typecheck/test/build 결과, 파일 범위 침범 여부를 다시 확인한다. -- 코드 대량 수정 금지. - -## 기록 위치 - -- `.context/handoff.md` -- `.context/fe-notes.md` -- `.context/be-notes.md` -- `.context/security-report.md` -- 필요 시 `docs/inbox/frontend.md`, `docs/inbox/backend.md`, `docs/inbox/security.md`, `docs/inbox/lead.md` - -## 보고 형식 - -토큰 절약을 위해 모든 역할은 기본 보고를 아래 형식으로 끝낸다. - -```text -한것: file1, file2 -검증: lint ✅ type ✅ test ✅ build ✅ -남은것: 없음 -``` - -문제 있을 때만 짧게 이유를 붙인다. - -```text -한것: file1 -검증: test ❌ — 핵심 이유 -남은것: 해야 할 일 -``` - -Lead 최종 보고는 아래 형식을 쓴다. - -```text -판정: merge 가능/불가 -한것: 핵심 3줄 이하 -검증: Security ✅ Lead ✅ -남은것: 없음/항목 -``` - -긴 설명, 전체 파일 출력, 장문 로그 붙여넣기는 금지한다. 상세 판단은 Conductor diff/test 결과로 한다. - ## 공통 규칙 -- 기본은 자율 수행이다. 역할 범위 안 코드 수정, 검증 명령 실행, 문서/.context 기록, 작은 설정 수정은 PM 승인 없이 진행한다. -- 단, 아래 항목은 반드시 PM 승인 후 진행한다: +- 시크릿/앱키를 클라이언트 번들에 넣지 않는다. +- 아래 항목은 PM 승인 후 진행한다: - 개인정보/시크릿/env 값 생성·수정·노출 - DB schema/rules/migration 실제 적용 - 인증 방식 변경 @@ -104,10 +21,4 @@ Lead 최종 보고는 아래 형식을 쓴다. - git push/merge/delete branch - 대량 삭제 - 프로젝트 밖 파일 수정 -- package.json/lockfile 변경은 PM 승인 후. -- DB schema/migration 변경은 PM 승인 후. -- 인증 방식 변경은 PM 승인 후. - 배포 실행은 PM만. -- 시크릿/앱키를 클라이언트 번들에 넣지 않는다. -- 실패한 명령은 숨기지 않고 역할 노트에 기록한다. -- 긴 전체 파일 출력 금지. diff, 테스트 결과, 수정 파일 목록 중심으로 보고한다. diff --git a/CLAUDE.md b/CLAUDE.md index 2fce4ed..15623c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,6 @@ # CLAUDE.md -Axis 코드베이스에서 작업할 때 따라야 할 규칙. 프로젝트 개요·아키텍처는 -[README.md](README.md), 상세 개발 노트는 [DEV_NOTES.md](DEV_NOTES.md) 참고. +Axis 코드베이스에서 작업할 때 따라야 할 규칙. 제품 개요는 [README.md](README.md) 참고. ## 절대 규칙 @@ -23,7 +22,7 @@ Axis 코드베이스에서 작업할 때 따라야 할 규칙. 프로젝트 개 - 타입 체크: `npx tsc --noEmit`. - 데이터셋 변경 시 `dataset.test.ts` 무결성 검사 통과 확인. - 스키마·결과 포맷을 바꾸면 `lib/comparison-cache.ts`의 `CACHE_VERSION`을 올려 - 구버전 캐시를 무효화한다 (현재 **v9**). + 구버전 캐시를 무효화한다. ## 데이터셋 작업 diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 6f82df4..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,68 +0,0 @@ -# Axis Design System - -## 1. Atmosphere & Identity - -Axis feels like a quiet decision desk for high-stakes electronics. Surfaces stay calm and readable; accent color appears only on winners, actions, and focus. The signature is comparison clarity: short verdicts, clear winners, and numeric gaps shown as selectable line graphs rather than noise. - -## 2. Color - -### Palette - -| Role | Token | Light | Dark | Usage | -|------|-------|-------|------|-------| -| Surface/primary | --bg | #ffffff | #0d0f14 | Page background | -| Surface/secondary | --bg-subtle | #f8f9fc | #111318 | Soft panels | -| Text/primary | --text | #0f172a | #e2e8f4 | Headlines, body | -| Text/muted | --muted | #64748b | #8891a6 | Notes, secondary | -| Accent/primary | --accent | #3454e8 | #5b78f5 | CTAs, winners, focus | -| Accent/secondary | --accent-2 | #7c5cff | #9474ff | Winner gradients | - -### Rules -- Accent is for interactive or winning states only. -- Coverage notes and helper text use muted, never decorative color. -- Spec line graphs reuse accent/accent-2 for the path and best points. - -## 3. Typography - -- Body and cards: system UI stack already set in globals. -- Section titles in detail cards stay compact and high-contrast. -- Coverage notes: ~0.82–0.88rem, weight 500–600, muted. - -## 4. Spacing - -- Detail cards: existing padding (~1.5rem). -- Spec graph cards: 0.7–1.05rem vertical rhythm. -- Line chart viewBox ~640×220 with soft gridlines. - -## 5. Components - -### CoverageNote -- Short muted sentence near hero input and results graph section. -- Copy pattern: `{year}년 이후 출시 제품부터 비교 가능합니다` (localized). - -### SpecBarGraphs -- One compare bar chart at a time for numeric comparable fields. -- Products are shown side-by-side as vertical bars. -- Bar height follows raw numeric magnitude (larger number = taller bar). No better/worse inversion. -- Chip selector shows exactly one spec chart to keep the page short. -- Product labels + numeric values stay visible; no recommendation highlight in the graph. -- Keep existing cmp-table below as the exact-value source. - -### Spec table -- Existing `.cmp-table` remains source of exact values. - -## 6. Motion - -- Verdict bar width transition remains ~0.7s; bar height transition matches verdict bars (~0.7s). - -## 7. Accessibility - -- Graphs must remain readable without color alone: product labels + numeric values stay visible. -- Spec chips use `role="radio"` with `aria-checked` for single-select. -- Focus rings use accent outline. -- Do not rely on percentage labels for the verdict bars; numeric values stay on graph rows. - -## 8. Accepted Debt - -- No DESIGN.md existed; this file extracts the current Axis look rather than redesigning. -- React-grab / react-scan / react-doctor deferred pending PM package approval. diff --git a/DEV_NOTES.md b/DEV_NOTES.md deleted file mode 100644 index c4db1f6..0000000 --- a/DEV_NOTES.md +++ /dev/null @@ -1,237 +0,0 @@ -# Axis — 개발 노트 - -> 마지막 업데이트: 2026-07-21 -> 테스트: `npm test` 통과 기준 유지 · 캐시 버전: **v9** -> 프로덕션: https://axis-app-beta.vercel.app -> -> 이 문서는 개발 단일 참조점이다. 제품 방향·진행 현황·아키텍처·남은 작업을 모두 담는다. -> 작업 규칙은 [CLAUDE.md](CLAUDE.md), 공개 소개는 [README.md](README.md). - ---- - -## 1. 제품 방향 - -**한 줄 정의(비전):** 사기 전에 결정해주고, 산 뒤 최적 타이밍까지 알려주는 도구. -= **결정(Decide) → 추적(Track) → 알림(Alert)** - -단순 비교/결정만으로는 ChatGPT를 못 이긴다. 해자는 **라이브 가격 + 가격 알림 + -검증 스펙** — 챗봇이 구조적으로 못 하는 것들. **단, 이 해자(가격·알림)는 현재 -미작동(seed 데이터)이고 그 경제성이 미검증이다 → 아래 실행 범위로 좁혀 먼저 검증한다.** - -### 실행 범위 (사업성 검증 반영, 2026-06-11 — v3 narrow) - -이전 "글로벌+한국 동시 / 4개 카테고리 / 푸시·구독 로드맵"은 1인 운영에 범위 과잉. -검증 통과 전까지 다음으로 좁힌다. (이전 방향 전문은 git 이력의 DIRECTION.md 참고.) - -- **시장: 한국 단독 먼저.** US/JP 코드 자산은 유지하되 콘텐츠·운영은 KR 집중. - **1개 시장 검증 전 다국가 운영 금지.** -- **카테고리: 노트북 먼저.** 폰/태블릿/이어폰 데이터셋은 유지하되, 출시·SEO·콘텐츠는 노트북 집중. -- **수익: Phase 1 제휴(쿠팡 파트너스) 단독.** 푸시·구독은 **검증 후로 보류**. - 제휴 링크는 광고가 아니라 유틸 → 절대 숨기지 않음. -- **데이터: seed → 실가격은 "쿠팡 현재가 + 선택 SKU 자체 일별 적재"로 PoC.** - 외부 가격이력 API(Keepa 등) 결제 금지. "역대최저·급락" 알림은 자체 이력이 - 수개월 쌓인 뒤에만 약속. -- **검증 우선:** 추가 기능 빌드(푸시·구독·다국가·신규 카테고리)는 §6의 30일 검증 - 통과 전까지 중단. - -**검증 게이트 (절대 규율):** 스펙 비교는 primary 스펙이 출처 등급 2티어 이상일 때만 -`verified`·색인. 출처 티어: 1 제조사 공식 / 2 검증 자료 / 3 AI 추정. 모든 결과에 출처·수집일 표기. - ---- - -## 2. 아키텍처 - -### 비교 요청 흐름 (`lib/decision-engine.ts`) - -``` -사용자 쿼리 ("에어팟 프로 vs 버즈") - ↓ -[1] 캐시 확인 (Supabase comparison_cache, v9|query|locale|country) - ↓ 미스 -[2] expandComparisonOptions() — 브랜드명 → 최신 모델 확장 -[3] detectCategory() — 카테고리 분류 -[4] resolveComparableSource() — product-aliases → registry → URL 확인 -[5] extractProductSpecs() — 공식 페이지 HTML 스크래핑 (병렬, apple|samsung|generic 파서) - + 데이터셋 fallback (enrichWithDatasetFallback, merge 방식) -[6] runAiDecision() — Groq API. 스키마 필드 전체 채우기, 스크래핑 값은 context로 복사 -[7] buildFinalComparison() — AI 행 기반 + 스크래핑 값 오버레이 + source URL(검증 배지) -[8] gradeVerification() — verified / partial / unverified -[9] setCachedComparison() — 캐시 저장 -``` - -### 검증 등급 - -| 등급 | 조건 | SEO | -|------|------|-----| -| `verified` | 모든 primary 필드에 공식 source URL | ✅ 인덱싱 | -| `partial` | 일부 primary 필드만 sourced | 조건부 | -| `unverified` | AI 단독 (source 없음) | ❌ noindex | - -### 레이어 구성 - -``` -[결정] 분류기 → 스키마 → 검증 스펙 → AI 결정 → 검증 배지 - lib/category, lib/specs/*, lib/ai/*, lib/decision-engine -[가격] Product → PriceProvider(region별) → Quote/History → 차트·최저가 - lib/pricing/* (amazon / coupang / seed) -[추적] 계정 → Watch(관심상품·목표가) → 가격 점검 크론 → 알림(이메일→푸시) - Supabase(watches) + cron + lib/watch · lib/push -``` - ---- - -## 3. 데이터셋 현황 - -수동 검증 데이터셋 (`lib/specs/dataset/`), **2020+ 모델, 인지도 있는 제품 기준**: - -| 카테고리 | 파일 | 개수 | -|---------|------|------| -| 스마트폰 | `smartphones.ts` | 55 | -| 이어폰 | `earphones.ts` | 18 | -| 노트북 | `laptops.ts` | 28 | -| 태블릿 | `tablets.ts` | 23 | -| **합계** | | **124** | - -추가로 다나와 자동 수집 데이터(`dataset/kr/`)가 수동 데이터 뒤에 병합됨 (ID 중복 시 수동 우선). - -### 제품명 로케일 정규화 - -- `VerifiedProduct`: `canonicalName`(한국어) + `nameEn` + `nameJa`. -- `localizeDisplayName()` — 제품 해석 후 로케일 표시명 반환. 카탈로그에 없는 제품은 - EN/JA에서 `koToEnTechName()`로 폴백 (아이폰17 → iPhone 17). -- 이 정규화로 "영어 설정 + 한글 입력 → 영어 제품명 + Amazon US 검색어"가 일관되게 동작. - -### 카테고리 스키마 (primary 필드) - -- **smartphone**: model_name, display_inch, chipset, storage_gb, camera_mp, battery, weight_g -- **laptop**: model_name, cpu, gpu, ram_gb, storage_gb, display_inch, resolution, battery_wh, weight_g, os, refresh_hz, brightness_nits -- **earphones**: model_name, driver, anc, battery_hr, battery_total_hr, charging_type, water_resist, weight_g - ---- - -## 4. 스펙 수집 인프라 (반자동 확장) - -``` -scripts/collect-specs/ -├── index.ts 메인 러너 -├── sources/{danawa,gsmarena,kakaku}.ts KR/US/JP 소스 -└── models/*.json 수집 대상 목록 -``` - -```bash -npm run collect:kr # 다나와 (KR, KRW 가격) -npm run collect:us # GSMArena (US, tier 2) -npm run collect:jp # 価格.com (JP, JPY 가격) -``` - -수집 후: 결과 파일 검토 → `dataset/index.ts` import → `npm test` 무결성 검사. -⚠️ Samsung/Apple JS 렌더링 페이지는 정적 스크래핑 실패 가능 → 수동 보완 필요. - ---- - -## 5. 완료된 핵심 작업 - -- 브랜드 리네임 (nudge/Optio → Axis), 멤버십/플랜 제거 -- 3단계 제품 발견 파이프라인 + 제품 레지스트리/앨리어스 -- AI 연동 (Groq Llama 3.1, 프로바이더 추상화) -- 가격 추적(lib/watch, lib/pricing) · 푸시 알림(service worker, VAPID) — 코드 완료 -- SEO 비교 페이지(/compare/[slug]), Cron 가격 체크, 비교 결과 캐시, 검색 누락 로깅 -- 스펙 정확도 수정: 아이폰16 프로 주사율(60→120Hz), `enrichWithDatasetFallback` merge 방식 -- 한/미/일 전체 점검: US/JP 데이터셋 fallback, ja 라벨 중앙화(JA_FIELD_LABELS), - danawa URL 공식소스 유출 차단 -- 제품명 로케일 정규화 + 데이터셋 124개 확장(수동), 태블릿 카테고리 신설 -- UI: 결과 카드 화이트 리디자인, fit score 바, 어필리에이트 "공식" 제거 + 다이렉트 링크 - ---- - -## 6. 남은 작업 / 미확인 사항 - -### 즉시 (운영 환경 설정) - -| 항목 | 내용 | -|------|------| -| `CRON_SECRET` 교체 | 현재 기본값 → `openssl rand -base64 32` → Vercel Production | -| VAPID 키 생성·등록 | 로컬 3종 SET. WatchButton은 PriceComparison remount로 노출 경로 복구됨 | -| Supabase 마이그레이션 | 0014_price_history.sql 로컬 적용 완료. 프로덕션은 Vercel 배포 후 `npx supabase db push` | -| 네이버 쇼핑 API 키 설정 | [https://developers.naver.com/apps/](https://developers.naver.com/apps/) → 앱 등록 → 쇼핑 체크 → `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET` → `AXIS_PRICE_SOURCE=naver` | -| Coupang API (대기) | 누적 15만원 매출 달성 후 파트너스 포털 최종승인 → `COUPANG_ACCESS_KEY` / `COUPANG_SECRET_KEY` → `AXIS_PRICE_SOURCE=coupang` 으로 전환 | - -### 기능 비활성화 (API 키 없음) - -- `BRAVE_SEARCH_API_KEY` 의도적 미설정 → 웹 검색 폴백은 검증 보류 -- `RESEND_API_KEY` 로컬 SET. 프로덕션 패리티·실발송 QA는 별도 확인 -- `VAPID_*` 로컬 SET. 푸시 실동작 QA는 별도 확인 - -### 미확인 - -- Sony 한국 URL(`sony.co.kr`) 실제 동작 여부 -- LG gram 14/16/17형 containment match 오매핑 가능성 -- ~~WatchButton UI 미노출~~ → 원인: ResultsView에서 PriceComparison orphan. remount 완료. VAPID 가설 폐기 -- Sony 한국 URL(`sony.co.kr`) 실제 동작 여부 -- LG gram 14/16/17형 containment match 오매핑 가능성 - -### 다음 — 빌드 아님, 검증 먼저 (30일 사업 검증) - -추가 기능 빌드 전에 핵심 가정부터 싸게 검증한다. 통과 시에만 빌드 재개. - -**핵심 가정 (먼저 검증):** -- A 실가격·이력을 싸게 확보 가능 — 쿠팡 현재가 + 노트북 28 SKU 자체 일별 적재 PoC - → **[준비 완료]** `naver-provider.ts` + `coupang-provider.ts` + `price-snapshot` 크론 구현 완료 (2026-06-11). - ① 네이버 쇼핑 API 즉시 발급 후 `AXIS_PRICE_SOURCE=naver` 로 활성화 (가격=전국최저가, 구매링크=쿠팡 제휴). - ② Coupang 누적 15만원 매출 달성 → 최종승인 → `AXIS_PRICE_SOURCE=coupang` 으로 전환 (env 변경만으로 완료). -- B SEO/커뮤니티로 유입 가능 — 노트북 추천/비교 콘텐츠 15~20편 색인 -- C 방문자가 제휴 클릭·구매 — 쿠팡 링크 클릭률 >8%, 전환 추적 -- D 알림이 재방문을 만든다 — 실알림 켜고 M2 재방문 >30% -- E 검증 배지가 선택에 영향 — 배지 유무 A/B - -**kill-switch:** A·B·C 모두 통과 → 노트북·KR·제휴로 추진. A 실패 → Track/Alert 폐기· -결정 전용 PIVOT 검토. B 또는 C 실패 → 사업 재고(STOP). - -**보류 (검증 전 빌드 금지):** P3-푸시앱 · P3-구독 · US/JP 운영 · 신규 카테고리(모니터 등) · -외부 가격이력 API 결제. - -> 상세 실험·지표·성공/실패 기준은 사업성 검증 보고서 및 메모리 -> (`project_business_validation`) 참조. P3-SEO(정적 비교·제품 페이지 색인)는 가정 B -> 검증 수단으로 흡수. - ---- - -## 7. 명령어 - -```bash -npm run dev # 개발 서버 (webpack) -npm run dev:turbo # 개발 서버 (turbopack) -npm test # 테스트 -npm run test:watch # 테스트 watch -npm run build # 빌드 -npx tsc --noEmit # 타입 체크 - -vercel env pull .env.local # 환경변수 로컬 동기화 -vercel env ls # 환경변수 상태 확인 -supabase db push # 마이그레이션 적용 -npx web-push generate-vapid-keys # VAPID 키 생성 -openssl rand -base64 32 # CRON_SECRET 생성 -``` - ---- - -## 8. 환경변수 체크리스트 - -| 변수 | 상태 | 설명 | -|------|------|------| -| `NEXT_PUBLIC_SUPABASE_URL` | 설정됨 | Supabase URL | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | 설정됨 | Supabase anon 키 | -| `SUPABASE_SERVICE_ROLE_KEY` | 설정됨 | Supabase 서비스 키 | -| `GROQ_API_KEY` | 설정됨 | AI 결정 엔진 | -| `CRON_SECRET` | ✅ 교체 완료 | 2026-06-11 새 키로 교체 | -| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | ✅ 로컬 SET | 푸시 알림 | -| `VAPID_PRIVATE_KEY` | ✅ 로컬 SET | 푸시 알림 | -| `VAPID_SUBJECT` | ✅ 로컬 SET | 푸시 알림 | -| `AXIS_PRICE_SOURCE` | ✅ naver | 네이버 쇼핑 최저가 활성화 | -| `NAVER_CLIENT_ID` | ✅ 설정됨 | 네이버 쇼핑 API | -| `NAVER_CLIENT_SECRET` | ✅ 설정됨 | 네이버 쇼핑 API | -| `BRAVE_SEARCH_API_KEY` | ❌ 미설정(보류) | 웹 검색 폴백 | -| `RESEND_API_KEY` | ✅ 로컬 SET / 프로덕션 패리티 미확인 | 이메일 알림 (D단계) | -| `NEXT_PUBLIC_SITE_URL` | ⚠️ 미설정 시 fallback | public=`https://axis-app-beta.vercel.app` · layout local=`http://localhost:3000` | -| `COUPANG_ACCESS_KEY` | ❌ 대기 중 | 쿠팡 파트너스 API (최종승인 후 발급 — 15만원 매출 필요) | -| `COUPANG_SECRET_KEY` | ❌ 대기 중 | 쿠팡 파트너스 API | diff --git a/PROMPT.md b/PROMPT.md deleted file mode 100644 index 7d69ff8..0000000 --- a/PROMPT.md +++ /dev/null @@ -1,145 +0,0 @@ -# Axis — AI 작업 프롬프트 - -> 배포 목표: 2026-08-15 (베타 → 정식) | 현재: 프로덕션 베타 배포 완료, 사업성 검증 단계 -> 문서 기준일: 2026-07-21 · 로컬 브랜치 `local-fix` · 프로덕션 HEAD 재배포 필요 - ---- - -## 프로젝트 컨텍스트 - -너는 **Axis** (전자제품 구매 결정 도구)를 베타에서 정식 배포로 끌어올리는 시니어 엔지니어다. - -**프로젝트 위치:** `/Users/minseokchae/Dev/deployed/Axis/` - -**프로덕션 URL:** https://axis-app-beta.vercel.app -**상태:** 베타 · 사업성 검증 단계 (한국 · 노트북 · 제휴 집중) - -**제품 설명:** -"아이폰 16 vs 갤럭시 S25" 같은 자연어 쿼리를 입력하면 공식 스펙을 검증해 AI가 비교 테이블과 단일 결론을 만들고, 가격을 추적해 최적 구매 타이밍과 알림을 제공한다. 한 줄 정의: **결정(Decide) → 추적(Track) → 알림(Alert)**. 챗봇이 구조적으로 못 하는 라이브 가격 + 가격 알림 + 검증 스펙이 해자다. - -**기술 스택:** -- Next.js (App Router) + React 19 + TypeScript (SSR + SEO) -- Vercel (Serverless + Cron, 무료 티어) -- Supabase (PostgreSQL, RLS) — 워크스페이스에서 Supabase가 승인된 **유일한** 프로젝트 -- Groq (Llama 3.1) — 프로바이더 추상화 (OpenAI/Gemini/Anthropic 폴백, 429 시 자동 전환) -- 네이버 쇼핑 Open API(실시간 최저가), Resend(이메일), Web Push(VAPID), Vitest - ---- - -## 현재 완료 상태 (2026-07-21 기준) - -### 기능 (완료) -- AI 구매 결정 엔진 + 검증 게이트 (verified / partial / unverified) -- 맞춤 재분석 (userContext — 용도·예산·상황 가중치로 결론 재계산, 캐시 우회) -- 네이버 쇼핑 실시간 최저가 + 자체 일별 가격 이력 적재 (크론) -- 이메일 가격 알림 (Resend) + 웹 푸시 알림 (VAPID, PWA) — **코드 완료** -- 검증 데이터셋 수동 124개 (스마트폰 55 · 이어폰 18 · 노트북 28 · 태블릿 23) -- 다국어 KR/US/JP (제품명 로케일 정규화: canonicalName / nameEn / nameJa) -- SEO 정적 비교 페이지 (`/compare/[slug]`), 비교 결과 캐시 (v9), 클릭 트래킹 -- ResultsView `PriceComparison`/`WatchButton` 재마운트 (2026-07-21, `hidePrices`일 때만 숨김) -- `lib/site-url.ts` 공용 사이트 URL 헬퍼 (공개 fallback = beta 호스트) - -### 운영 환경 (로컬 기준 설정 완료) -- `CRON_SECRET` 교체 완료, VAPID 3종 설정, `AXIS_PRICE_SOURCE=naver` 활성 -- `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET` 설정 완료 -- `RESEND_API_KEY` 로컬 SET (프로덕션 패리티는 PM 확인 필요) - -### 대기 중 / 프로덕션 갭 -- 쿠팡 파트너스 API: 누적 매출 15만원 달성 후 발급 (`AXIS_PRICE_SOURCE=coupang` env 전환만으로 완료) -- `BRAVE_SEARCH_API_KEY`(웹 검색 폴백) 미설정 — **보류** (30일 검증 범위 밖) -- 프로덕션 배포가 seed-only SHA `3b54e0a`에 고정 → **로컬 HEAD 재배포 필요 (PM)** - ---- - -## 남은 작업 (이 세션에서 진행할 것) - -> **원칙:** 추가 기능 빌드(푸시앱·구독·다국가 운영·신규 카테고리·외부 가격이력 API 결제)는 아래 30일 검증 통과 전까지 **중단**. 검증 먼저, 빌드는 그 다음. - -### Phase 1: 노트북·한국 집중 콘텐츠 (검증 가정 B) - -- 노트북 추천/비교 콘텐츠 15~20편 색인 (SEO/커뮤니티 유입 검증) -- `/compare/[slug]` 정적 페이지가 verified 등급으로 색인되는지 확인 -- 데이터셋: 노트북 28 SKU 우선 정확도 점검 (LG gram 14/16/17형 containment match 오매핑 가능성 확인) - -### Phase 2: 제휴 전환 추적 (검증 가정 C) - -- 쿠팡 링크 클릭률 측정 (목표 >8%), `click_events` 적재 검증 -- 제휴 링크는 광고가 아니라 유틸 → **절대 숨기지 않음** -- 누적 15만원 매출 달성 시 쿠팡 파트너스 최종승인 → env 전환 - -### Phase 3: 알림 재방문 검증 (검증 가정 D) - -- 로컬 `RESEND_API_KEY` SET 확인됨 → 프로덕션 패리티 확인 후 실알림 활성화 -- M2 재방문 >30% 측정 -- WatchButton: 과거 "VAPID_SUBJECT" 가설 폐기. 실제 원인은 ResultsView에서 `PriceComparison` 미마운트였고, 2026-07-21 재마운트로 로컬 수정 완료. 프로덕션 반영은 HEAD 재배포 후 확인. - -### Phase 4: 검증 배지 A/B (검증 가정 E) - -- 검증 배지 유무가 선택에 미치는 영향 A/B 테스트 -- AI 추정값으로 `verified`를 만들지 않는지 게이트 재확인 - -### Phase 5: 미확인 항목 해소 - -- Sony 한국 URL(`sony.co.kr`) 실제 동작 여부 확인 -- 스펙 수집 인프라(`scripts/collect-specs/`) 반자동 확장 점검 - -**kill-switch:** 가정 A·B·C 모두 통과 → 노트북·KR·제휴로 추진. A 실패 → Track/Alert 폐기·결정 전용 PIVOT. B 또는 C 실패 → 사업 재고(STOP). - ---- - -## 코딩 원칙 (반드시 준수) - -- **결과/추천 로직은 요청 없이 건드리지 않음.** `selectedOption`, `reasons`, `oneLineConclusion`, per-option 분석은 사용자가 프롬프트로 직접 작업. -- **스펙은 DB에서 꺼내지 않음.** 공식 페이지 AI 검증 또는 `lib/specs/dataset/` 수동 데이터에서만. Supabase는 계정·히스토리·가격추적·알림 전용. -- **검증 게이트 준수.** primary 스펙이 공식 소스(tier 1~2)로 뒷받침될 때만 `verified`. 뻥스펙·하드코딩 fallback 추천 금지. 없는 제품은 "찾을 수 없음"으로 떨어뜨림. -- 스키마·결과 포맷 변경 시 `lib/comparison-cache.ts`의 `CACHE_VERSION`을 올려 구버전 캐시 무효화 (현재 v9). -- `CRON_SECRET` 등 시크릿을 코드·문서에 하드코딩하지 않음. -- 변경 후 반드시 `npm test` (특히 레지스트리·데이터셋·파이프라인 변경 시) + `npx tsc --noEmit`. - ---- - -## 주요 파일 구조 - -``` -app/ - api/ - compare/ 비교 요청 (userContext 맞춤 재분석) - track/ 클릭·페이지뷰 트래킹 - price/ 실시간 가격 조회 - cron/ 가격 점검(알림) · 가격 스냅샷(이력 적재) - history/ 가격 이력 조회 - watches/ 관심 상품 관리 - push/ 웹 푸시 구독 - compare/[slug]/ SEO 정적 비교 페이지 - results/ 동적 결과 페이지 -components/ - results-view.tsx · context-card.tsx · timing-section.tsx - vs-input.tsx · watch-button.tsx -lib/ - decision-engine.ts 비교 파이프라인 오케스트레이터 - ai/ AI 프로바이더 추상화 + 프롬프트 - specs/dataset/ 수동 검증 스펙 124개 - pricing/ 가격 프로바이더 (naver · coupang · seed) - comparison-cache.ts 캐시 레이어 (v9) - affiliate.ts 제휴 링크 생성 (Amazon/Coupang/Naver) -scripts/collect-specs/ 반자동 스펙 수집 (danawa/gsmarena/kakaku) -``` - ---- - -## 명령어 - -```bash -npm run dev # 개발 서버 (webpack) -npm run dev:turbo # 개발 서버 (turbopack) -npm test # 테스트 -npm run build # 빌드 -npx tsc --noEmit # 타입 체크 - -vercel env pull .env.local # 환경변수 로컬 동기화 -supabase db push # 마이그레이션 적용 -npx web-push generate-vapid-keys # VAPID 키 생성 -openssl rand -base64 32 # CRON_SECRET 생성 -``` - -> 비용 원칙: **최저비용 최대수익.** 모든 레이어 무료 티어 기반, 수익 발생 시 유료 전환. 상세 개발 노트는 [DEV_NOTES.md](DEV_NOTES.md), 작업 규칙은 [CLAUDE.md](CLAUDE.md), 공개 소개는 [README.md](README.md) 참고. diff --git a/README.md b/README.md index 3d17ae7..cca48aa 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,123 @@ # Axis -> 사기 전에 결정해주고, 산 뒤 최적 타이밍까지 알려주는 전자제품 구매 결정 도구. +**전자제품, 사기 전에 결론을 내려주는 AI 구매 결정 도구** -![Deploy](https://img.shields.io/badge/deploy-Vercel-black) ![Stack](https://img.shields.io/badge/Next.js%2019-React%2019-white) ![DB](https://img.shields.io/badge/Supabase-PostgreSQL-green) ![AI](https://img.shields.io/badge/AI-Groq%20Llama%203.1-orange) +[![Live Demo](https://img.shields.io/badge/Live-axis--app--beta.vercel.app-3454e8?style=flat-square)](https://axis-app-beta.vercel.app) +[![Next.js](https://img.shields.io/badge/Next.js-16-black?style=flat-square)](https://nextjs.org/) +[![Supabase](https://img.shields.io/badge/Supabase-PostgreSQL-3FCF8E?style=flat-square)](https://supabase.com/) +[![AI](https://img.shields.io/badge/AI-Groq%20Llama-f55036?style=flat-square)](https://groq.com/) +[![CI](https://img.shields.io/badge/CI-GitHub%20Actions-2088FF?style=flat-square)](https://github.com/Min0504/Axis/actions) -**프로덕션:** https://axis-app-beta.vercel.app · **상태:** 베타 (사업성 검증 단계) +> “아이폰 16 vs 갤럭시 S25”처럼 입력하면, 공식 스펙을 검증한 뒤 **한 가지 추천**과 비교표를 만들고, 가격 추적으로 살 타이밍까지 알려줍니다. -"아이폰 16 vs 갤럭시 S25" 같은 자연어 쿼리를 입력하면 공식 스펙을 검증해 AI가 비교 테이블과 결론을 만들고, 가격을 추적해 최적 구매 타이밍을 알려준다. 한국(한국어) 우선 운영. - -**결정(Decide) → 추적(Track) → 알림(Alert)** +**Live:** https://axis-app-beta.vercel.app · KR 우선 · ko / en / ja --- -## 주요 기능 +## 문제 -| 기능 | 설명 | -|------|------| -| **AI 구매 결정** | 자연어 비교 쿼리 → 공식 스펙 검증 → 단일 결론 + 비교 테이블 + 선택지별 분석 | -| **맞춤 재분석** | 용도·예산·상황을 입력하면 그 가중치로 결론을 재계산 (캐시 우회) | -| **검증 배지** | primary 스펙이 공식 소스로 확인될 때만 `verified`, AI 추정은 noindex | -| **실시간 최저가** | 네이버 쇼핑 Open API 기반 현재가 + 일별 가격 이력 적재 | -| **구매 타이밍** | 가격 이력 기반 "지금 살까 / 기다릴까" 판정 + 다음 모델 출시 주기 힌트 | -| **가격 알림** | 관심 상품 등록 → 목표가·역대최저·급락 시 이메일/푸시 알림 (일일 크론) | -| **다국어** | KR/US/JP 코드 지원 · 운영은 KR 우선 | +전자제품 구매는 스펙·가격·타이밍이 한꺼번에 얽혀 있습니다. +ChatGPT류는 실시간 가격·추적이 없고, 다나와·Keepa류는 표와 차트만 있어 **무엇을 살지**를 대신 결정해 주지 않습니다. + +## 해결 -## 핵심 차별점 +Axis는 **Decide → Track → Alert** 한 줄로 이어집니다. -| | 약점 | Axis | -|---|---|---| -| ChatGPT / Perplexity | 실시간 가격 없음, 추적·알림 불가 | 라이브 가격 + 알림 | -| 다나와 / Keepa | 결정·가이드 없음 (표·차트만) | AI 결정 + 검증 스펙 | +| 단계 | 하는 일 | +|------|---------| +| **Decide** | 자연어 비교 → 공식 스펙 검증 → 단일 결론 + 표 + 선택지별 분석 | +| **Track** | 네이버 쇼핑 기반 현재가 · 일별 가격 이력 | +| **Alert** | 목표가·급락 시 이메일 / 웹 푸시 (크론) | --- -## 아키텍처 +## 직접 만든 핵심 -``` -┌─────────────────────────────────────────────────────┐ -│ 사용자 (브라우저) │ -│ VsInput → /api/compare → ResultsView + ContextCard │ -└──────────────────────┬──────────────────────────────┘ - │ - ┌────────────▼────────────┐ - │ decision-engine.ts │ 오케스트레이터 - │ ┌──────────────────┐ │ - │ │ Verification Gate│ │ dataset > scraped > AI - │ └────────┬─────────┘ │ - │ ┌────────▼──────────┐ │ - │ │ runAiDecision │ │ Groq (기본) / OpenAI / Gemini / Anthropic - │ └───────────────────┘ │ - └────────────┬────────────┘ - │ - ┌──────────────────┼──────────────────┐ - ▼ ▼ ▼ - Supabase 가격 프로바이더 캐시 레이어 - PostgreSQL naver / coupang comparison_cache -``` +- **Verification Gate** — primary 스펙이 제조사·공인 소스(tier 1–2)로 뒷받침될 때만 `verified`. AI 추정만으로는 verified를 만들지 않음 +- **스펙 파이프라인** — 수동 검증 데이터셋 + 공식 페이지 추출 + AI 보완을 계층적으로 합성 +- **로케일 락** — UI 언어(ko/en/ja)가 결과 문장·스펙 표기·캐시 키를 결정 +- **맞춤 재분석** — 용도·예산 컨텍스트로 캐시를 우회해 결론 재계산 +- **구매 타이밍** — 가격 이력으로 “지금 / 대기” 판정 + +--- ## 기술 스택 -| 레이어 | 기술 | -|--------|------| -| Framework | Next.js (App Router) / React 19 | -| 호스팅 | Vercel (Cron 포함) | -| DB / Auth | Supabase (PostgreSQL, RLS) | -| AI | Groq (Llama 3.1) — 프로바이더 추상화 | -| 가격 | 네이버 쇼핑 Open API | -| 이메일 | Resend | -| 푸시 | Web Push (VAPID, PWA) | +| 영역 | 선택 | +|------|------| +| App | Next.js 16 (App Router), React 19, TypeScript | +| Hosting | Vercel (Cron 포함) | +| Data / Auth | Supabase (PostgreSQL, RLS) | +| AI | Groq (Llama) — OpenAI / Gemini / Anthropic 폴백 추상화 | +| Price | 네이버 쇼핑 Open API | +| Notify | Resend, Web Push (VAPID) | +| Test / CI | Vitest, GitHub Actions | + +--- + +## 아키텍처 -> 모든 레이어 무료 티어 기반. 수익 발생 시 유료 전환. +```text +Browser + VsInput ──► POST /api/compare ──► ResultsView + │ + decision-engine + ┌────────┴────────┐ + │ Verification Gate│ dataset > scraped > AI + │ runAiDecision │ Groq / fallbacks + └────────┬────────┘ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + Supabase Price providers comparison_cache + (auth/hist) (naver / …) (locale-aware) +``` --- -## 시작하기 +## 빠른 시작 ```bash npm install -cp .env.example .env.local -npm run dev # Webpack -npm run dev:turbo # Turbopack (더 빠름) +cp .env.example .env.local # Supabase + GROQ_API_KEY 최소 설정 +npm run dev npm test ``` -### 필수 환경변수 - -| 변수 | 용도 | -|------|------| -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase 프로젝트 URL | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase 공개 키 | -| `SUPABASE_SERVICE_ROLE_KEY` | 서버 측 DB 접근 | -| `GROQ_API_KEY` | AI 결정 엔진 | +| 필수 환경변수 | 용도 | +|---------------|------| +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | 공개 키 | +| `SUPABASE_SERVICE_ROLE_KEY` | 서버 DB | +| `GROQ_API_KEY` | AI 결정 | -선택 환경변수(OpenAI/Gemini/Anthropic 폴백, 네이버/쿠팡 API, Resend, VAPID 등)는 `.env.example` 참고. +나머지는 `.env.example` 참고. DB 스키마는 `supabase/migrations/`. -### Supabase 설정 +--- -```bash -supabase db push +## 저장소 구조 + +```text +app/ App Router (pages + API) +components/ UI (결과·입력·가격·공유) +lib/ + ai/ 프롬프트 · 프로바이더 + specs/ 스키마 · 데이터셋 · 추출 · 검증 + pricing/ 가격 프로바이더 + decision-engine.ts +supabase/ migrations +tests/ Vitest ``` --- -## 프로젝트 구조 +## 검증 상태 -``` -app/ -├── api/compare/ 비교 요청 (맞춤 재분석 포함) -├── api/price/ 실시간 가격 조회 -├── api/cron/ 가격 점검 · 스냅샷 -├── api/watches/ 관심 상품 관리 -├── compare/[slug]/ SEO 정적 비교 페이지 -└── results/ 동적 결과 페이지 - -components/ results-view · context-card · timing-section · vs-input -lib/ decision-engine · ai/ · pricing/ · specs/dataset/ · affiliate.ts -``` - -## 검증 게이트 - -``` -verified = tier 1~2 (제조사·검증 리뷰)로 확인됨 → 색인 허용 -partial = 일부만 검증됨 → noindex -unverified = AI 추정값만 → noindex -``` +| 배지 | 의미 | 검색 색인 | +|------|------|-----------| +| `verified` | 공식 소스로 primary 스펙 확인 | 허용 | +| `partial` | 일부만 확인 | noindex | +| `unverified` | AI 추정 위주 | noindex | --- -개발 상세는 [DEV_NOTES.md](DEV_NOTES.md), 작업 규칙은 [CLAUDE.md](CLAUDE.md) 참고. +Built as a solo product experiment — feedback welcome via issues. diff --git a/app/api/admin/extract/route.ts b/app/api/admin/extract/route.ts index 8787e6a..d1454c5 100644 --- a/app/api/admin/extract/route.ts +++ b/app/api/admin/extract/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { isAdminRequest } from "@/lib/admin-auth"; import { isAiConfigured } from "@/lib/ai/decide"; import { getProductById, allVerifiedProducts } from "@/lib/specs/dataset"; import { discoverOfficialUrl } from "@/lib/specs/extract/discover"; @@ -15,16 +16,17 @@ import type { ProductSourceCandidate } from "@/lib/specs/types"; * they're committed to the verified store. * * Safety: - * - Gated behind AXIS_ADMIN=1 (off in production by default). + * - Gated behind AXIS_ADMIN=1 + AXIS_ADMIN_TOKEN (Bearer) in production. * - SSRF-safe: only fetches the `source` URL already stored in our catalog; * the caller supplies an `id`, never a URL. * - Returns { result: null } when no AI key is configured (honest no-op). * * Usage: GET /api/admin/extract?id=macbook-air-13-m3 * GET /api/admin/extract → lists available ids + * Header: Authorization: Bearer */ export async function GET(req: Request) { - if (process.env.AXIS_ADMIN !== "1") { + if (!isAdminRequest(req)) { return NextResponse.json({ error: "not found" }, { status: 404 }); } diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index 15c9d9f..f887592 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { buildDecision, buildQuery, parseOptions } from "@/lib/decision-engine"; import { createSupabaseRouteClient } from "@/lib/supabase-route"; import { ensureUserProfile } from "@/lib/users/ensure-profile"; -import { getClientIp, rateLimit } from "@/lib/rate-limit"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; import { COUNTRY_COOKIE, LOCALE_COOKIE, countryForLocale, isCountry, isLocale } from "@/lib/i18n"; import type { ComparisonResult } from "@/lib/types"; @@ -34,7 +34,7 @@ function collectOptions(body: Body): string[] { export async function POST(req: Request) { const ip = getClientIp(req); - const limit = rateLimit(`compare:${ip}`, RATE_LIMIT, RATE_WINDOW_MS); + const limit = await checkRateLimit(`compare:${ip}`, RATE_LIMIT, RATE_WINDOW_MS); if (!limit.allowed) { const retryAfter = Math.ceil((limit.resetAt - Date.now()) / 1000); return NextResponse.json( diff --git a/app/api/price/route.ts b/app/api/price/route.ts index 5e8d24d..248cda1 100644 --- a/app/api/price/route.ts +++ b/app/api/price/route.ts @@ -8,7 +8,7 @@ import { type Region } from "@/lib/pricing"; import { isLocale, type Locale } from "@/lib/i18n"; -import { getClientIp, rateLimit } from "@/lib/rate-limit"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; export type PriceApiResult = { productId: string; @@ -34,7 +34,7 @@ export type PriceApiResult = { */ export async function GET(req: Request) { const ip = getClientIp(req); - const limit = rateLimit(`price:${ip}`, 60, 60_000); + const limit = await checkRateLimit(`price:${ip}`, 60, 60_000); if (!limit.allowed) { const retryAfter = Math.ceil((limit.resetAt - Date.now()) / 1000); return NextResponse.json( diff --git a/app/api/share/guest/route.ts b/app/api/share/guest/route.ts index 9e789a7..7ed5993 100644 --- a/app/api/share/guest/route.ts +++ b/app/api/share/guest/route.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase-route"; -import { getClientIp, rateLimit } from "@/lib/rate-limit"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; import type { ComparisonResult } from "@/lib/types"; /** 22-char URL-safe token (~131 bits) — harder to enumerate than 10-char charset. */ @@ -9,6 +9,9 @@ function generateToken() { return randomBytes(16).toString("base64url").slice(0, 22); } +/** Hard cap on serialized result size to blunt guest-share abuse. */ +const MAX_RESULT_BYTES = 48_000; + /** * Guest share endpoint: accepts a result payload directly and stores it * as a public anonymous comparison so the link can be shared without login. @@ -16,7 +19,7 @@ function generateToken() { */ export async function POST(req: Request) { const ip = getClientIp(req); - const limit = rateLimit(`share-guest:${ip}`, 10, 60_000); + const limit = await checkRateLimit(`share-guest:${ip}`, 10, 60_000); if (!limit.allowed) { const retryAfter = Math.ceil((limit.resetAt - Date.now()) / 1000); return NextResponse.json( @@ -42,6 +45,15 @@ export async function POST(req: Request) { return NextResponse.json({ error: "결과 데이터가 없습니다." }, { status: 400 }); } + try { + const encoded = JSON.stringify(body.result); + if (encoded.length > MAX_RESULT_BYTES) { + return NextResponse.json({ error: "결과 데이터가 너무 큽니다." }, { status: 413 }); + } + } catch { + return NextResponse.json({ error: "잘못된 요청입니다." }, { status: 400 }); + } + const supabase = await createSupabaseRouteClient(req); if (!supabase) { return NextResponse.json({ error: "Supabase not configured" }, { status: 503 }); diff --git a/app/api/track/route.ts b/app/api/track/route.ts index 23fe910..9de31ba 100644 --- a/app/api/track/route.ts +++ b/app/api/track/route.ts @@ -1,12 +1,22 @@ import { NextResponse } from "next/server"; import { createServiceClientSafe } from "@/lib/supabase-server"; -import { getClientIp, rateLimit } from "@/lib/rate-limit"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; export const runtime = "nodejs"; +const EVENT_TYPES = new Set(["affiliate", "compare_view"]); +const REGIONS = new Set(["KR", "US", "JP"]); + +function clip(value: unknown, max: number): string | null { + if (value == null) return null; + const text = String(value).trim(); + if (!text) return null; + return text.slice(0, max); +} + export async function POST(req: Request) { const ip = getClientIp(req); - const limit = rateLimit(`track:${ip}`, 60, 60_000); + const limit = await checkRateLimit(`track:${ip}`, 60, 60_000); if (!limit.allowed) { const retryAfter = Math.ceil((limit.resetAt - Date.now()) / 1000); return NextResponse.json( @@ -17,27 +27,29 @@ export async function POST(req: Request) { try { const body = await req.json(); - const { event_type, product_id, slug, region, retailer, session_id } = body; - - if (!event_type || !["affiliate", "compare_view"].includes(event_type)) { + const eventType = clip(body?.event_type, 32); + if (!eventType || !EVENT_TYPES.has(eventType)) { return NextResponse.json({ error: "invalid_event" }, { status: 400 }); } + const regionRaw = clip(body?.region, 8)?.toUpperCase() ?? null; + const region = regionRaw && REGIONS.has(regionRaw) ? regionRaw : null; + const db = createServiceClientSafe(); if (!db) { return NextResponse.json({ ok: true }); // silently skip if no DB } - const referrer = req.headers.get("referer") ?? undefined; + const referrer = clip(req.headers.get("referer"), 500) ?? undefined; await db.from("click_events").insert({ - event_type, - product_id: product_id ?? null, - slug: slug ?? null, - region: region ?? null, - retailer: retailer ?? null, - session_id: session_id ?? null, - referrer: referrer ?? null, + event_type: eventType, + product_id: clip(body?.product_id, 120), + slug: clip(body?.slug, 160), + region, + retailer: clip(body?.retailer, 64), + session_id: clip(body?.session_id, 128), + referrer: referrer ?? null }); return NextResponse.json({ ok: true }); diff --git a/app/api/watches/route.ts b/app/api/watches/route.ts index adf36fa..58d5d68 100644 --- a/app/api/watches/route.ts +++ b/app/api/watches/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { upsertWatch, deleteWatch, listWatchesByEmail } from "@/lib/watch/db"; import { createSupabaseRouteClient } from "@/lib/supabase-route"; -import { getClientIp, rateLimit } from "@/lib/rate-limit"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; import type { Region } from "@/lib/pricing/types"; function isValidEmail(email: string): boolean { @@ -33,9 +33,9 @@ async function requireSessionEmail(req: Request): Promise< return { email }; } -function rateLimitOrReject(req: Request, action: string): NextResponse | null { +async function rateLimitOrReject(req: Request, action: string): Promise { const ip = getClientIp(req); - const limit = rateLimit(`watches:${action}:${ip}`, 30, 60_000); + const limit = await checkRateLimit(`watches:${action}:${ip}`, 30, 60_000); if (!limit.allowed) { const retryAfter = Math.ceil((limit.resetAt - Date.now()) / 1000); return NextResponse.json( @@ -51,7 +51,7 @@ function rateLimitOrReject(req: Request, action: string): NextResponse | null { * Returns the watch list for the authenticated user only. */ export async function GET(req: Request) { - const limited = rateLimitOrReject(req, "get"); + const limited = await rateLimitOrReject(req, "get"); if (limited) return limited; const auth = await requireSessionEmail(req); @@ -67,7 +67,7 @@ export async function GET(req: Request) { * Optional `email` in body is ignored (session email wins). */ export async function POST(req: Request) { - const limited = rateLimitOrReject(req, "post"); + const limited = await rateLimitOrReject(req, "post"); if (limited) return limited; const auth = await requireSessionEmail(req); @@ -106,7 +106,7 @@ export async function POST(req: Request) { * Body: { productId, region } */ export async function DELETE(req: Request) { - const limited = rateLimitOrReject(req, "delete"); + const limited = await rateLimitOrReject(req, "delete"); if (limited) return limited; const auth = await requireSessionEmail(req); diff --git a/app/compare/[slug]/page.tsx b/app/compare/[slug]/page.tsx index aa0ec49..5c5b804 100644 --- a/app/compare/[slug]/page.tsx +++ b/app/compare/[slug]/page.tsx @@ -9,6 +9,8 @@ import PageViewTracker from "@/components/page-view-tracker"; import type { ComparisonResult } from "@/lib/types"; import { getSiteUrl } from "@/lib/site-url"; import { isIndexable } from "@/lib/specs/source"; +import { getLocale } from "@/lib/i18n/server"; +import { getDictionary, type Locale } from "@/lib/i18n"; // Re-generate at most once per day. export const revalidate = 86400; @@ -31,7 +33,8 @@ export async function generateMetadata({ params }: Props): Promise { const title = `${def.title} — Axis의 선택은?`; const siteUrl = getSiteUrl(); - const result = await getOrGenerate(slug, def.options); + // SEO metadata stays Korean (KR index pages). + const result = await getOrGenerate(slug, def.options, "ko"); const indexable = result ? isIndexable(result.verification ?? "unverified") : false; return { @@ -49,10 +52,15 @@ export async function generateMetadata({ params }: Props): Promise { }; } -async function getOrGenerate(slug: string, options: string[]): Promise { +async function getOrGenerate( + slug: string, + options: string[], + locale: Locale +): Promise { const db = createServiceClient(); + const query = buildQuery(options); - // 1. Try cache + // 1. Try cache — only reuse when cached result language matches. const { data } = await db .from("seo_comparisons") .select("result") @@ -60,19 +68,20 @@ async function getOrGenerate(slug: string, options: string[]): Promise { if (error) console.error("[seo_comparisons.upsert]", error.message); }); @@ -86,7 +95,9 @@ export default async function ComparePage({ params }: Props) { const def = COMPARISONS.find((c) => c.slug === slug); if (!def) notFound(); - const result = await getOrGenerate(slug, def.options); + const locale = await getLocale(); + const t = getDictionary(locale); + const result = await getOrGenerate(slug, def.options, locale); if (!result) notFound(); const query = buildQuery(def.options); @@ -95,8 +106,11 @@ export default async function ComparePage({ params }: Props) { ).slice(0, 4); const siteUrl = getSiteUrl(); + const categoryLabel = + t.home.categoryLabels[def.category as keyof typeof t.home.categoryLabels] ?? + CATEGORY_LABELS[def.category]; - // JSON-LD structured data + // JSON-LD structured data (KR SEO) const jsonLd = { "@context": "https://schema.org", "@type": "Article", @@ -114,11 +128,10 @@ export default async function ComparePage({ params }: Props) { dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> - {/* Breadcrumb */}
axis › - {CATEGORY_LABELS[def.category]} + {categoryLabel} › {def.title}
@@ -127,16 +140,15 @@ export default async function ComparePage({ params }: Props) { - {/* Related comparisons */} {relatedComparisons.length > 0 && (
-

비슷한 비교

+

{t.home.compareTitle}

    {relatedComparisons.map((c) => (
  • @@ -151,13 +163,10 @@ export default async function ComparePage({ params }: Props) {
)} - {/* CTA */}
-

- 내 상황(예산·용도·기존 기기)을 더해서 맞춤 분석을 받고 싶다면? -

+

{t.home.compareIndex.ctaCopy}

- 내 상황 맞춤 비교 받기 → + {t.home.compareIndex.cta}
diff --git a/app/compare/page.tsx b/app/compare/page.tsx index 0aee1a5..661de5b 100644 --- a/app/compare/page.tsx +++ b/app/compare/page.tsx @@ -6,7 +6,8 @@ import { localizedComparisonTitle } from "@/lib/compare-pages/localized-title"; import { getDictionary } from "@/lib/i18n"; import { getLocale } from "@/lib/i18n/server"; -const CATEGORIES: ComparisonDef["category"][] = ["laptop", "smartphone", "earphones", "tablet"]; +/** Ops focus: KR + laptop first. Other category assets remain in COMPARISONS. */ +const FOCUS_CATEGORIES: ComparisonDef["category"][] = ["laptop"]; export async function generateMetadata(): Promise { const locale = await getLocale(); @@ -21,7 +22,7 @@ export default async function ComparePage() { const locale = await getLocale(); const t = getDictionary(locale); const byCategory = Object.fromEntries( - CATEGORIES.map((cat) => [cat, COMPARISONS.filter((c) => c.category === cat)]) + FOCUS_CATEGORIES.map((cat) => [cat, COMPARISONS.filter((c) => c.category === cat)]) ) as Record; return ( @@ -37,7 +38,7 @@ export default async function ComparePage() {

{t.home.compareIndex.sub}

- {CATEGORIES.map((cat) => ( + {FOCUS_CATEGORIES.map((cat) => (

{t.home.categoryLabels[cat]}

    diff --git a/app/globals.css b/app/globals.css index 6d6a907..48e0e52 100644 --- a/app/globals.css +++ b/app/globals.css @@ -367,4388 +367,7 @@ a { margin: 0; } -/* ===== Home: compact, input-first hero ===== */ -.hero { - text-align: center; - margin: 1.4rem 0 1.6rem; -} - -.hero.compact { - margin-top: 2rem; -} - -.badge { - display: inline-flex; - align-items: center; - gap: 0.45rem; - background: rgba(255, 255, 255, 0.72); - border: 1px solid var(--border); - color: var(--muted); - padding: 0.34rem 0.85rem 0.34rem 0.7rem; - border-radius: 9999px; - font-size: 0.8rem; - font-weight: 600; - box-shadow: var(--shadow-sm); -} - -.badge::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 9999px; - background: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 84, 232, 0.18); -} - -[data-theme="dark"] .badge { - background: rgba(255, 255, 255, 0.05); -} - -.hero h1 { - margin: 0.85rem 0 0.7rem; - font-size: clamp(1.85rem, 4.4vw, 2.9rem); - font-weight: 700; - line-height: 1.18; - letter-spacing: -0.025em; -} - -.hero h1 span { - color: var(--accent); -} - -.sub { - color: var(--muted); - font-size: 1.02rem; - max-width: 520px; - margin: 0 auto; - line-height: 1.65; - white-space: pre-line; -} - -.hero-proof { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 0.5rem; - margin-top: 1.2rem; -} - -.proof-pill { - display: inline-flex; - align-items: center; - gap: 0.3rem; - border: 1px solid rgba(52, 84, 232, 0.25); - border-radius: 9999px; - background: rgba(52, 84, 232, 0.08); - padding: 0.4rem 0.85rem; - color: var(--accent); - font-size: 0.82rem; - font-weight: 700; -} - -.proof-pill strong { - color: var(--accent); - font-size: 0.82rem; - font-weight: 700; -} - -[data-theme="dark"] .proof-pill { - background: rgba(91, 120, 245, 0.14); - border-color: rgba(91, 120, 245, 0.35); -} - -/* ===== Home sections ===== */ -.home-section { - margin-top: 2.6rem; -} - -.section-copy { - max-width: 640px; - margin-bottom: 1.1rem; -} - -.section-copy.center { - margin-left: auto; - margin-right: auto; - text-align: center; -} - -.section-kicker { - margin: 0 0 0.4rem; - color: var(--accent); - font-size: 0.74rem; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.section-copy h2 { - margin: 0; - font-size: clamp(1.35rem, 2.6vw, 1.75rem); - font-weight: 800; - line-height: 1.25; - letter-spacing: -0.02em; -} - -.section-copy p:not(.section-kicker) { - margin: 0.55rem 0 0; - color: var(--muted); - font-size: 0.95rem; - line-height: 1.6; -} - -/* ===== Example chips (one-tap prefill) ===== */ -.example-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.8rem; -} - -.example-card { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.3rem; - width: 100%; - text-align: left; - font: inherit; - cursor: pointer; - border: 1px solid var(--border); - border-radius: 18px; - background: rgba(255, 255, 255, 0.74); - box-shadow: var(--shadow-sm); - padding: 1rem 1.1rem 0.9rem; - transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; -} - -[data-theme="dark"] .example-card { - background: rgba(255, 255, 255, 0.04); -} - -.example-card:hover { - transform: translateY(-2px); - border-color: var(--accent); - box-shadow: 0 14px 30px rgba(52, 84, 232, 0.16); -} - -.example-cat { - color: var(--accent); - font-size: 0.72rem; - font-weight: 800; - letter-spacing: 0.02em; -} - -.example-query { - font-size: 1.02rem; - font-weight: 700; - line-height: 1.3; - letter-spacing: -0.01em; -} - -.example-note { - color: var(--muted); - font-size: 0.86rem; - line-height: 1.5; -} - -.example-go { - margin-top: 0.55rem; - color: var(--muted); - font-size: 0.82rem; - font-weight: 600; - transition: color 0.16s ease, transform 0.16s ease; -} - -.example-card:hover .example-go { - color: var(--accent); - transform: translateX(2px); -} - -/* ===== Method band ===== */ -/* ── popular comparison grid ── */ -.home-compare-grid { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 0.65rem; -} - -.home-compare-card { - border: 1px solid var(--border); - border-radius: 12px; - background: var(--card); - transition: box-shadow .15s; -} - -.home-compare-card:hover { - box-shadow: 0 2px 12px rgba(0,0,0,.08); -} - -[data-theme="dark"] .home-compare-card:hover { - box-shadow: 0 2px 12px rgba(0,0,0,.3); -} - -.home-compare-link { - display: flex; - flex-direction: column; - gap: 0.25rem; - padding: 0.9rem 1rem; - text-decoration: none; - color: var(--fg); -} - -.home-compare-cat { - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: .06em; - color: var(--muted); -} - -.compare-data-note { - margin: 0.4rem 0 0; - font-size: 0.85rem; - color: var(--muted); -} - -.home-compare-rank { - font-size: 0.7rem; - font-weight: 800; - color: var(--accent); - opacity: 0.7; -} - -.home-compare-title { - font-size: 0.88rem; - font-weight: 700; - line-height: 1.35; -} - -.home-compare-arrow { - font-size: 0.8rem; - color: var(--muted); - margin-top: 0.1rem; -} - -.home-compare-more { - margin-top: 0.9rem; - text-align: center; - font-size: 0.85rem; -} - -.home-compare-more a { - color: var(--muted); - text-decoration: none; -} - -.home-compare-more a:hover { - color: var(--fg); -} - -/* ── end popular comparison grid ── */ - -.home-method { - display: grid; - grid-template-columns: minmax(220px, 0.8fr) 1.2fr; - gap: 1.4rem; - align-items: start; -} - -.method-grid { - display: grid; - gap: 0.7rem; -} - -.method-item { - display: grid; - grid-template-columns: auto 1fr; - column-gap: 0.85rem; - row-gap: 0.25rem; - padding: 0.95rem 1.1rem; - border: 1px solid var(--border); - border-radius: 16px; - background: rgba(255, 255, 255, 0.74); - box-shadow: var(--shadow-sm); -} - -[data-theme="dark"] .method-item { - background: rgba(255, 255, 255, 0.04); -} - -.method-item span { - grid-row: span 2; - align-self: center; - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border-radius: 9px; - background: rgba(52, 84, 232, 0.1); - color: var(--accent); - font-size: 0.82rem; - font-weight: 800; -} - -[data-theme="dark"] .method-item span { - background: rgba(91, 120, 245, 0.16); -} - -.method-item h3 { - margin: 0; - font-size: 0.98rem; -} - -.method-item p { - margin: 0; - color: var(--muted); - line-height: 1.55; - font-size: 0.9rem; -} - -.method-em { - font-weight: 600; - color: var(--fg); -} - -/* ── Feature grid (replaces method 01/02/03 cards) ── */ -.feature-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.55rem; - align-content: start; -} - -.feature-item { - display: flex; - flex-direction: column; - gap: 0.18rem; - padding: 0.75rem 0.9rem; - border: 1px solid var(--border); - border-radius: 12px; - background: rgba(52, 84, 232, 0.04); -} - -[data-theme="dark"] .feature-item { - background: rgba(91, 120, 245, 0.07); - border-color: var(--border); -} - -.feature-item strong { - font-size: 0.86rem; - font-weight: 700; - color: var(--fg); - line-height: 1.3; -} - -.feature-item span { - font-size: 0.78rem; - color: var(--muted); - line-height: 1.4; -} - -/* ── Rank list (popular comparisons) ── */ -.home-rank-wrap { - position: relative; - max-height: 305px; - overflow: hidden; -} - -.home-rank-list { - list-style: none; - margin: 0; - padding: 0; -} - -.home-rank-item { - border-bottom: 1px solid var(--border-light); -} - -.home-rank-item:first-child { - border-top: 1px solid var(--border-light); -} - -.home-rank-link { - display: flex; - align-items: center; - gap: 0.9rem; - padding: 0.78rem 0.4rem; - color: var(--fg); - text-decoration: none; - background: none; - border: none; - width: 100%; - cursor: pointer; - font-family: inherit; - text-align: left; - transition: color 0.15s ease; -} - -.home-rank-link:hover { - color: var(--accent); -} - -.home-rank-link:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.home-rank-link.home-rank-loading { - color: var(--accent); -} - -.home-rank-num { - font-size: 0.78rem; - font-weight: 800; - color: var(--accent); - min-width: 18px; - text-align: right; - opacity: 0.8; - flex-shrink: 0; -} - -.home-rank-text { - font-size: 0.93rem; - font-weight: 600; - line-height: 1.35; - flex: 1; -} - -.home-rank-arrow { - font-size: 0.8rem; - color: var(--muted); - flex-shrink: 0; - transition: transform 0.15s ease; -} - -.home-rank-link:hover .home-rank-arrow { - transform: translateX(3px); - color: var(--accent); -} - -@keyframes rankSpin { - to { transform: rotate(360deg); } -} - -.home-rank-spinner { - display: inline-block; - width: 14px; - height: 14px; - border: 2px solid var(--accent); - border-top-color: transparent; - border-radius: 50%; - animation: rankSpin 0.7s linear infinite; - flex-shrink: 0; -} - -.home-rank-fade { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 88px; - background: linear-gradient(to bottom, transparent, var(--bg) 90%); - pointer-events: none; -} - -.vs-shell { - margin-top: 0.5rem; -} - -.vs-grid { - display: grid; - grid-template-columns: 1fr auto 1fr; - gap: 1rem; - align-items: stretch; -} - -.vs-divider { - align-self: center; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted); - opacity: 0.5; - padding: 0 0.25rem; - user-select: none; -} - -.vs-card { - position: relative; - display: flex; - flex-direction: column; - gap: 0.55rem; - min-height: 180px; - padding: 1.2rem; - border: 1px solid var(--border); - border-radius: 24px; - background: var(--card); - box-shadow: var(--shadow-md); - overflow: hidden; - transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; -} - -.vs-card:focus-within { - border-color: var(--accent); - box-shadow: 0 18px 44px rgba(52, 84, 232, 0.18); - transform: translateY(-2px); -} - -.vs-card input { - border: none; - outline: none; - font-size: 1.35rem; - font-weight: 600; - background: transparent; - padding: 0; - z-index: 1; -} - -.vs-card input::placeholder { - color: #c7cdd8; - font-weight: 500; -} - -.vs-tag { - width: 28px; - height: 28px; - border-radius: 9999px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.85rem; - font-weight: 700; - color: white; - z-index: 1; -} - -.vs-card-a .vs-tag { - background: #3454e8; -} - -.vs-card-b .vs-tag { - background: #7c5cff; -} - -.vs-label { - color: var(--muted); - font-size: 0.9rem; - z-index: 1; -} - -.vs-watermark { - position: absolute; - right: 0.2rem; - bottom: -1.4rem; - font-size: 8rem; - font-weight: 800; - line-height: 1; - opacity: 0.08; - pointer-events: none; -} - -.vs-card-a .vs-watermark { - color: #3454e8; -} - -.vs-card-b .vs-watermark { - color: #7c5cff; -} - -@media (max-width: 560px) { - .vs-grid { - grid-template-columns: 1fr; - } - - .vs-divider { - text-align: center; - padding: 0.2rem 0; - } -} - -.btn-primary { - width: 100%; - margin-top: 1.2rem; - border: none; - border-radius: 14px; - padding: 0.9rem 1rem; - color: white; - font-size: 1rem; - font-weight: 700; - background: linear-gradient(135deg, #1a2238 0%, #2d3f86 55%, #4b54c9 100%); - cursor: pointer; - transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease; - box-shadow: 0 8px 20px rgba(45, 63, 134, 0.22); -} - -.btn-primary:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 14px 28px rgba(45, 63, 134, 0.3); -} - -.btn-primary:active:not(:disabled) { - transform: translateY(0); -} - -.btn-primary.large { - padding: 1.05rem 1rem; - font-size: 1.05rem; -} - -.btn-primary:disabled { - opacity: 0.45; - cursor: not-allowed; - box-shadow: none; -} - -.btn-outline { - width: 100%; - margin-top: 0.8rem; - border: 1px solid var(--border); - border-radius: 14px; - padding: 0.85rem 1rem; - text-align: center; - background: white; - cursor: pointer; - font-weight: 600; - transition: border-color 0.16s ease, color 0.16s ease, transform 0.16s ease; -} - -.btn-outline:hover { - border-color: var(--accent); - color: var(--accent); - transform: translateY(-1px); -} - -.btn-primary.block { - display: block; - text-align: center; -} - -.history-card, -.result-card, -.detail-card { - border: 1px solid var(--border); - border-radius: 20px; - background: var(--card); - box-shadow: var(--shadow-sm); -} - -[data-theme="dark"] .history-card, -[data-theme="dark"] .result-card, -[data-theme="dark"] .detail-card { - background: var(--card); -} - -.history-card { - margin-top: 1.5rem; - padding: 0; - overflow: hidden; -} - -.result-card, -.detail-card { - margin-top: 1rem; - padding: 1.4rem 1.5rem; -} - -.detail-card h2 { - margin: 0 0 1.1rem; - font-size: 0.82rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--muted); -} - -/* ════════════════════════════════════════ - Verdict hero — 결과 첫 블록 (프리미엄) - ════════════════════════════════════════ */ -.verdict-hero { - position: relative; - overflow: hidden; - margin-top: 0; - padding: 1.8rem 1.9rem 1.7rem; - border: 1px solid var(--border); - border-radius: 22px; - background: var(--card); - box-shadow: var(--shadow-md); -} - -/* 상단 accent 라인 */ -.verdict-hero::before { - content: ""; - position: absolute; - inset: 0 0 auto 0; - height: 3px; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); -} - -/* AXIS 선택 배지 */ -.vh-badge { - display: inline-flex; - align-items: center; - gap: 0.45rem; - padding: 0.34rem 0.75rem 0.34rem 0.6rem; - border-radius: 9999px; - background: color-mix(in srgb, var(--accent) 10%, transparent); - color: var(--accent); - font-size: 0.72rem; - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.vh-badge-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); -} - -.vh-title { - margin: 0.85rem 0 0.5rem; - font-size: clamp(1.7rem, 5vw, 2.55rem); - font-weight: 900; - line-height: 1.12; - letter-spacing: -0.035em; - color: var(--text); - word-break: break-word; - overflow-wrap: break-word; -} - -.vh-conclusion { - margin: 0; - max-width: 34rem; - color: var(--muted); - font-size: 1.02rem; - line-height: 1.62; - word-break: break-word; - overflow-wrap: break-word; -} - -/* 승부 막대 영역 */ -.vh-matchup { - margin-top: 1.6rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border-light); - display: flex; - flex-direction: column; - gap: 1rem; -} - -.vh-row { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.vh-row-head { - display: flex; - align-items: center; - gap: 0.55rem; - min-width: 0; -} - -.vh-row-name { - font-size: 0.92rem; - font-weight: 600; - color: var(--muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.vh-row-win .vh-row-name { - color: var(--text); - font-weight: 800; -} - -.vh-row-tag { - flex-shrink: 0; - padding: 0.12rem 0.5rem; - border-radius: 9999px; - background: var(--accent); - color: #fff; - font-size: 0.64rem; - font-weight: 800; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.vh-bar-track { - height: 8px; - border-radius: 9999px; - background: var(--border-light); - overflow: hidden; -} - -.vh-bar-fill { - height: 100%; - border-radius: 9999px; - background: var(--border); - transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1); -} - -.vh-row-win .vh-bar-track { - background: color-mix(in srgb, var(--accent) 14%, transparent); -} - -.vh-row-win .vh-bar-fill { - background: linear-gradient(90deg, var(--accent), var(--accent-2)); -} - -.vh-bar-note { - margin: 0.1rem 0 0; - font-size: 0.72rem; - color: var(--muted); - opacity: 0.6; - line-height: 1.4; -} - -/* result-card / result-conclusion — admin hero 등에서 재사용되는 기본형 */ -.result-card { - padding: 1.7rem 1.8rem; - text-align: center; -} - -.result-conclusion { - margin: 0.5rem auto 0; - max-width: 30rem; - color: var(--muted); - font-size: 1rem; - line-height: 1.62; -} - -.detail-card ul { - margin: 0; - padding-left: 1.1rem; -} - -/* Reason list */ -.reason-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0; -} - -.reason-list li { - display: flex; - align-items: flex-start; - gap: 0.75rem; - line-height: 1.6; - font-size: 0.95rem; - padding: 0.75rem 0; - border-bottom: 1px solid var(--border-light); -} - -.reason-list li:last-child { border-bottom: none; } - -.reason-mark { - flex-shrink: 0; - width: 20px; - height: 20px; - margin-top: 0.2rem; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.58rem; - font-weight: 900; - color: white; - background: linear-gradient(135deg, #3454e8 0%, #7c5cff 100%); - box-shadow: 0 2px 6px rgba(52, 84, 232, 0.3); -} - - -/* ---- spec section ---- */ -.spec-section { - padding-bottom: 0; -} - -.spec-source-note { - margin: 0 0 0.9rem; - color: var(--muted); - font-size: 0.82rem; - font-weight: 500; -} - -/* Scroll wrapper for horizontal overflow — extends to card edges */ -.spec-table-scroll { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - margin: 0 -1.5rem; - padding: 0 1.5rem 1.4rem; -} - -/* Official link icon inside column header */ -.cmp-head-name { - display: flex; - align-items: center; - gap: 0.35rem; -} - -.cmp-head-link { - font-size: 0.75rem; - color: var(--muted); - opacity: 0.7; - flex-shrink: 0; - transition: opacity 0.15s; -} - -.cmp-head-link:hover { - opacity: 1; - color: var(--accent); -} - -.cmp-source-chip { - display: inline-flex; - align-items: center; - gap: 0.18rem; - max-width: 9.5rem; - padding: 0.16rem 0.45rem; - border-radius: 9999px; - border: 1px solid rgba(52, 84, 232, 0.16); - background: rgba(52, 84, 232, 0.08); - color: var(--accent); - font-size: 0.66rem; - font-weight: 800; - line-height: 1.2; - white-space: nowrap; -} - -.cmp-source-chip.source-authorized_importer { - border-color: rgba(16, 185, 129, 0.2); - background: rgba(16, 185, 129, 0.09); - color: #059669; -} - -.cmp-source-chip:hover { - border-color: currentColor; -} - -.admin-hero h1 { - font-size: clamp(2rem, 4vw, 3rem); -} - -.admin-source-card { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.admin-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) 8rem; - gap: 0.8rem; -} - -.admin-field { - display: flex; - flex-direction: column; - gap: 0.45rem; - font-size: 0.84rem; - font-weight: 700; - color: var(--text); -} - -.admin-field input, -.admin-field select, -.admin-field textarea { - width: 100%; - border: 1.5px solid var(--border); - border-radius: 12px; - background: var(--bg); - color: var(--text); - font: inherit; - box-sizing: border-box; -} - -.admin-field input, -.admin-field select { - min-height: 3rem; - padding: 0 0.9rem; -} - -.admin-field textarea { - min-height: 22rem; - padding: 0.9rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - font-size: 0.86rem; - line-height: 1.55; - resize: vertical; -} - -.admin-field input:focus, -.admin-field select:focus, -.admin-field textarea:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--ring); -} - -.admin-actions { - display: grid; - grid-template-columns: 1fr auto; - gap: 0.8rem; - align-items: center; -} - -.admin-actions .btn-primary, -.admin-actions .btn-outline { - margin-top: 0; -} - -.admin-validity { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 2.5rem; - border-radius: 9999px; - padding: 0 0.85rem; - font-size: 0.84rem; - font-weight: 800; - white-space: nowrap; -} - -.admin-validity.valid { - color: #059669; - background: rgba(16, 185, 129, 0.09); -} - -.admin-validity.invalid { - color: #dc2626; - background: rgba(220, 38, 38, 0.08); -} - -.admin-source-summary { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; - color: var(--muted); - font-size: 0.88rem; -} - -.admin-source-summary span, -.admin-source-summary a { - display: inline-flex; - align-items: center; - border: 1px solid var(--border); - border-radius: 9999px; - padding: 0.34rem 0.7rem; - background: rgba(52, 84, 232, 0.06); - font-weight: 700; -} - -.admin-source-summary a { - color: var(--accent); -} - -.admin-message { - margin: 0; - color: var(--muted); - font-size: 0.92rem; - line-height: 1.5; -} - -/* Empty spec state */ -.spec-empty { - padding: 1.2rem 0 1.4rem; - text-align: center; -} - -.spec-empty p { - margin: 0 0 0.9rem; - color: var(--muted); - font-size: 0.92rem; - line-height: 1.55; -} - -.spec-empty-links { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; - justify-content: center; -} - -.spec-empty-links a { - display: inline-flex; - align-items: center; - padding: 0.4rem 0.85rem; - border-radius: 9999px; - border: 1px solid var(--border); - font-size: 0.85rem; - font-weight: 600; - color: var(--text); - transition: border-color 0.15s, color 0.15s; -} - -.spec-empty-links a:hover { - border-color: var(--accent); - color: var(--accent); -} - -.hint { - margin: 0.8rem 0 0; - color: var(--muted); - font-size: 0.9rem; - text-align: center; -} - -.hint.error { - color: #dc2626; -} - -.guest { - display: inline-block; -} - -/* ---- back button ---- */ -.btn-back { - display: inline-flex; - align-items: center; - gap: 0.25rem; - font-size: 0.92rem; - font-weight: 500; - color: var(--muted); - margin-bottom: 1.2rem; - transition: color 0.16s ease; -} - -.btn-back:hover { - color: var(--text); -} - -/* ---- settings bar ---- */ -.settings-bar { - display: flex; - align-items: center; - gap: 0.5rem; -} - -/* Language dropdown */ -.lang-dropdown { - position: relative; -} - -.lang-trigger { - display: flex; - align-items: center; - gap: 0.3rem; - border: 1px solid var(--border); - border-radius: 10px; - padding: 0.38rem 0.7rem; - background: white; - font-size: 0.82rem; - font-weight: 600; - color: var(--text); - cursor: pointer; - transition: border-color 0.14s ease, color 0.14s ease; - white-space: nowrap; -} - -.lang-trigger:hover { - border-color: var(--accent); - color: var(--accent); -} - -.lang-globe { - font-size: 0.9rem; - opacity: 0.6; -} - -.lang-caret { - font-size: 0.55rem; - color: var(--muted); - margin-left: 0.05rem; -} - -.lang-menu { - position: absolute; - right: 0; - top: calc(100% + 0.4rem); - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; - box-shadow: var(--shadow-md); - padding: 0.3rem; - min-width: 110px; - z-index: 30; -} - -.lang-option { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - text-align: left; - border: none; - background: transparent; - padding: 0.5rem 0.7rem; - border-radius: 8px; - font-size: 0.85rem; - font-weight: 500; - color: var(--text); - cursor: pointer; - transition: background 0.12s ease; -} - -.lang-option:hover { - background: var(--bg); -} - -.lang-option.active { - font-weight: 700; - color: var(--accent); -} - -.lang-check { - font-size: 0.72rem; - font-weight: 800; - color: var(--accent); -} - -[data-theme="dark"] .lang-trigger { - background: var(--card); -} - -.theme-toggle { - width: 34px; - height: 34px; - border: 1px solid var(--border); - border-radius: 10px; - background: white; - font-size: 1rem; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - transition: border-color 0.14s ease; -} - -[data-theme="dark"] .theme-toggle { - background: var(--card); - color: var(--text); -} - -.theme-toggle:hover { - border-color: var(--accent); -} - -/* ---- user dropdown menu ---- */ -.user-nav { - position: relative; -} - -.user-trigger { - display: flex; - align-items: center; - gap: 0.5rem; - border: 1px solid var(--border); - border-radius: 9999px; - padding: 0.35rem 0.7rem 0.35rem 0.4rem; - background: white; - cursor: pointer; -} - -.user-avatar { - width: 28px; - height: 28px; - border-radius: 9999px; - display: inline-flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, #3454e8, #7c5cff); - color: white; - font-size: 0.85rem; - font-weight: 700; - flex-shrink: 0; -} - -.user-avatar.lg { - width: 40px; - height: 40px; - font-size: 1.1rem; -} - -.user-caret { - color: var(--muted); - font-size: 0.7rem; -} - -.user-dropdown { - position: absolute; - right: 0; - top: calc(100% + 0.5rem); - width: 250px; - background: white; - border: 1px solid var(--border); - border-radius: 16px; - box-shadow: 0 16px 40px rgba(17, 24, 39, 0.12); - padding: 0.5rem; - z-index: 20; -} - -.user-dropdown-head { - display: flex; - align-items: center; - gap: 0.7rem; - padding: 0.6rem 0.6rem 0.8rem; - border-bottom: 1px solid var(--border); -} - -.user-dropdown-id { - display: flex; - flex-direction: column; - min-width: 0; -} - -.user-dropdown-id strong { - font-size: 0.9rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.user-dropdown-id span { - color: var(--muted); - font-size: 0.78rem; -} - -.user-dropdown-list { - display: flex; - flex-direction: column; - padding: 0.4rem 0; -} - -.user-dropdown-list a { - padding: 0.6rem 0.6rem; - border-radius: 10px; - font-size: 0.92rem; -} - -.user-dropdown-list a:hover { - background: var(--bg); -} - -.user-dropdown-signout { - width: 100%; - text-align: left; - border: none; - border-top: 1px solid var(--border); - background: transparent; - color: #dc2626; - font-size: 0.92rem; - padding: 0.7rem 0.6rem 0.5rem; - cursor: pointer; -} - -/* ---- account page ---- */ -.account-card { - display: flex; - flex-direction: column; - gap: 0; - padding: 0.4rem 1.2rem; -} - -.account-row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - padding: 0.9rem 0; - border-bottom: 1px solid var(--border); -} - -.account-row:last-child { - border-bottom: none; -} - -.account-key { - color: var(--muted); - font-size: 0.9rem; -} - -.account-val { - font-weight: 600; - text-align: right; -} - -.hint-inline { - color: var(--muted); - font-weight: 400; - font-size: 0.85rem; -} - -.plan-badge { - display: inline-block; - padding: 0.2rem 0.6rem; - border-radius: 9999px; - font-size: 0.8rem; - font-weight: 700; -} - -.plan-badge.free { - background: var(--bg); - color: var(--muted); -} - -.plan-badge.pro { - background: linear-gradient(135deg, #3454e8, #7c5cff); - color: white; -} - -.upsell { - text-align: center; -} - -.account-actions-row { - margin-top: 1.2rem; -} - -/* ---- membership / pricing ---- */ -.membership .feature-table, -.membership > .hint { - max-width: 720px; - margin-left: auto; - margin-right: auto; -} - -.plan-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - margin-top: 1rem; -} - -.plan-grid.three { - grid-template-columns: repeat(3, 1fr); - align-items: stretch; -} - -.plan-grid.three .plan-card { - display: flex; - flex-direction: column; -} - -.plan-grid.three .plan-points { - flex: 1; -} - -@media (max-width: 760px) { - .plan-grid, - .plan-grid.three { - grid-template-columns: 1fr; - } -} - -.plan-badge.plus { - background: rgba(52, 84, 232, 0.12); - color: var(--accent); -} - -.plan-card { - position: relative; - border: 1px solid var(--border); - border-radius: 20px; - background: white; - padding: 1.4rem; - box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06); -} - -.plan-card.featured { - border-color: #3454e8; - box-shadow: 0 16px 40px rgba(52, 84, 232, 0.18); -} - -.plan-ribbon { - position: absolute; - top: -0.7rem; - right: 1.2rem; - background: linear-gradient(135deg, #3454e8, #7c5cff); - color: white; - font-size: 0.72rem; - font-weight: 700; - padding: 0.25rem 0.7rem; - border-radius: 9999px; -} - -.plan-head h2 { - margin: 0; - font-size: 1.3rem; -} - -.plan-price { - margin: 0.5rem 0 0.2rem; - font-size: 2rem; - font-weight: 800; -} - -.plan-price span { - font-size: 0.95rem; - font-weight: 500; - color: var(--muted); -} - -.plan-tagline { - margin: 0 0 1rem; - color: var(--muted); - font-size: 0.9rem; -} - -.plan-points { - list-style: none; - padding: 0; - margin: 0 0 1.2rem; - display: flex; - flex-direction: column; - gap: 0.55rem; -} - -.plan-points li { - position: relative; - padding-left: 1.4rem; - font-size: 0.92rem; -} - -.plan-points li::before { - content: "✓"; - position: absolute; - left: 0; - color: #3454e8; - font-weight: 700; -} - -.plan-current { - text-align: center; - color: var(--muted); - font-size: 0.9rem; - margin: 0; -} - -.feature-table { - margin-top: 1.2rem; -} - -.feat-grid { - display: flex; - flex-direction: column; -} - -.feat-row { - display: grid; - grid-template-columns: 2fr 1fr 1fr; - align-items: center; - gap: 0.6rem; - padding: 0.7rem 0; - border-bottom: 1px solid var(--border); - font-size: 0.92rem; -} - -.feat-grid.three .feat-row { - grid-template-columns: 1.6fr 1fr 1fr 1fr; -} - -.feat-row:last-child { - border-bottom: none; -} - -.feat-row > span:not(:first-child) { - text-align: center; -} - -.feat-header { - font-weight: 700; - color: var(--muted); -} - -.feat-yes { - color: #3454e8; - font-weight: 700; -} - -.feat-no { - color: #c7cdd8; -} - -/* ════════════════════════════════════════ - Auth page — redesigned - ════════════════════════════════════════ */ - -.auth-page { - min-height: 100svh; - display: flex; - align-items: center; - justify-content: center; - padding: 2rem 1.5rem; - background: var(--bg); -} - -.auth-wrap { - position: relative; - z-index: 1; - width: 100%; - max-width: 360px; - display: flex; - flex-direction: column; - align-items: center; - gap: 2rem; -} - -/* Brand */ -.auth-brand { - text-align: center; -} - -.auth-logo { - display: inline-flex; - align-items: center; - gap: 0.5rem; - font-size: 1.95rem; - font-weight: 800; - color: var(--text); - letter-spacing: -0.04em; - text-decoration: none; -} - -.auth-logo-mark { - display: flex; - align-items: center; - color: var(--accent); - flex-shrink: 0; -} - -.auth-tagline { - margin: 0.55rem 0 0; - font-size: 1rem; - font-weight: 600; - color: var(--text); - letter-spacing: -0.01em; -} - -/* Subtle dot-grid background */ -.auth-page::before { - content: ""; - position: fixed; - inset: 0; - background-image: radial-gradient(var(--border) 1px, transparent 1px); - background-size: 28px 28px; - opacity: 0.45; - pointer-events: none; - z-index: 0; -} - -/* Form wrapper — open, no card border */ -.auth-form-wrap { - width: 100%; - display: flex; - flex-direction: column; - gap: 0; - background: var(--card); - border: 1px solid var(--border); - border-radius: 20px; - padding: 2rem 1.8rem; - box-shadow: 0 4px 24px rgba(0,0,0,0.07), 0 1px 4px rgba(0,0,0,0.04); -} - -[data-theme="dark"] .auth-form-wrap { - box-shadow: 0 4px 24px rgba(0,0,0,0.3); -} - -/* Fields */ -.auth-fields { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.auth-field { - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.auth-field label { - font-size: 0.84rem; - font-weight: 600; - color: var(--text); -} - -.auth-field-row { - display: flex; - align-items: center; - justify-content: space-between; -} - -.auth-field input { - width: 100%; - padding: 0.78rem 0.95rem; - border: 1.5px solid var(--border); - border-radius: 10px; - background: var(--bg); - color: var(--text); - font-size: 0.96rem; - font-family: inherit; - transition: border-color 0.15s, box-shadow 0.15s; - box-sizing: border-box; -} - -.auth-field input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--ring); -} - -[data-theme="dark"] .auth-field input { - background: rgba(255,255,255,0.05); - border-color: rgba(255,255,255,0.12); - color: var(--text); -} - -/* Error */ -.auth-error { - margin: 0; - padding: 0.6rem 0.8rem; - background: rgba(220, 38, 38, 0.06); - border: 1px solid rgba(220, 38, 38, 0.2); - border-radius: 8px; - color: #dc2626; - font-size: 0.84rem; - line-height: 1.45; -} - -/* Forgot password — inline next to label */ -.auth-forgot { - background: none; - border: none; - color: var(--muted); - font-size: 0.82rem; - cursor: pointer; - padding: 0; - transition: color 0.15s; -} - -.auth-forgot:hover { color: var(--accent); } - -/* Primary CTA */ -.btn-auth-primary { - margin-top: 0.5rem; - width: 100%; - padding: 0.82rem 1rem; - background: var(--accent); - border: none; - border-radius: 11px; - color: white; - font-size: 0.97rem; - font-weight: 700; - cursor: pointer; - transition: opacity 0.15s, box-shadow 0.15s; - box-shadow: 0 2px 10px rgba(52, 84, 232, 0.3); -} - -.btn-auth-primary:hover:not(:disabled) { - opacity: 0.92; - box-shadow: 0 4px 16px rgba(52, 84, 232, 0.4); -} - -.btn-auth-primary:disabled { - opacity: 0.45; - cursor: default; - box-shadow: none; -} - -/* Mode switch link */ -.auth-switch { - margin: 1rem 0 0; - text-align: center; - font-size: 0.86rem; - color: var(--muted); -} - -.auth-switch button { - background: none; - border: none; - color: var(--accent); - font-size: 0.86rem; - font-weight: 600; - cursor: pointer; - padding: 0; -} - -.auth-switch button:hover { text-decoration: underline; } - -/* Separator */ -.auth-sep { - display: flex; - align-items: center; - gap: 0.75rem; - margin: 1.4rem 0 1rem; - color: var(--muted); - font-size: 0.78rem; - letter-spacing: 0.04em; -} - -.auth-sep::before, -.auth-sep::after { - content: ""; - flex: 1; - height: 1px; - background: var(--border); -} - -/* Google button — secondary style */ -.btn-auth-google { - display: flex; - align-items: center; - justify-content: center; - gap: 0.6rem; - width: 100%; - padding: 0.72rem 1rem; - background: transparent; - border: 1.5px solid var(--border); - border-radius: 11px; - color: var(--text); - font-size: 0.9rem; - font-weight: 600; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} - -.btn-auth-google:hover:not(:disabled) { - background: var(--bg-subtle); - border-color: var(--accent); -} - -.btn-auth-google:disabled { opacity: 0.45; cursor: default; } - -[data-theme="dark"] .btn-auth-google { - border-color: rgba(255,255,255,0.14); -} - -/* Guest link */ -.auth-guest { - font-size: 0.84rem; - color: var(--muted); - text-decoration: none; - transition: color 0.15s; -} - -.auth-guest:hover { color: var(--text); } - -/* Done states (email sent / reset sent) */ -.auth-done { - width: 100%; - background: var(--card); - border: 1px solid var(--border); - border-radius: 20px; - padding: 2.4rem 1.8rem; - box-shadow: 0 4px 24px rgba(0,0,0,0.07); - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.6rem; -} - -.auth-done-icon { font-size: 2.4rem; } - -.auth-done h2 { - margin: 0.2rem 0 0; - font-size: 1.1rem; - font-weight: 700; -} - -.auth-done p { - margin: 0; - color: var(--muted); - font-size: 0.9rem; - line-height: 1.6; -} - -.auth-done button { - margin-top: 0.6rem; - background: none; - border: none; - color: var(--accent); - font-size: 0.88rem; - font-weight: 600; - cursor: pointer; - text-decoration: underline; -} - -.upgrade-cta { - display: block; - margin-top: 0.8rem; - color: var(--accent); - font-weight: 600; -} - -/* ---- option input (multi-way) ---- */ -.opt-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; -} - -.opt-card { - position: relative; - display: flex; - flex-direction: column; - gap: 0.55rem; - min-height: 150px; - padding: 1.2rem; - border: 1px solid var(--border); - border-radius: 22px; - background: var(--card); - box-shadow: var(--shadow-md); - overflow: hidden; - transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; -} - -.opt-card:focus-within { - border-color: var(--accent); - box-shadow: 0 18px 44px rgba(52, 84, 232, 0.18); - transform: translateY(-2px); -} - -.opt-card-top { - display: flex; - align-items: center; - gap: 0.5rem; - z-index: 1; -} - -.opt-tag { - width: 28px; - height: 28px; - border-radius: 9999px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.85rem; - font-weight: 700; - color: white; - background: var(--accent); -} - -.opt-0 .opt-tag { background: #3454e8; } -.opt-1 .opt-tag { background: #7c5cff; } -.opt-2 .opt-tag { background: #0ea5a3; } -.opt-3 .opt-tag { background: #f59e0b; } -.opt-4 .opt-tag { background: #ec4899; } - -.opt-label { - color: var(--muted); - font-size: 0.9rem; - flex: 1; -} - -.opt-remove { - border: none; - background: transparent; - color: #c7cdd8; - font-size: 1.2rem; - line-height: 1; - cursor: pointer; - padding: 0 0.2rem; -} - -.opt-remove:hover { color: #dc2626; } - -.opt-card input { - border: none; - outline: none; - font-size: 1.3rem; - font-weight: 600; - background: transparent; - padding: 0; - z-index: 1; -} - -.opt-card input::placeholder { - color: #c7cdd8; - font-weight: 500; -} - -.opt-watermark { - position: absolute; - right: 0.2rem; - bottom: -1.4rem; - font-size: 7rem; - font-weight: 800; - line-height: 1; - opacity: 0.07; - pointer-events: none; - color: var(--accent); -} - -.opt-0 .opt-watermark { color: #3454e8; } -.opt-1 .opt-watermark { color: #7c5cff; } -.opt-2 .opt-watermark { color: #0ea5a3; } -.opt-3 .opt-watermark { color: #f59e0b; } -.opt-4 .opt-watermark { color: #ec4899; } - -.opt-add-btn { - display: flex; - align-items: center; - gap: 0.5rem; - width: fit-content; - margin: 1rem auto 0; - border: 1px solid var(--border); - border-radius: 9999px; - background: white; - color: var(--muted); - font-size: 0.9rem; - font-weight: 600; - padding: 0.5rem 1.05rem; - cursor: pointer; - transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease; -} - -.opt-add-btn:hover { - border-color: var(--accent); - color: var(--accent); - background: rgba(52, 84, 232, 0.04); -} - -.opt-add-count { - font-size: 0.78rem; - opacity: 0.6; -} - -/* ---- result option chips ---- */ -.result-chips { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 0.45rem; - margin: 0.2rem 0 1rem; -} - -.result-chip { - font-size: 0.85rem; - font-weight: 600; - color: var(--muted); - background: white; - border: 1px solid var(--border); - border-radius: 9999px; - padding: 0.3rem 0.8rem; -} - -.result-chip.win { - color: var(--accent); - border-color: rgba(52, 84, 232, 0.4); - background: rgba(52, 84, 232, 0.07); -} - -.result-chip.link { - display: inline-flex; - align-items: center; - gap: 0.3rem; - transition: border-color 0.16s ease, color 0.16s ease, box-shadow 0.16s ease; -} - -.result-chip.link:hover { - border-color: var(--accent); - color: var(--accent); - box-shadow: 0 4px 12px rgba(52, 84, 232, 0.14); -} - -.chip-ext { - font-size: 0.72rem; - opacity: 0.6; -} - -.result-vs { - font-size: 0.72rem; - font-weight: 700; - color: #c4c9d4; -} - -/* ---- per-option analysis ---- */ -.analysis-list { - display: flex; - flex-direction: column; - gap: 0.8rem; -} - -.analysis-item { - border: 1px solid var(--border); - border-radius: 16px; - padding: 1rem 1.1rem; - scroll-margin-top: 1.2rem; - transition: border-color 0.3s ease, box-shadow 0.3s ease; -} - -.analysis-item.winner-item { - border-color: rgba(52, 84, 232, 0.25); - border-left: 3px solid var(--accent); - background: linear-gradient(to right, rgba(52, 84, 232, 0.03), transparent 40%); -} - -[data-theme="dark"] .analysis-item.winner-item { - border-color: rgba(91, 120, 245, 0.3); - border-left: 3px solid #7c5cff; - background: linear-gradient(to right, rgba(124, 92, 255, 0.06), transparent 40%); -} - -.analysis-item:target { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--ring); -} - -.analysis-head { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.analysis-letter { - width: 24px; - height: 24px; - flex-shrink: 0; - border-radius: 7px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.78rem; - font-weight: 700; - color: white; - background: linear-gradient(135deg, #3454e8, #7c5cff); -} - -.analysis-name { - font-weight: 700; - font-size: 1.02rem; -} - -.analysis-pick { - font-size: 0.72rem; - font-weight: 700; - color: var(--accent); - background: rgba(52, 84, 232, 0.1); - border-radius: 9999px; - padding: 0.15rem 0.5rem; -} - -.analysis-official { - margin-left: auto; - font-size: 0.82rem; - color: var(--muted); -} - -.analysis-official:hover { - color: var(--accent); -} - -.analysis-item p { - margin: 0; - line-height: 1.65; - word-break: break-word; - overflow-wrap: break-word; - color: var(--muted); - font-size: 0.9rem; -} - -/* ---- share + affiliate CTA ---- */ -.share-actions { - display: flex; - flex-direction: column; - gap: 0.8rem; - margin: 0; -} - -.share-card-preview { - position: relative; - overflow: hidden; - padding: 1rem; - border: 1px solid var(--border); - border-radius: 14px; - background: - linear-gradient(135deg, rgba(52, 84, 232, 0.1), rgba(15, 23, 42, 0.02) 48%, rgba(52, 84, 232, 0.12)), - var(--card); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62); -} - -[data-theme="dark"] .share-card-preview { - background: - linear-gradient(135deg, rgba(91, 120, 245, 0.16), rgba(255, 255, 255, 0.04) 52%, rgba(91, 120, 245, 0.1)), - var(--card); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07); -} - -.share-card-eyebrow { - display: block; - margin-bottom: 0.5rem; - color: var(--accent); - font-size: 0.68rem; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.share-card-pick { - display: block; - color: var(--fg); - font-size: 1.02rem; - line-height: 1.25; - overflow-wrap: anywhere; -} - -.share-card-preview p { - margin: 0.45rem 0 0; - color: var(--muted); - font-size: 0.85rem; - line-height: 1.45; - overflow-wrap: anywhere; -} - -.btn-buy { - display: flex; - align-items: center; - gap: 0.65rem; - width: 100%; - padding: 1rem 1.15rem; - border-radius: 14px; - border: none; - background: #0f172a; - color: white; - font-size: 0.98rem; - font-weight: 700; - text-align: left; - cursor: pointer; - text-decoration: none; - transition: opacity 0.15s ease; -} - -[data-theme="dark"] .btn-buy { - background: #e2e8f4; - color: #0d0f14; -} - -[data-theme="dark"] .btn-buy .buy-icon { - background: rgba(0, 0, 0, 0.12); - color: #0d0f14; -} - -[data-theme="dark"] .btn-buy .buy-store { - background: rgba(0, 0, 0, 0.1); - color: #0d0f14; -} - -.btn-buy:hover { - opacity: 0.88; - text-decoration: none; -} - -.btn-buy:focus-visible, -.btn-share:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; -} - -.buy-icon { - width: 22px; - height: 22px; - border-radius: 7px; - display: inline-flex; - align-items: center; - justify-content: center; - background: rgba(255, 255, 255, 0.15); - flex-shrink: 0; - font-size: 0.8rem; -} - -.buy-text { - display: block; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.buy-copy { - flex: 1; - min-width: 0; -} - -.buy-hint { - display: block; - margin-top: 0.2rem; - color: rgba(255, 255, 255, 0.7); - font-size: 0.76rem; - font-weight: 500; - line-height: 1.2; -} - -[data-theme="dark"] .btn-buy .buy-hint { - color: rgba(13, 15, 20, 0.62); -} - -.buy-copy, -.buy-text { - overflow: hidden; - text-overflow: ellipsis; -} - -.buy-official-badge { - display: inline-block; - font-size: 0.65rem; - font-weight: 700; - background: rgba(255,255,255,0.22); - border-radius: 4px; - padding: 1px 5px; - margin-left: 5px; - vertical-align: middle; - letter-spacing: 0.02em; -} - -.buy-store { - margin-left: auto; - font-size: 0.8rem; - font-weight: 600; - opacity: 0.88; - background: rgba(255, 255, 255, 0.2); - border-radius: 8px; - padding: 0.2rem 0.55rem; - white-space: nowrap; -} - -.share-row { - display: flex; - gap: 0.55rem; -} - -.btn-share { - flex: 1; - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.85rem; - padding: 0.8rem 1rem; - border: 1px solid var(--border); - border-radius: 14px; - background: var(--card); - color: var(--fg); - font-size: 0.93rem; - font-weight: 600; - cursor: pointer; - transition: border-color 0.16s ease, color 0.16s ease, box-shadow 0.16s ease; -} - -.btn-share:disabled { - cursor: wait; - opacity: 0.72; -} - -.btn-share:hover { - border-color: var(--accent); - color: var(--accent); - box-shadow: 0 2px 8px rgba(52, 84, 232, 0.1); -} - -.share-hint { - flex-shrink: 0; - color: var(--muted); - font-size: 0.76rem; - font-weight: 500; - text-align: right; -} - -.affiliate-note { - display: grid; - gap: 0.25rem; - margin: 0; - font-size: 0.78rem; - color: var(--muted); - text-align: left; - line-height: 1.4; - overflow-wrap: anywhere; -} - -/* ════════════════════════════════════════ - TimingSection — 구매 타이밍 - ════════════════════════════════════════ */ - -.timing-section { - padding: 1.35rem 1.5rem 1.2rem; -} - -/* 헤더 행: 라벨 + 신호 점 */ -.ts-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; -} - -.ts-label { - font-size: .72rem; - font-weight: 600; - letter-spacing: .08em; - text-transform: uppercase; - color: var(--muted); -} - -/* 신호 점 — 이모지 없이 CSS로 */ -.ts-signal { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} -.ts-green { background: #22c55e; box-shadow: 0 0 0 3px rgba(34,197,94,.18); } -.ts-amber { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245,158,11,.18); } -.ts-red { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.18); } -.ts-gray { background: var(--muted); } - -[data-theme="dark"] .ts-green { box-shadow: 0 0 0 3px rgba(34,197,94,.25); } -[data-theme="dark"] .ts-amber { box-shadow: 0 0 0 3px rgba(245,158,11,.25); } -[data-theme="dark"] .ts-red { box-shadow: 0 0 0 3px rgba(239,68,68,.25); } - -/* 판정 텍스트 */ -.ts-verdict { - margin: 0 0 .3rem; - font-size: 1.12rem; - font-weight: 800; - letter-spacing: -.025em; - line-height: 1.2; - color: var(--fg); -} - -.ts-sub { - margin: 0 0 1.2rem; - font-size: .82rem; - color: var(--muted); - line-height: 1.6; -} - -/* 가격 게이지 */ -.ts-gauge-wrap { margin-bottom: .6rem; } - -.ts-gauge-track { - position: relative; - height: 3px; - background: var(--border); - border-radius: 99px; - overflow: visible; - margin-bottom: .55rem; -} - -.ts-gauge-fill { - height: 100%; - border-radius: 99px; - transition: width .5s cubic-bezier(.22,1,.36,1); -} - -.ts-fill-green { background: #22c55e; } -.ts-fill-amber { background: #f59e0b; } -.ts-fill-red { background: #ef4444; } -.ts-fill-gray { background: var(--border); } - -/* 현재 가격 마커 */ -.ts-gauge-cursor { - position: absolute; - top: 50%; - transform: translate(-50%, -50%); - width: 11px; - height: 11px; - border-radius: 50%; - background: var(--card); - border: 2px solid var(--fg); - box-shadow: 0 1px 4px rgba(0,0,0,.15); - transition: left .5s cubic-bezier(.22,1,.36,1); -} - -[data-theme="dark"] .ts-gauge-cursor { - box-shadow: 0 1px 6px rgba(0,0,0,.4); -} - -/* 최저가 / 현재 / 평균 레이블 */ -.ts-price-row { - display: flex; - justify-content: space-between; - font-size: .72rem; - color: var(--muted); - line-height: 1.3; - gap: .2rem; -} - -.ts-price-row span { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.ts-price-row span:nth-child(2) { - text-align: center; -} - -.ts-price-row span:last-child { - text-align: right; -} - -.ts-price-current { - font-weight: 700; - color: var(--fg); -} - -@media (max-width: 400px) { - .ts-price-row { - font-size: .66rem; - } -} - -/* 다음 모델 힌트 */ -.ts-cycle { - display: flex; - align-items: center; - gap: .45rem; - padding-top: 1rem; - border-top: 1px solid var(--border-light); - font-size: .76rem; - color: var(--muted); - line-height: 1.4; -} - -.ts-cycle svg { flex-shrink: 0; opacity: .6; } - -/* ════════════════════════════════════════ - ContextCard — 맞춤 재분석 - ════════════════════════════════════════ */ - -/* 닫힌 상태: 미니멀 텍스트 버튼 */ -.cc-trigger { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 1rem 1.4rem; - background: var(--card); - border: 1px solid var(--border); - border-radius: 20px; - cursor: pointer; - text-align: left; - transition: border-color .18s, background .18s; - margin-top: .9rem; -} - -.cc-trigger:hover { - border-color: var(--muted); - background: var(--bg-subtle); -} - -[data-theme="dark"] .cc-trigger:hover { - background: rgba(255,255,255,.03); -} - -.cc-trigger-text { - display: flex; - flex-direction: column; - gap: .2rem; -} - -.cc-trigger-title { - font-size: .88rem; - font-weight: 700; - color: var(--fg); - line-height: 1.2; -} - -.cc-trigger-hint { - font-size: .76rem; - color: var(--muted); - font-weight: 400; -} - -.cc-chevron { - color: var(--muted); - flex-shrink: 0; - opacity: .6; -} - -/* 열린 상태 */ -.cc-card { - margin-top: .9rem; - border: 1px solid var(--border); - border-radius: 20px; - background: var(--card); - overflow: hidden; - box-shadow: var(--shadow-sm); -} - -.cc-head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1.4rem; - border-bottom: 1px solid var(--border-light); -} - -.cc-head-title { - font-size: .88rem; - font-weight: 700; - color: var(--fg); -} - -.cc-close { - display: flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - background: var(--bg-subtle); - border: 1px solid var(--border); - border-radius: 50%; - cursor: pointer; - color: var(--muted); - transition: border-color .15s, color .15s; - flex-shrink: 0; -} - -.cc-close:hover { - border-color: var(--fg); - color: var(--fg); -} - -[data-theme="dark"] .cc-close { - background: rgba(255,255,255,.05); -} - -.cc-form { - display: flex; - flex-direction: column; - gap: 1.1rem; - padding: 1.2rem 1.4rem 1.4rem; -} - -.cc-field { - display: flex; - flex-direction: column; - gap: .5rem; -} - -.cc-field-label { - font-size: .7rem; - font-weight: 700; - letter-spacing: .07em; - text-transform: uppercase; - color: var(--muted); - margin: 0; -} - -.cc-optional { - font-weight: 400; - text-transform: none; - letter-spacing: 0; -} - -.cc-chips { - display: flex; - flex-wrap: wrap; - gap: .4rem; -} - -.cc-chip { - padding: .38rem .82rem; - border: 1px solid var(--border); - border-radius: 99px; - background: transparent; - cursor: pointer; - font-size: .8rem; - color: var(--muted); - font-weight: 500; - transition: all .13s; - line-height: 1.4; -} - -.cc-chip:hover { - border-color: var(--fg); - color: var(--fg); -} - -.cc-chip-on { - border-color: var(--accent); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 8%, var(--card)); - font-weight: 700; -} - -[data-theme="dark"] .cc-chip-on { - background: color-mix(in srgb, var(--accent) 14%, var(--card)); -} - -.cc-input { - padding: .6rem .9rem; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-subtle); - color: var(--fg); - font-size: .84rem; - line-height: 1.5; - transition: border-color .15s, background .15s; - font-family: inherit; -} - -.cc-input:focus { - outline: none; - border-color: var(--accent); - background: var(--card); -} - -.cc-input::placeholder { - color: var(--muted); - opacity: .7; -} - -[data-theme="dark"] .cc-input { - background: rgba(255,255,255,.05); -} - -[data-theme="dark"] .cc-input:focus { - background: rgba(255,255,255,.07); -} - -.cc-submit { - display: flex; - align-items: center; - justify-content: center; - gap: .5rem; - padding: .82rem 1rem; - background: var(--fg); - color: var(--bg); - border: none; - border-radius: 12px; - cursor: pointer; - font-size: .88rem; - font-weight: 700; - letter-spacing: -.01em; - transition: opacity .15s; - font-family: inherit; - margin-top: .1rem; -} - -.cc-submit:hover { opacity: .82; } - -[data-theme="dark"] .cc-submit { - background: var(--fg); - color: #0d0f14; -} - -/* ---- price comparison (live price + history) ---- */ -.price-head { - display: flex; - align-items: center; - gap: 0.6rem; - margin-bottom: 0.7rem; -} - -.price-head h2 { - margin: 0; -} - -.price-demo { - font-size: 0.7rem; - font-weight: 700; - color: var(--muted); - background: var(--bg); - border: 1px solid var(--border); - border-radius: 9999px; - padding: 0.12rem 0.5rem; -} - -.price-list { - list-style: none; - margin: 0; - padding: 0; -} - -.price-row { - display: flex; - align-items: center; - gap: 0.9rem; - padding: 0.75rem 0.6rem; - border-bottom: 1px solid var(--border); - border-radius: 10px; - flex-wrap: wrap; -} - -.price-row:last-child { - border-bottom: none; -} - -.price-row.cheapest { - background: rgba(52, 84, 232, 0.05); -} - -[data-theme="dark"] .price-row.cheapest { - background: rgba(91, 120, 245, 0.1); -} - -.price-main { - display: flex; - flex-direction: column; - gap: 0.3rem; - flex: 1; - min-width: 0; -} - -.price-name { - font-weight: 700; - font-size: 0.98rem; -} - -.price-tags { - display: flex; - gap: 0.35rem; -} - -.price-tag { - font-size: 0.68rem; - font-weight: 700; - border-radius: 9999px; - padding: 0.1rem 0.45rem; -} - -.cheapest-tag { - color: var(--accent); - background: rgba(52, 84, 232, 0.12); -} - -.deal-tag { - color: #0f9d6b; - background: rgba(15, 157, 107, 0.12); -} - -[data-theme="dark"] .deal-tag { - color: #34d399; - background: rgba(52, 211, 153, 0.14); -} - -.spark { - color: var(--muted); - flex-shrink: 0; -} - -.price-figures { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.15rem; - flex-shrink: 0; -} - -.price-now { - font-size: 1.05rem; - font-weight: 800; - letter-spacing: -0.01em; - color: var(--text); - transition: color 0.14s ease; -} - -.price-now:hover { - color: var(--accent); -} - -.price-go { - font-size: 0.75rem; - color: var(--muted); -} - -.price-sub { - font-size: 0.76rem; - color: var(--muted); -} - -@media (max-width: 480px) { - .price-figures { - align-items: flex-start; - width: 100%; - } -} - -/* ---- watch / track ---- */ -.watch-btn-wrap { - position: relative; - display: inline-block; -} - -.watch-btn { - display: inline-flex; - align-items: center; - gap: 0.3rem; - flex-shrink: 0; - border: 1px solid var(--border); - border-radius: 9999px; - background: var(--card); - color: var(--muted); - font-size: 0.78rem; - font-weight: 600; - padding: 0.34rem 0.7rem; - cursor: pointer; - transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease; -} - -.watch-btn:hover { - border-color: var(--accent); - color: var(--accent); -} - -.watch-btn.on { - border-color: var(--accent); - color: var(--accent); - background: rgba(52, 84, 232, 0.08); -} - -[data-theme="dark"] .watch-btn.on { - background: rgba(91, 120, 245, 0.14); -} - -/* Home watchlist card */ -.watch-card { - margin-top: 1.5rem; - border: 1px solid var(--border); - border-radius: 24px; - background: var(--card); - box-shadow: var(--shadow-sm); - overflow: hidden; -} - -.watch-head { - display: flex; - align-items: center; - gap: 0.55rem; - padding: 0.85rem 1.2rem; - border-bottom: 1px solid var(--border); -} - -.watch-head h3 { - margin: 0; - font-size: 0.92rem; - font-weight: 700; -} - -.watch-count { - font-size: 0.7rem; - font-weight: 700; - color: var(--accent); - background: rgba(52, 84, 232, 0.1); - border-radius: 9999px; - padding: 0.1rem 0.5rem; -} - -.watch-items { - list-style: none; - margin: 0; - padding: 0; -} - -.watch-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.85rem 1.2rem; - border-bottom: 1px solid var(--border); -} - -.watch-item:last-child { - border-bottom: none; -} - -.watch-name { - font-weight: 600; - font-size: 0.95rem; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.watch-right { - display: flex; - align-items: center; - gap: 0.8rem; - flex-shrink: 0; -} - -.watch-price { - font-weight: 800; - font-size: 0.96rem; - color: var(--text); - transition: color 0.14s ease; -} - -.watch-price:hover { - color: var(--accent); -} - -.watch-remove { - width: 28px; - height: 28px; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - color: #b8c0d0; - font-size: 1.05rem; - line-height: 1; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease; -} - -.watch-remove:hover { - background: rgba(220, 38, 38, 0.08); - border-color: rgba(220, 38, 38, 0.2); - color: #dc2626; -} - -.watch-note { - margin: 0; - padding: 0.7rem 1.2rem 0.9rem; - font-size: 0.8rem; - color: var(--muted); - border-top: 1px solid var(--border); -} - -/* ---- watch push prompt ---- */ -.watch-push-prompt { - position: absolute; - top: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - z-index: 200; - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 1rem 1.1rem 0.9rem; - width: 240px; - box-shadow: 0 8px 24px rgba(0,0,0,0.12); -} - -[data-theme="dark"] .watch-push-prompt { - box-shadow: 0 8px 24px rgba(0,0,0,0.4); -} - -.watch-push-sub { - margin: 0 0 0.8rem; - font-size: 0.76rem; - color: var(--muted); - padding-right: 1rem; - line-height: 1.4; -} - -.watch-push-denied { - padding: 0.7rem 1rem; -} - -.watch-push-denied-msg { - margin: 0; - font-size: 0.78rem; - color: var(--muted); - padding-right: 1.4rem; - line-height: 1.4; -} - -/* ---- watch email modal ---- */ -.watch-email-modal { - position: absolute; - top: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - z-index: 200; - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 1rem 1.1rem 0.9rem; - width: 260px; - box-shadow: 0 8px 24px rgba(0,0,0,0.12); -} - -[data-theme="dark"] .watch-email-modal { - box-shadow: 0 8px 24px rgba(0,0,0,0.4); -} - -.watch-email-close { - position: absolute; - top: 0.5rem; - right: 0.6rem; - background: none; - border: none; - font-size: 1rem; - color: var(--muted); - cursor: pointer; - padding: 0 0.2rem; - line-height: 1; -} - -.watch-email-label { - margin: 0 0 0.7rem; - font-size: 0.82rem; - font-weight: 600; - color: var(--fg); - padding-right: 1rem; -} - -.watch-email-form { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.watch-email-input { - width: 100%; - padding: 0.45rem 0.6rem; - border: 1px solid var(--border); - border-radius: 7px; - background: var(--bg); - color: var(--fg); - font-size: 0.82rem; - box-sizing: border-box; -} - -.watch-email-input:focus { - outline: none; - border-color: var(--accent, #3b82f6); -} - -.watch-email-confirm { - width: 100%; - padding: 0.45rem; - background: var(--fg); - color: var(--bg); - border: none; - border-radius: 7px; - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; -} - -.watch-email-confirm:disabled { - opacity: 0.5; - cursor: default; -} - -.watch-email-skip { - display: block; - width: 100%; - margin-top: 0.4rem; - background: none; - border: none; - font-size: 0.75rem; - color: var(--muted); - cursor: pointer; - text-align: center; - padding: 0.2rem 0; -} - -.watch-email-skip:hover { - color: var(--fg); -} - -/* ---- share watermark (Free shared pages) ---- */ -.share-watermark { - position: fixed; - bottom: 0; - left: 0; - right: 0; - background: white; - border-top: 1px solid var(--border); - padding: 1rem 1.2rem 1.4rem; - text-align: center; - box-shadow: 0 -8px 32px rgba(17, 24, 39, 0.1); - z-index: 10; -} - -.share-watermark p { - margin: 0 0 0.1rem; - font-size: 0.95rem; - color: var(--muted); -} - -.share-watermark strong { - color: var(--text); -} - -.vs-upsell { - text-align: center; - color: var(--muted); - font-size: 0.9rem; - margin: 0.9rem 0 0; -} - -.vs-upsell a { - color: var(--accent); - font-weight: 600; -} - -/* ---- comparison table (N columns) ---- */ -.cmp-table { - display: flex; - flex-direction: column; - width: 100%; -} - -.cmp-row { - display: grid; - grid-template-columns: minmax(80px, 0.85fr) repeat(var(--cols), minmax(0, 1fr)); - column-gap: 0.5rem; - align-items: start; -} - -.cmp-key, -.cmp-col { - padding: 0.7rem 0.6rem; - border-bottom: 1px solid var(--border); -} - -.cmp-row:last-child .cmp-key, -.cmp-row:last-child .cmp-col { - border-bottom: none; -} - -.cmp-key { - color: var(--muted); - font-size: 0.85rem; - font-weight: 600; - align-self: start; - padding-top: 0.9rem; - min-width: 0; - word-break: keep-all; -} - -.cmp-col { - font-size: 0.9rem; - line-height: 1.4; - word-break: break-word; - overflow-wrap: break-word; - min-width: 0; -} - -.cmp-head .cmp-col { - font-weight: 700; - color: var(--text); - display: flex; - flex-direction: column; - gap: 0.3rem; - padding-top: 0.55rem; -} - -/* Continuous winner highlight band */ -.cmp-col.winner { - background: rgba(52, 84, 232, 0.06); - color: var(--accent); -} - -[data-theme="dark"] .cmp-col.winner { - background: rgba(91, 120, 245, 0.12); -} - -.cmp-head .cmp-col.winner { - border-top-left-radius: 12px; - border-top-right-radius: 12px; - color: var(--accent); -} - -.cmp-row:last-child .cmp-col.winner { - border-bottom-left-radius: 12px; - border-bottom-right-radius: 12px; -} - -.cmp-win-tag { - align-self: flex-start; - font-size: 0.62rem; - font-weight: 700; - color: white; - background: var(--accent); - border-radius: 9999px; - padding: 0.08rem 0.5rem; - letter-spacing: 0.02em; -} - -/* ---- account hero / usage ---- */ -.account-hero { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.6rem; - text-align: center; - margin: 1rem 0 1.4rem; -} - -.account-avatar { - width: 64px; - height: 64px; - border-radius: 20px; - display: inline-flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, #3454e8, #7c5cff); - color: white; - font-size: 1.8rem; - font-weight: 700; -} - -.nickname-view { - display: flex; - align-items: center; - gap: 0.6rem; -} - -.nickname-view h1 { - margin: 0; - font-size: 1.7rem; -} - -.nickname-edit { - border: 1px solid var(--border); - background: white; - border-radius: 9999px; - font-size: 0.8rem; - color: var(--muted); - padding: 0.25rem 0.7rem; - cursor: pointer; - transition: border-color 0.16s ease, color 0.16s ease; -} - -.nickname-edit:hover { - border-color: var(--accent); - color: var(--accent); -} - -.nickname-edit-row { - width: 100%; - max-width: 320px; -} - -.nickname-edit-row input { - width: 100%; - border: 1px solid var(--border); - border-radius: 12px; - padding: 0.8rem; - font-size: 1.1rem; - text-align: center; -} - -.nickname-edit-row input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 4px var(--ring); -} - -.nickname-actions { - display: flex; - gap: 0.5rem; - margin-top: 0.6rem; -} - -.nickname-actions .btn-primary, -.nickname-actions .btn-outline { - margin-top: 0; -} - -.usage-head { - display: flex; - justify-content: space-between; - align-items: baseline; -} - -.usage-head h2 { margin: 0; } - -.usage-count { - font-weight: 700; - color: var(--accent); -} - -.usage-bar { - height: 10px; - border-radius: 9999px; - background: var(--bg); - overflow: hidden; - margin: 0.8rem 0 0.6rem; -} - -.usage-fill { - display: block; - height: 100%; - border-radius: 9999px; - background: linear-gradient(90deg, #3454e8, #7c5cff); - transition: width 0.3s ease; -} - -.usage-sub { - margin: 0; - color: var(--muted); - font-size: 0.88rem; -} - -/* ---- history ---- */ -.history-head { - display: flex; - align-items: center; - gap: 0.55rem; - padding: 0.85rem 1.2rem; - border-bottom: 1px solid var(--border); -} - -.history-head h3 { - margin: 0; - font-size: 0.92rem; - font-weight: 700; -} - -.history-count { - font-size: 0.7rem; - font-weight: 700; - color: var(--muted); - background: var(--bg); - border-radius: 9999px; - padding: 0.1rem 0.5rem; -} - -.history-items { - list-style: none; - padding: 0; - margin: 0; -} - -.history-item { - display: flex; - align-items: center; - gap: 0; - border-bottom: 1px solid var(--border); - transition: background 0.14s ease; -} - -.history-item:last-child { border-bottom: none; } - -.history-item:hover { - background: rgba(52, 84, 232, 0.03); -} - -[data-theme="dark"] .history-item:hover { - background: rgba(255, 255, 255, 0.03); -} - -/* Link area fills the row */ -.history-item-main { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0.4rem; - padding: 0.95rem 0.5rem 0.95rem 1.2rem; - text-decoration: none; -} - -/* Matchup: what was compared */ -.history-matchup { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.4rem; - font-size: 0.96rem; - line-height: 1.3; -} - -.hm-part { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} - -.hm-opt { - color: var(--text); - font-weight: 600; -} - -.hm-win { - color: var(--text); - font-weight: 600; -} - -.hm-vs { - font-size: 0.64rem; - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; - color: #c4c9d4; -} - -/* Verdict: which one AXIS picked */ -.history-verdict { - display: inline-flex; - align-items: center; - gap: 0.38rem; - font-size: 0.82rem; - font-weight: 700; - color: var(--accent); - transition: opacity 0.14s ease; -} - -.verdict-mark { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - flex-shrink: 0; - border-radius: 9999px; - background: rgba(52, 84, 232, 0.12); - font-size: 0.58rem; - font-weight: 800; -} - -[data-theme="dark"] .verdict-mark { - background: rgba(91, 120, 245, 0.2); -} - -.history-item:hover .history-matchup { - opacity: 0.85; -} - -/* Date + delete button */ -.history-item-meta { - display: flex; - align-items: center; - gap: 0.3rem; - flex-shrink: 0; - padding-right: 0.6rem; -} - -.history-item-date { - color: var(--muted); - font-size: 0.75rem; - white-space: nowrap; - padding-right: 0.2rem; -} - -@media (max-width: 400px) { - .history-item-date { display: none; } -} - -.history-delete { - width: 30px; - height: 30px; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - color: #b8c0d0; - font-size: 1.1rem; - line-height: 1; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease; -} - -.history-delete:hover { - background: rgba(220, 38, 38, 0.08); - border-color: rgba(220, 38, 38, 0.2); - color: #dc2626; -} - -.history-delete:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -/* Empty / login prompt */ -.history-empty { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.3rem; - padding: 2rem 1.5rem; - text-align: center; -} - -.history-empty p { - margin: 0; - color: var(--muted); - font-size: 0.88rem; -} - -.history-empty-cta { - margin-top: 0.2rem; - color: var(--accent); - font-weight: 600; - font-size: 0.9rem; -} - -@media (max-width: 820px) { - .example-grid { - grid-template-columns: 1fr; - } - - .home-method { - grid-template-columns: 1fr; - } - - .feature-grid { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (max-width: 560px) { - .container { - width: 100%; - padding: 0.75rem 1.2rem 3rem; - } - - .topbar { - margin-bottom: 1.2rem; - } - - .hero { - margin: 0.6rem 0 1rem; - } - - .hero h1 { - font-size: 1.65rem; - } - - .sub { - font-size: 0.93rem; - } - - .hero-proof { - gap: 0.4rem; - } - - .proof-pill { - font-size: 0.76rem; - padding: 0.32rem 0.65rem; - } - - .home-section { - margin-top: 1.8rem; - } - - /* Verdict hero: tighter on mobile */ - .verdict-hero { - padding: 1.4rem 1.25rem 1.3rem; - border-radius: 18px; - } - - .vh-title { - font-size: 1.6rem; - letter-spacing: -0.025em; - } - - .vh-conclusion { - font-size: 0.94rem; - } - - .vh-matchup { - margin-top: 1.3rem; - padding-top: 1.2rem; - gap: 0.85rem; - } - - .detail-card { - padding: 1rem 1.1rem; - border-radius: 16px; - margin-top: 0.75rem; - } - - .detail-card h2 { - font-size: 0.82rem; - margin-bottom: 0.85rem; - } - - /* Buttons: not too huge on mobile */ - .btn-buy { - padding: 0.9rem 1rem; - font-size: 0.92rem; - border-radius: 12px; - } - - .btn-share { - align-items: flex-start; - flex-direction: column; - gap: 0.2rem; - padding: 0.65rem 1rem; - font-size: 0.88rem; - border-radius: 12px; - } - - .share-hint { - text-align: left; - } - - .share-actions-wrap { - padding: 0.9rem 1.1rem 1.1rem; - } - - .btn-primary { - padding: 0.82rem 1rem; - font-size: 0.95rem; - border-radius: 12px; - margin-top: 0.9rem; - } - - .buy-store { - font-size: 0.75rem; - } - - /* Input on mobile */ - .vs-card { - padding: 0.9rem; - min-height: 148px; - } - - .opt-submit { - margin-top: 0.9rem; - } - - /* Share actions spacing */ - .share-actions { - gap: 0.55rem; - } - - /* Reason list */ - .reason-list li { - font-size: 0.9rem; - padding: 0.65rem 0; - } - - /* Back button */ - .btn-back { - font-size: 0.85rem; - margin-bottom: 0.7rem; - } - - /* Results body spacing */ - .results-body { - padding-bottom: 2.5rem; - } - - /* Topbar: tighten brand + nav gap */ - .brand { - font-size: 1.45rem; - } - - .topbar-right { - gap: 0.3rem; - } - - /* Input cards: compact */ - .opt-card { - min-height: 128px; - padding: 0.9rem 1rem; - } - - .vs-card input, - .opt-card input { - font-size: 1.15rem; - } - - .buy-timing-block { - border-radius: 16px; - } - - .ts-verdict { - font-size: 1rem; - } -} - -/* ── compare pages ─────────────────────────────────────────────────────── */ - -.compare-breadcrumb { - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.9rem 1.2rem 0; - font-size: 0.8rem; - color: var(--muted); - max-width: 680px; - margin: 0 auto; -} - -.compare-breadcrumb a { - color: var(--muted); - text-decoration: none; -} - -.compare-breadcrumb a:hover { color: var(--fg); } - -/* related comparisons */ -.compare-related { - max-width: 680px; - margin: 0 auto 2rem; - padding: 1.4rem 1.2rem; - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; -} - -.compare-related-title { - font-size: 1rem; - font-weight: 600; - margin: 0 0 1rem; -} - -.compare-related-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.8rem; -} - -.compare-related-link { - font-size: 0.9rem; - font-weight: 500; - color: var(--fg); - text-decoration: none; -} - -.compare-related-link:hover { text-decoration: underline; } - -.compare-related-desc { - margin: 0.15rem 0 0; - font-size: 0.78rem; - color: var(--muted); -} - -/* CTA */ -.compare-cta { - max-width: 680px; - margin: 0 auto 3rem; - padding: 1.4rem 1.2rem; - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; - text-align: center; -} - -.compare-cta-copy { - margin: 0 0 1rem; - font-size: 0.9rem; - color: var(--muted); -} - -.compare-cta-btn { - display: inline-block; - padding: 0.7rem 1.4rem; - font-size: 0.9rem; -} - -/* index page */ -.compare-index-section { - margin-bottom: 2rem; -} - -.compare-index-category { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: .07em; - color: var(--muted); - margin: 0 0 0.8rem; -} - -.compare-index-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.compare-index-item { - border: 1px solid var(--border); - border-radius: 10px; - background: var(--card); -} - -.compare-index-link { - display: block; - padding: 0.85rem 1rem; - text-decoration: none; - color: var(--fg); -} - -.compare-index-link:hover { background: var(--hover, rgba(0,0,0,.03)); border-radius: 10px; } - -.compare-index-title { - font-size: 0.92rem; - font-weight: 600; -} - -.compare-index-desc { - margin: 0.2rem 0 0; - font-size: 0.78rem; - color: var(--muted); -} - -/* ═══════════════════════════════════════════════════════════════════════════ - Analyze Overlay — "Axis에게 맡기기" 클릭 후 프리미엄 분석 로딩 화면 - ═══════════════════════════════════════════════════════════════════════════ */ - -@keyframes overlayFadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes modalRise { - from { - opacity: 0; - transform: translateY(28px) scale(0.96); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes indeterminate { - 0% { transform: translateX(-100%) scaleX(0.4); } - 40% { transform: translateX(30%) scaleX(0.8); } - 100% { transform: translateX(350%) scaleX(0.4); } -} - -@keyframes pillGlow { - 0%, 100% { border-color: rgba(255,255,255,0.12); } - 50% { border-color: rgba(91,120,245,0.5); box-shadow: 0 0 18px rgba(91,120,245,0.25); } -} - -.analyze-overlay { - position: fixed; - inset: 0; - background: rgba(6, 8, 16, 0.90); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - animation: overlayFadeIn 0.22s ease; -} - -.analyze-modal { - width: min(440px, 88vw); - padding: 2.8rem 2rem 2.4rem; - background: rgba(255,255,255,0.03); - border: 1px solid rgba(255,255,255,0.07); - border-radius: 28px; - text-align: center; - animation: modalRise 0.32s cubic-bezier(0.18, 0, 0.08, 1); -} - -.analyze-logo { - font-size: 0.78rem; - font-weight: 800; - letter-spacing: 0.16em; - text-transform: uppercase; - color: rgba(255,255,255,0.22); - margin-bottom: 2rem; -} - -.analyze-comparing { - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: 0.8rem; - margin-bottom: 2.2rem; - min-height: 2.6rem; -} - -.analyze-pill { - display: inline-block; - padding: 0.55rem 1.05rem; - background: rgba(255,255,255,0.05); - border: 1px solid rgba(255,255,255,0.12); - border-radius: 100px; - font-size: 0.95rem; - font-weight: 600; - color: #e8ecf8; - animation: pillGlow 2.4s ease-in-out infinite; -} - -.analyze-vs-badge { - flex-shrink: 0; - font-size: 0.62rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - color: rgba(255,255,255,0.25); - padding: 0 0.1rem; -} - -.analyze-bar { - width: 100%; - height: 2px; - background: rgba(255,255,255,0.05); - border-radius: 2px; - overflow: hidden; - margin: 0 auto 1.8rem; - max-width: 240px; -} - -.analyze-progress { - height: 100%; - width: 40%; - background: linear-gradient(90deg, transparent 0%, #5b78f5 40%, #9474ff 70%, transparent 100%); - border-radius: 2px; - transform-origin: left; - animation: indeterminate 1.5s ease-in-out infinite; -} - -.analyze-status { - color: rgba(255,255,255,0.3); - font-size: 0.82rem; - letter-spacing: 0.02em; -} - -/* ── 버튼 로딩 상태 ──────────────────────────────────────────────────── */ - -@keyframes spinBtn { - to { transform: rotate(360deg); } -} - -.btn-loading { - opacity: 0.7 !important; - cursor: not-allowed; -} - -.btn-loading-inner { - display: inline-flex; - align-items: center; - gap: 0.55rem; -} - -.btn-spinner { - display: inline-block; - width: 16px; - height: 16px; - border: 2px solid rgba(255,255,255,0.25); - border-top-color: white; - border-radius: 50%; - animation: spinBtn 0.7s linear infinite; - flex-shrink: 0; -} - -/* ── 결과 없는 상태 (/results 직접 접근) ───────────────────────────── */ - -.results-empty-state { - min-height: 60vh; - display: flex; - align-items: center; - justify-content: center; -} - -.results-empty-inner { - text-align: center; - padding: 3rem 1.5rem; - max-width: 380px; -} - -.results-empty-icon { - font-size: 2.5rem; - display: block; - margin-bottom: 1.2rem; - opacity: 0.7; -} - -.results-empty-title { - font-size: 1.4rem; - font-weight: 700; - margin-bottom: 0.75rem; - color: var(--fg); -} - -.results-empty-sub { - color: var(--muted); - font-size: 0.95rem; - line-height: 1.65; - margin-bottom: 2rem; -} - -.results-empty-cta { - display: inline-block; - width: auto; - padding: 0.85rem 2rem; - font-size: 1rem; - text-decoration: none; - margin-top: 0; -} - -/* ── Fit score bars (스펙 우위 비율) ─────────────────────────────────────── */ -.fit-bars { - margin-top: 1.6rem; - padding-top: 1.4rem; - border-top: 1px solid var(--border); - display: flex; - flex-direction: column; - gap: 0.65rem; -} - -.fit-bar-row { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 140px); - align-items: center; - gap: 0.75rem; -} - -@media (max-width: 480px) { - .fit-bar-row { - grid-template-columns: minmax(0, 1fr) minmax(0, 90px); - gap: 0.5rem; - } -} - -.fit-bar-name { - font-size: 0.82rem; - font-weight: 600; - color: var(--muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.fit-winner .fit-bar-name { - color: var(--fg); - font-weight: 700; -} - -.fit-bar-track { - width: 100%; - height: 6px; - background: var(--border); - border-radius: 99px; - overflow: hidden; -} - -.fit-bar-fill { - height: 100%; - background: var(--muted); - border-radius: 99px; - transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); -} - -.fit-winner .fit-bar-fill { - background: linear-gradient(90deg, var(--accent), #6c8fff); -} - -.fit-bar-note { - font-size: 0.7rem; - color: var(--muted); - opacity: 0.55; - margin: 0.4rem 0 0; - line-height: 1.4; -} - - -/* ---- coverage note ---- */ -.coverage-note { - margin: 0.55rem 0 0; - color: color-mix(in srgb, var(--text) 62%, var(--muted)); - font-size: 0.84rem; - font-weight: 700; - line-height: 1.5; - letter-spacing: 0; -} - -.coverage-note-inline { - margin: 0.7rem 0 0.35rem; -} - -.coverage-note-home { - margin: 0.75rem auto 0; - max-width: 36rem; - text-align: center; - padding: 0.55rem 0.85rem; - border: 1px solid var(--border-light); - border-radius: 10px; - background: color-mix(in srgb, var(--bg-subtle) 88%, transparent); -} - -/* ---- spec graphs (compare bars) ---- */ -.spec-graph-section { - display: grid; - gap: 1rem; -} - -.spec-graph-list { - display: grid; - gap: 1.05rem; -} - -.spec-graph-selector { - display: grid; - gap: 0.55rem; -} - -.spec-graph-selector-label { - font-size: 0.8rem; - font-weight: 700; - color: color-mix(in srgb, var(--text) 62%, var(--muted)); -} - -.spec-graph-chips { - display: flex; - flex-wrap: wrap; - gap: 0.45rem; -} - -.spec-graph-chip { - border: 1px solid var(--border-light); - background: color-mix(in srgb, var(--bg) 88%, var(--bg-subtle)); - color: color-mix(in srgb, var(--text) 72%, var(--muted)); - border-radius: 9999px; - padding: 0.38rem 0.72rem; - font-size: 0.78rem; - font-weight: 700; - cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; -} - -.spec-graph-chip:hover { - border-color: color-mix(in srgb, var(--accent) 35%, var(--border-light)); - color: var(--text); -} - -.spec-graph-chip.is-on { - background: color-mix(in srgb, var(--accent) 14%, var(--bg)); - border-color: color-mix(in srgb, var(--accent) 45%, var(--border-light)); - color: var(--accent); -} - -.spec-graph-chip:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.spec-bar-card { - padding: 0.95rem 1rem 0.85rem; - border: 1px solid var(--border-light); - border-radius: 12px; - background: var(--bg-subtle); -} - -.spec-graph-label-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.75rem; - margin-bottom: 0.85rem; -} - -.spec-graph-label { - margin: 0; - font-size: 0.95rem; - font-weight: 800; - color: var(--text); -} - -.spec-bar-compare { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(4.5rem, 1fr)); - gap: 0.85rem; - align-items: end; - min-height: 11.5rem; -} - -.spec-bar-col { - display: grid; - grid-template-rows: auto 1fr auto; - gap: 0.4rem; - justify-items: center; - min-width: 0; -} - -.spec-bar-value { - font-size: 0.8rem; - font-weight: 800; - color: color-mix(in srgb, var(--text) 62%, var(--muted)); - font-variant-numeric: tabular-nums; - text-align: center; - line-height: 1.2; -} - -.spec-bar-track { - width: 100%; - max-width: 3.2rem; - height: 8.5rem; - border-radius: 9999px; - background: color-mix(in srgb, var(--border-light) 88%, var(--border)); - display: flex; - align-items: flex-end; - overflow: hidden; - padding: 0.22rem; -} - -.spec-bar-fill { - width: 100%; - border-radius: 9999px; - background: color-mix(in srgb, var(--text) 28%, var(--border)); - transition: height 0.7s cubic-bezier(0.22, 1, 0.36, 1); - min-height: 0.55rem; -} - -.spec-bar-name { - font-size: 0.78rem; - font-weight: 700; - color: var(--text); - text-align: center; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - - - - - -@media (max-width: 640px) { - .spec-bar-card { - padding: 0.8rem 0.75rem 0.7rem; - } - - .spec-graph-label-row { - flex-direction: column; - align-items: flex-start; - gap: 0.25rem; - } - - .spec-bar-compare { - gap: 0.55rem; - min-height: 10.5rem; - } - - .spec-bar-track { - height: 7.6rem; - max-width: 2.7rem; - } - - .spec-bar-value { - font-size: 0.74rem; - } - - .spec-bar-name { - font-size: 0.72rem; - } -} +@import "./styles/home.css"; +@import "./styles/results.css"; +@import "./styles/auth.css"; +@import "./styles/commerce.css"; diff --git a/app/page.tsx b/app/page.tsx index 7fa2512..ee6d784 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -66,14 +66,6 @@ export default async function Home() { {t.home.hero2}

    {t.home.sub}

    -
    - {t.home.proof.map((item) => ( - - {item.value} - {item.label} - - ))} -
diff --git a/app/styles/auth.css b/app/styles/auth.css new file mode 100644 index 0000000..7279f05 --- /dev/null +++ b/app/styles/auth.css @@ -0,0 +1,796 @@ +/* ════════════════════════════════════════ + Auth page — redesigned + ════════════════════════════════════════ */ + +.auth-page { + min-height: 100svh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1.5rem; + background: var(--bg); +} + +.auth-wrap { + position: relative; + z-index: 1; + width: 100%; + max-width: 360px; + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; +} + +/* Brand */ +.auth-brand { + text-align: center; +} + +.auth-logo { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 1.95rem; + font-weight: 800; + color: var(--text); + letter-spacing: -0.04em; + text-decoration: none; +} + +.auth-logo-mark { + display: flex; + align-items: center; + color: var(--accent); + flex-shrink: 0; +} + +.auth-tagline { + margin: 0.55rem 0 0; + font-size: 1rem; + font-weight: 600; + color: var(--text); + letter-spacing: -0.01em; +} + +/* Subtle dot-grid background */ +.auth-page::before { + content: ""; + position: fixed; + inset: 0; + background-image: radial-gradient(var(--border) 1px, transparent 1px); + background-size: 28px 28px; + opacity: 0.45; + pointer-events: none; + z-index: 0; +} + +/* Form wrapper — open, no card border */ +.auth-form-wrap { + width: 100%; + display: flex; + flex-direction: column; + gap: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: 20px; + padding: 2rem 1.8rem; + box-shadow: 0 4px 24px rgba(0,0,0,0.07), 0 1px 4px rgba(0,0,0,0.04); +} + +[data-theme="dark"] .auth-form-wrap { + box-shadow: 0 4px 24px rgba(0,0,0,0.3); +} + +/* Fields */ +.auth-fields { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.auth-field label { + font-size: 0.84rem; + font-weight: 600; + color: var(--text); +} + +.auth-field-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.auth-field input { + width: 100%; + padding: 0.78rem 0.95rem; + border: 1.5px solid var(--border); + border-radius: 10px; + background: var(--bg); + color: var(--text); + font-size: 0.96rem; + font-family: inherit; + transition: border-color 0.15s, box-shadow 0.15s; + box-sizing: border-box; +} + +.auth-field input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--ring); +} + +[data-theme="dark"] .auth-field input { + background: rgba(255,255,255,0.05); + border-color: rgba(255,255,255,0.12); + color: var(--text); +} + +/* Error */ +.auth-error { + margin: 0; + padding: 0.6rem 0.8rem; + background: rgba(220, 38, 38, 0.06); + border: 1px solid rgba(220, 38, 38, 0.2); + border-radius: 8px; + color: #dc2626; + font-size: 0.84rem; + line-height: 1.45; +} + +/* Forgot password — inline next to label */ +.auth-forgot { + background: none; + border: none; + color: var(--muted); + font-size: 0.82rem; + cursor: pointer; + padding: 0; + transition: color 0.15s; +} + +.auth-forgot:hover { color: var(--accent); } + +/* Primary CTA */ +.btn-auth-primary { + margin-top: 0.5rem; + width: 100%; + padding: 0.82rem 1rem; + background: var(--accent); + border: none; + border-radius: 11px; + color: white; + font-size: 0.97rem; + font-weight: 700; + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s; + box-shadow: 0 2px 10px rgba(52, 84, 232, 0.3); +} + +.btn-auth-primary:hover:not(:disabled) { + opacity: 0.92; + box-shadow: 0 4px 16px rgba(52, 84, 232, 0.4); +} + +.btn-auth-primary:disabled { + opacity: 0.45; + cursor: default; + box-shadow: none; +} + +/* Mode switch link */ +.auth-switch { + margin: 1rem 0 0; + text-align: center; + font-size: 0.86rem; + color: var(--muted); +} + +.auth-switch button { + background: none; + border: none; + color: var(--accent); + font-size: 0.86rem; + font-weight: 600; + cursor: pointer; + padding: 0; +} + +.auth-switch button:hover { text-decoration: underline; } + +/* Separator */ +.auth-sep { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.4rem 0 1rem; + color: var(--muted); + font-size: 0.78rem; + letter-spacing: 0.04em; +} + +.auth-sep::before, +.auth-sep::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + +/* Google button — secondary style */ +.btn-auth-google { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + width: 100%; + padding: 0.72rem 1rem; + background: transparent; + border: 1.5px solid var(--border); + border-radius: 11px; + color: var(--text); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.btn-auth-google:hover:not(:disabled) { + background: var(--bg-subtle); + border-color: var(--accent); +} + +.btn-auth-google:disabled { opacity: 0.45; cursor: default; } + +[data-theme="dark"] .btn-auth-google { + border-color: rgba(255,255,255,0.14); +} + +/* Guest link */ +.auth-guest { + font-size: 0.84rem; + color: var(--muted); + text-decoration: none; + transition: color 0.15s; +} + +.auth-guest:hover { color: var(--text); } + +/* Done states (email sent / reset sent) */ +.auth-done { + width: 100%; + background: var(--card); + border: 1px solid var(--border); + border-radius: 20px; + padding: 2.4rem 1.8rem; + box-shadow: 0 4px 24px rgba(0,0,0,0.07); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +.auth-done-icon { font-size: 2.4rem; } + +.auth-done h2 { + margin: 0.2rem 0 0; + font-size: 1.1rem; + font-weight: 700; +} + +.auth-done p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.6; +} + +.auth-done button { + margin-top: 0.6rem; + background: none; + border: none; + color: var(--accent); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + text-decoration: underline; +} + +.upgrade-cta { + display: block; + margin-top: 0.8rem; + color: var(--accent); + font-weight: 600; +} + +/* ---- option input (multi-way) ---- */ +.opt-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.opt-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.55rem; + min-height: 150px; + padding: 1.2rem; + border: 1px solid var(--border); + border-radius: 22px; + background: var(--card); + box-shadow: var(--shadow-md); + overflow: hidden; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} + +.opt-card:focus-within { + border-color: var(--accent); + box-shadow: 0 18px 44px rgba(52, 84, 232, 0.18); + transform: translateY(-2px); +} + +.opt-card-top { + display: flex; + align-items: center; + gap: 0.5rem; + z-index: 1; +} + +.opt-tag { + width: 28px; + height: 28px; + border-radius: 9999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + font-weight: 700; + color: white; + background: var(--accent); +} + +.opt-0 .opt-tag { background: #3454e8; } +.opt-1 .opt-tag { background: #7c5cff; } +.opt-2 .opt-tag { background: #0ea5a3; } +.opt-3 .opt-tag { background: #f59e0b; } +.opt-4 .opt-tag { background: #ec4899; } + +.opt-label { + color: var(--muted); + font-size: 0.9rem; + flex: 1; +} + +.opt-remove { + border: none; + background: transparent; + color: #c7cdd8; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; + padding: 0 0.2rem; +} + +.opt-remove:hover { color: #dc2626; } + +.opt-card input { + border: none; + outline: none; + font-size: 1.3rem; + font-weight: 600; + background: transparent; + padding: 0; + z-index: 1; +} + +.opt-card input::placeholder { + color: #c7cdd8; + font-weight: 500; +} + +.opt-watermark { + position: absolute; + right: 0.2rem; + bottom: -1.4rem; + font-size: 7rem; + font-weight: 800; + line-height: 1; + opacity: 0.07; + pointer-events: none; + color: var(--accent); +} + +.opt-0 .opt-watermark { color: #3454e8; } +.opt-1 .opt-watermark { color: #7c5cff; } +.opt-2 .opt-watermark { color: #0ea5a3; } +.opt-3 .opt-watermark { color: #f59e0b; } +.opt-4 .opt-watermark { color: #ec4899; } + +.opt-add-btn { + display: flex; + align-items: center; + gap: 0.5rem; + width: fit-content; + margin: 1rem auto 0; + border: 1px solid var(--border); + border-radius: 9999px; + background: white; + color: var(--muted); + font-size: 0.9rem; + font-weight: 600; + padding: 0.5rem 1.05rem; + cursor: pointer; + transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease; +} + +.opt-add-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(52, 84, 232, 0.04); +} + +.opt-add-count { + font-size: 0.78rem; + opacity: 0.6; +} + +/* ---- result option chips ---- */ +.result-chips { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.45rem; + margin: 0.2rem 0 1rem; +} + +.result-chip { + font-size: 0.85rem; + font-weight: 600; + color: var(--muted); + background: white; + border: 1px solid var(--border); + border-radius: 9999px; + padding: 0.3rem 0.8rem; +} + +.result-chip.win { + color: var(--accent); + border-color: rgba(52, 84, 232, 0.4); + background: rgba(52, 84, 232, 0.07); +} + +.result-chip.link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + transition: border-color 0.16s ease, color 0.16s ease, box-shadow 0.16s ease; +} + +.result-chip.link:hover { + border-color: var(--accent); + color: var(--accent); + box-shadow: 0 4px 12px rgba(52, 84, 232, 0.14); +} + +.chip-ext { + font-size: 0.72rem; + opacity: 0.6; +} + +.result-vs { + font-size: 0.72rem; + font-weight: 700; + color: #c4c9d4; +} + +/* ---- per-option analysis ---- */ +.analysis-list { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.analysis-item { + border: 1px solid var(--border); + border-radius: 16px; + padding: 1rem 1.1rem; + scroll-margin-top: 1.2rem; + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.analysis-item.winner-item { + border-color: rgba(52, 84, 232, 0.25); + border-left: 3px solid var(--accent); + background: linear-gradient(to right, rgba(52, 84, 232, 0.03), transparent 40%); +} + +[data-theme="dark"] .analysis-item.winner-item { + border-color: rgba(91, 120, 245, 0.3); + border-left: 3px solid #7c5cff; + background: linear-gradient(to right, rgba(124, 92, 255, 0.06), transparent 40%); +} + +.analysis-item:target { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--ring); +} + +.analysis-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.analysis-letter { + width: 24px; + height: 24px; + flex-shrink: 0; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.78rem; + font-weight: 700; + color: white; + background: linear-gradient(135deg, #3454e8, #7c5cff); +} + +.analysis-name { + font-weight: 700; + font-size: 1.02rem; +} + +.analysis-pick { + font-size: 0.72rem; + font-weight: 700; + color: var(--accent); + background: rgba(52, 84, 232, 0.1); + border-radius: 9999px; + padding: 0.15rem 0.5rem; +} + +.analysis-official { + margin-left: auto; + font-size: 0.82rem; + color: var(--muted); +} + +.analysis-official:hover { + color: var(--accent); +} + +.analysis-item p { + margin: 0; + line-height: 1.65; + word-break: break-word; + overflow-wrap: break-word; + color: var(--muted); + font-size: 0.9rem; +} + +/* ---- share + affiliate CTA ---- */ +.share-actions { + display: flex; + flex-direction: column; + gap: 0.8rem; + margin: 0; +} + +.share-card-preview { + position: relative; + overflow: hidden; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 14px; + background: + linear-gradient(135deg, rgba(52, 84, 232, 0.1), rgba(15, 23, 42, 0.02) 48%, rgba(52, 84, 232, 0.12)), + var(--card); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62); +} + +[data-theme="dark"] .share-card-preview { + background: + linear-gradient(135deg, rgba(91, 120, 245, 0.16), rgba(255, 255, 255, 0.04) 52%, rgba(91, 120, 245, 0.1)), + var(--card); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07); +} + +.share-card-eyebrow { + display: block; + margin-bottom: 0.5rem; + color: var(--accent); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.share-card-pick { + display: block; + color: var(--fg); + font-size: 1.02rem; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.share-card-preview p { + margin: 0.45rem 0 0; + color: var(--muted); + font-size: 0.85rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.btn-buy { + display: flex; + align-items: center; + gap: 0.65rem; + width: 100%; + padding: 1rem 1.15rem; + border-radius: 14px; + border: none; + background: #0f172a; + color: white; + font-size: 0.98rem; + font-weight: 700; + text-align: left; + cursor: pointer; + text-decoration: none; + transition: opacity 0.15s ease; +} + +[data-theme="dark"] .btn-buy { + background: #e2e8f4; + color: #0d0f14; +} + +[data-theme="dark"] .btn-buy .buy-icon { + background: rgba(0, 0, 0, 0.12); + color: #0d0f14; +} + +[data-theme="dark"] .btn-buy .buy-store { + background: rgba(0, 0, 0, 0.1); + color: #0d0f14; +} + +.btn-buy:hover { + opacity: 0.88; + text-decoration: none; +} + +.btn-buy:focus-visible, +.btn-share:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.buy-icon { + width: 22px; + height: 22px; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.15); + flex-shrink: 0; + font-size: 0.8rem; +} + +.buy-text { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.buy-copy { + flex: 1; + min-width: 0; +} + +.buy-hint { + display: block; + margin-top: 0.2rem; + color: rgba(255, 255, 255, 0.7); + font-size: 0.76rem; + font-weight: 500; + line-height: 1.2; +} + +[data-theme="dark"] .btn-buy .buy-hint { + color: rgba(13, 15, 20, 0.62); +} + +.buy-copy, +.buy-text { + overflow: hidden; + text-overflow: ellipsis; +} + +.buy-official-badge { + display: inline-block; + font-size: 0.65rem; + font-weight: 700; + background: rgba(255,255,255,0.22); + border-radius: 4px; + padding: 1px 5px; + margin-left: 5px; + vertical-align: middle; + letter-spacing: 0.02em; +} + +.buy-store { + margin-left: auto; + font-size: 0.8rem; + font-weight: 600; + opacity: 0.88; + background: rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 0.2rem 0.55rem; + white-space: nowrap; +} + +.share-row { + display: flex; + gap: 0.55rem; +} + +.btn-share { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.85rem; + padding: 0.8rem 1rem; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--card); + color: var(--fg); + font-size: 0.93rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.16s ease, color 0.16s ease, box-shadow 0.16s ease; +} + +.btn-share:disabled { + cursor: wait; + opacity: 0.72; +} + +.btn-share:hover { + border-color: var(--accent); + color: var(--accent); + box-shadow: 0 2px 8px rgba(52, 84, 232, 0.1); +} + +.share-hint { + flex-shrink: 0; + color: var(--muted); + font-size: 0.76rem; + font-weight: 500; + text-align: right; +} + +.affiliate-note { + display: grid; + gap: 0.25rem; + margin: 0; + font-size: 0.78rem; + color: var(--muted); + text-align: left; + line-height: 1.4; + overflow-wrap: anywhere; +} diff --git a/app/styles/commerce.css b/app/styles/commerce.css new file mode 100644 index 0000000..eb19221 --- /dev/null +++ b/app/styles/commerce.css @@ -0,0 +1,1967 @@ +/* ════════════════════════════════════════ + TimingSection — 구매 타이밍 + ════════════════════════════════════════ */ + +.timing-section { + padding: 1.35rem 1.5rem 1.2rem; +} + +/* 헤더 행: 라벨 + 신호 점 */ +.ts-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.ts-label { + font-size: .72rem; + font-weight: 600; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--muted); +} + +/* 신호 점 — 이모지 없이 CSS로 */ +.ts-signal { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.ts-green { background: #22c55e; box-shadow: 0 0 0 3px rgba(34,197,94,.18); } +.ts-amber { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245,158,11,.18); } +.ts-red { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.18); } +.ts-gray { background: var(--muted); } + +[data-theme="dark"] .ts-green { box-shadow: 0 0 0 3px rgba(34,197,94,.25); } +[data-theme="dark"] .ts-amber { box-shadow: 0 0 0 3px rgba(245,158,11,.25); } +[data-theme="dark"] .ts-red { box-shadow: 0 0 0 3px rgba(239,68,68,.25); } + +/* 판정 텍스트 */ +.ts-verdict { + margin: 0 0 .3rem; + font-size: 1.12rem; + font-weight: 800; + letter-spacing: -.025em; + line-height: 1.2; + color: var(--fg); +} + +.ts-sub { + margin: 0 0 1.2rem; + font-size: .82rem; + color: var(--muted); + line-height: 1.6; +} + +/* 가격 게이지 */ +.ts-gauge-wrap { margin-bottom: .6rem; } + +.ts-gauge-track { + position: relative; + height: 3px; + background: var(--border); + border-radius: 99px; + overflow: visible; + margin-bottom: .55rem; +} + +.ts-gauge-fill { + height: 100%; + border-radius: 99px; + transition: width .5s cubic-bezier(.22,1,.36,1); +} + +.ts-fill-green { background: #22c55e; } +.ts-fill-amber { background: #f59e0b; } +.ts-fill-red { background: #ef4444; } +.ts-fill-gray { background: var(--border); } + +/* 현재 가격 마커 */ +.ts-gauge-cursor { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--card); + border: 2px solid var(--fg); + box-shadow: 0 1px 4px rgba(0,0,0,.15); + transition: left .5s cubic-bezier(.22,1,.36,1); +} + +[data-theme="dark"] .ts-gauge-cursor { + box-shadow: 0 1px 6px rgba(0,0,0,.4); +} + +/* 최저가 / 현재 / 평균 레이블 */ +.ts-price-row { + display: flex; + justify-content: space-between; + font-size: .72rem; + color: var(--muted); + line-height: 1.3; + gap: .2rem; +} + +.ts-price-row span { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ts-price-row span:nth-child(2) { + text-align: center; +} + +.ts-price-row span:last-child { + text-align: right; +} + +.ts-price-current { + font-weight: 700; + color: var(--fg); +} + +@media (max-width: 400px) { + .ts-price-row { + font-size: .66rem; + } +} + +/* 다음 모델 힌트 */ +.ts-cycle { + display: flex; + align-items: center; + gap: .45rem; + padding-top: 1rem; + border-top: 1px solid var(--border-light); + font-size: .76rem; + color: var(--muted); + line-height: 1.4; +} + +.ts-cycle svg { flex-shrink: 0; opacity: .6; } + +/* ════════════════════════════════════════ + ContextCard — 맞춤 재분석 + ════════════════════════════════════════ */ + +/* 닫힌 상태: 미니멀 텍스트 버튼 */ +.cc-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 1rem 1.4rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 20px; + cursor: pointer; + text-align: left; + transition: border-color .18s, background .18s; + margin-top: .9rem; +} + +.cc-trigger:hover { + border-color: var(--muted); + background: var(--bg-subtle); +} + +[data-theme="dark"] .cc-trigger:hover { + background: rgba(255,255,255,.03); +} + +.cc-trigger-text { + display: flex; + flex-direction: column; + gap: .2rem; +} + +.cc-trigger-title { + font-size: .88rem; + font-weight: 700; + color: var(--fg); + line-height: 1.2; +} + +.cc-trigger-hint { + font-size: .76rem; + color: var(--muted); + font-weight: 400; +} + +.cc-chevron { + color: var(--muted); + flex-shrink: 0; + opacity: .6; +} + +/* 열린 상태 */ +.cc-card { + margin-top: .9rem; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--card); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.cc-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.4rem; + border-bottom: 1px solid var(--border-light); +} + +.cc-head-title { + font-size: .88rem; + font-weight: 700; + color: var(--fg); +} + +.cc-close { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 50%; + cursor: pointer; + color: var(--muted); + transition: border-color .15s, color .15s; + flex-shrink: 0; +} + +.cc-close:hover { + border-color: var(--fg); + color: var(--fg); +} + +[data-theme="dark"] .cc-close { + background: rgba(255,255,255,.05); +} + +.cc-form { + display: flex; + flex-direction: column; + gap: 1.1rem; + padding: 1.2rem 1.4rem 1.4rem; +} + +.cc-field { + display: flex; + flex-direction: column; + gap: .5rem; +} + +.cc-field-label { + font-size: .7rem; + font-weight: 700; + letter-spacing: .07em; + text-transform: uppercase; + color: var(--muted); + margin: 0; +} + +.cc-optional { + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} + +.cc-chips { + display: flex; + flex-wrap: wrap; + gap: .4rem; +} + +.cc-chip { + padding: .38rem .82rem; + border: 1px solid var(--border); + border-radius: 99px; + background: transparent; + cursor: pointer; + font-size: .8rem; + color: var(--muted); + font-weight: 500; + transition: all .13s; + line-height: 1.4; +} + +.cc-chip:hover { + border-color: var(--fg); + color: var(--fg); +} + +.cc-chip-on { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--card)); + font-weight: 700; +} + +[data-theme="dark"] .cc-chip-on { + background: color-mix(in srgb, var(--accent) 14%, var(--card)); +} + +.cc-input { + padding: .6rem .9rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-subtle); + color: var(--fg); + font-size: .84rem; + line-height: 1.5; + transition: border-color .15s, background .15s; + font-family: inherit; +} + +.cc-input:focus { + outline: none; + border-color: var(--accent); + background: var(--card); +} + +.cc-input::placeholder { + color: var(--muted); + opacity: .7; +} + +[data-theme="dark"] .cc-input { + background: rgba(255,255,255,.05); +} + +[data-theme="dark"] .cc-input:focus { + background: rgba(255,255,255,.07); +} + +.cc-submit { + display: flex; + align-items: center; + justify-content: center; + gap: .5rem; + padding: .82rem 1rem; + background: var(--fg); + color: var(--bg); + border: none; + border-radius: 12px; + cursor: pointer; + font-size: .88rem; + font-weight: 700; + letter-spacing: -.01em; + transition: opacity .15s; + font-family: inherit; + margin-top: .1rem; +} + +.cc-submit:hover { opacity: .82; } + +[data-theme="dark"] .cc-submit { + background: var(--fg); + color: #0d0f14; +} + +/* ---- price comparison (live price + history) ---- */ +.price-head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.7rem; +} + +.price-head h2 { + margin: 0; +} + +.price-demo { + font-size: 0.7rem; + font-weight: 700; + color: var(--muted); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 9999px; + padding: 0.12rem 0.5rem; +} + +.price-list { + list-style: none; + margin: 0; + padding: 0; +} + +.price-row { + display: flex; + align-items: center; + gap: 0.9rem; + padding: 0.75rem 0.6rem; + border-bottom: 1px solid var(--border); + border-radius: 10px; + flex-wrap: wrap; +} + +.price-row:last-child { + border-bottom: none; +} + +.price-row.cheapest { + background: rgba(52, 84, 232, 0.05); +} + +[data-theme="dark"] .price-row.cheapest { + background: rgba(91, 120, 245, 0.1); +} + +.price-main { + display: flex; + flex-direction: column; + gap: 0.3rem; + flex: 1; + min-width: 0; +} + +.price-name { + font-weight: 700; + font-size: 0.98rem; +} + +.price-tags { + display: flex; + gap: 0.35rem; +} + +.price-tag { + font-size: 0.68rem; + font-weight: 700; + border-radius: 9999px; + padding: 0.1rem 0.45rem; +} + +.cheapest-tag { + color: var(--accent); + background: rgba(52, 84, 232, 0.12); +} + +.deal-tag { + color: #0f9d6b; + background: rgba(15, 157, 107, 0.12); +} + +[data-theme="dark"] .deal-tag { + color: #34d399; + background: rgba(52, 211, 153, 0.14); +} + +.spark { + color: var(--muted); + flex-shrink: 0; +} + +.price-figures { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.15rem; + flex-shrink: 0; +} + +.price-now { + font-size: 1.05rem; + font-weight: 800; + letter-spacing: -0.01em; + color: var(--text); + transition: color 0.14s ease; +} + +.price-now:hover { + color: var(--accent); +} + +.price-go { + font-size: 0.75rem; + color: var(--muted); +} + +.price-sub { + font-size: 0.76rem; + color: var(--muted); +} + +@media (max-width: 480px) { + .price-figures { + align-items: flex-start; + width: 100%; + } +} + +/* ---- watch / track ---- */ +.watch-btn-wrap { + position: relative; + display: inline-block; +} + +.watch-btn { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: 9999px; + background: var(--card); + color: var(--muted); + font-size: 0.78rem; + font-weight: 600; + padding: 0.34rem 0.7rem; + cursor: pointer; + transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease; +} + +.watch-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.watch-btn.on { + border-color: var(--accent); + color: var(--accent); + background: rgba(52, 84, 232, 0.08); +} + +[data-theme="dark"] .watch-btn.on { + background: rgba(91, 120, 245, 0.14); +} + +/* Home watchlist card */ +.watch-card { + margin-top: 1.5rem; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--card); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.watch-head { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.85rem 1.2rem; + border-bottom: 1px solid var(--border); +} + +.watch-head h3 { + margin: 0; + font-size: 0.92rem; + font-weight: 700; +} + +.watch-count { + font-size: 0.7rem; + font-weight: 700; + color: var(--accent); + background: rgba(52, 84, 232, 0.1); + border-radius: 9999px; + padding: 0.1rem 0.5rem; +} + +.watch-items { + list-style: none; + margin: 0; + padding: 0; +} + +.watch-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1.2rem; + border-bottom: 1px solid var(--border); +} + +.watch-item:last-child { + border-bottom: none; +} + +.watch-name { + font-weight: 600; + font-size: 0.95rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.watch-right { + display: flex; + align-items: center; + gap: 0.8rem; + flex-shrink: 0; +} + +.watch-price { + font-weight: 800; + font-size: 0.96rem; + color: var(--text); + transition: color 0.14s ease; +} + +.watch-price:hover { + color: var(--accent); +} + +.watch-remove { + width: 28px; + height: 28px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: #b8c0d0; + font-size: 1.05rem; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease; +} + +.watch-remove:hover { + background: rgba(220, 38, 38, 0.08); + border-color: rgba(220, 38, 38, 0.2); + color: #dc2626; +} + +.watch-note { + margin: 0; + padding: 0.7rem 1.2rem 0.9rem; + font-size: 0.8rem; + color: var(--muted); + border-top: 1px solid var(--border); +} + +/* ---- watch push prompt ---- */ +.watch-push-prompt { + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + z-index: 200; + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem 1.1rem 0.9rem; + width: 240px; + box-shadow: 0 8px 24px rgba(0,0,0,0.12); +} + +[data-theme="dark"] .watch-push-prompt { + box-shadow: 0 8px 24px rgba(0,0,0,0.4); +} + +.watch-push-sub { + margin: 0 0 0.8rem; + font-size: 0.76rem; + color: var(--muted); + padding-right: 1rem; + line-height: 1.4; +} + +.watch-push-denied { + padding: 0.7rem 1rem; +} + +.watch-push-denied-msg { + margin: 0; + font-size: 0.78rem; + color: var(--muted); + padding-right: 1.4rem; + line-height: 1.4; +} + +/* ---- watch email modal ---- */ +.watch-email-modal { + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + z-index: 200; + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem 1.1rem 0.9rem; + width: 260px; + box-shadow: 0 8px 24px rgba(0,0,0,0.12); +} + +[data-theme="dark"] .watch-email-modal { + box-shadow: 0 8px 24px rgba(0,0,0,0.4); +} + +.watch-email-close { + position: absolute; + top: 0.5rem; + right: 0.6rem; + background: none; + border: none; + font-size: 1rem; + color: var(--muted); + cursor: pointer; + padding: 0 0.2rem; + line-height: 1; +} + +.watch-email-label { + margin: 0 0 0.7rem; + font-size: 0.82rem; + font-weight: 600; + color: var(--fg); + padding-right: 1rem; +} + +.watch-email-form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.watch-email-input { + width: 100%; + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg); + color: var(--fg); + font-size: 0.82rem; + box-sizing: border-box; +} + +.watch-email-input:focus { + outline: none; + border-color: var(--accent, #3b82f6); +} + +.watch-email-confirm { + width: 100%; + padding: 0.45rem; + background: var(--fg); + color: var(--bg); + border: none; + border-radius: 7px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} + +.watch-email-confirm:disabled { + opacity: 0.5; + cursor: default; +} + +.watch-email-skip { + display: block; + width: 100%; + margin-top: 0.4rem; + background: none; + border: none; + font-size: 0.75rem; + color: var(--muted); + cursor: pointer; + text-align: center; + padding: 0.2rem 0; +} + +.watch-email-skip:hover { + color: var(--fg); +} + +/* ---- share watermark (Free shared pages) ---- */ +.share-watermark { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: white; + border-top: 1px solid var(--border); + padding: 1rem 1.2rem 1.4rem; + text-align: center; + box-shadow: 0 -8px 32px rgba(17, 24, 39, 0.1); + z-index: 10; +} + +.share-watermark p { + margin: 0 0 0.1rem; + font-size: 0.95rem; + color: var(--muted); +} + +.share-watermark strong { + color: var(--text); +} + +.vs-upsell { + text-align: center; + color: var(--muted); + font-size: 0.9rem; + margin: 0.9rem 0 0; +} + +.vs-upsell a { + color: var(--accent); + font-weight: 600; +} + +/* ---- comparison table (N columns) ---- */ +.cmp-table { + display: flex; + flex-direction: column; + width: 100%; +} + +.cmp-row { + display: grid; + grid-template-columns: minmax(80px, 0.85fr) repeat(var(--cols), minmax(0, 1fr)); + column-gap: 0.5rem; + align-items: start; +} + +.cmp-key, +.cmp-col { + padding: 0.7rem 0.6rem; + border-bottom: 1px solid var(--border); +} + +.cmp-row:last-child .cmp-key, +.cmp-row:last-child .cmp-col { + border-bottom: none; +} + +.cmp-key { + color: var(--muted); + font-size: 0.85rem; + font-weight: 600; + align-self: start; + padding-top: 0.9rem; + min-width: 0; + word-break: keep-all; +} + +.cmp-col { + font-size: 0.9rem; + line-height: 1.4; + word-break: break-word; + overflow-wrap: break-word; + min-width: 0; +} + +.cmp-head .cmp-col { + font-weight: 700; + color: var(--text); + display: flex; + flex-direction: column; + gap: 0.3rem; + padding-top: 0.55rem; +} + +/* Continuous winner highlight band */ +.cmp-col.winner { + background: rgba(52, 84, 232, 0.06); + color: var(--accent); +} + +[data-theme="dark"] .cmp-col.winner { + background: rgba(91, 120, 245, 0.12); +} + +.cmp-head .cmp-col.winner { + border-top-left-radius: 12px; + border-top-right-radius: 12px; + color: var(--accent); +} + +.cmp-row:last-child .cmp-col.winner { + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; +} + +.cmp-win-tag { + align-self: flex-start; + font-size: 0.62rem; + font-weight: 700; + color: white; + background: var(--accent); + border-radius: 9999px; + padding: 0.08rem 0.5rem; + letter-spacing: 0.02em; +} + +/* ---- account hero / usage ---- */ +.account-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + text-align: center; + margin: 1rem 0 1.4rem; +} + +.account-avatar { + width: 64px; + height: 64px; + border-radius: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #3454e8, #7c5cff); + color: white; + font-size: 1.8rem; + font-weight: 700; +} + +.nickname-view { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.nickname-view h1 { + margin: 0; + font-size: 1.7rem; +} + +.nickname-edit { + border: 1px solid var(--border); + background: white; + border-radius: 9999px; + font-size: 0.8rem; + color: var(--muted); + padding: 0.25rem 0.7rem; + cursor: pointer; + transition: border-color 0.16s ease, color 0.16s ease; +} + +.nickname-edit:hover { + border-color: var(--accent); + color: var(--accent); +} + +.nickname-edit-row { + width: 100%; + max-width: 320px; +} + +.nickname-edit-row input { + width: 100%; + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.8rem; + font-size: 1.1rem; + text-align: center; +} + +.nickname-edit-row input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--ring); +} + +.nickname-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.nickname-actions .btn-primary, +.nickname-actions .btn-outline { + margin-top: 0; +} + +.usage-head { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.usage-head h2 { margin: 0; } + +.usage-count { + font-weight: 700; + color: var(--accent); +} + +.usage-bar { + height: 10px; + border-radius: 9999px; + background: var(--bg); + overflow: hidden; + margin: 0.8rem 0 0.6rem; +} + +.usage-fill { + display: block; + height: 100%; + border-radius: 9999px; + background: linear-gradient(90deg, #3454e8, #7c5cff); + transition: width 0.3s ease; +} + +.usage-sub { + margin: 0; + color: var(--muted); + font-size: 0.88rem; +} + +/* ---- history ---- */ +.history-head { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.85rem 1.2rem; + border-bottom: 1px solid var(--border); +} + +.history-head h3 { + margin: 0; + font-size: 0.92rem; + font-weight: 700; +} + +.history-count { + font-size: 0.7rem; + font-weight: 700; + color: var(--muted); + background: var(--bg); + border-radius: 9999px; + padding: 0.1rem 0.5rem; +} + +.history-items { + list-style: none; + padding: 0; + margin: 0; +} + +.history-item { + display: flex; + align-items: center; + gap: 0; + border-bottom: 1px solid var(--border); + transition: background 0.14s ease; +} + +.history-item:last-child { border-bottom: none; } + +.history-item:hover { + background: rgba(52, 84, 232, 0.03); +} + +[data-theme="dark"] .history-item:hover { + background: rgba(255, 255, 255, 0.03); +} + +/* Link area fills the row */ +.history-item-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.95rem 0.5rem 0.95rem 1.2rem; + text-decoration: none; +} + +/* Matchup: what was compared */ +.history-matchup { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + font-size: 0.96rem; + line-height: 1.3; +} + +.hm-part { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.hm-opt { + color: var(--text); + font-weight: 600; +} + +.hm-win { + color: var(--text); + font-weight: 600; +} + +.hm-vs { + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #c4c9d4; +} + +/* Verdict: which one AXIS picked */ +.history-verdict { + display: inline-flex; + align-items: center; + gap: 0.38rem; + font-size: 0.82rem; + font-weight: 700; + color: var(--accent); + transition: opacity 0.14s ease; +} + +.verdict-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + border-radius: 9999px; + background: rgba(52, 84, 232, 0.12); + font-size: 0.58rem; + font-weight: 800; +} + +[data-theme="dark"] .verdict-mark { + background: rgba(91, 120, 245, 0.2); +} + +.history-item:hover .history-matchup { + opacity: 0.85; +} + +/* Date + delete button */ +.history-item-meta { + display: flex; + align-items: center; + gap: 0.3rem; + flex-shrink: 0; + padding-right: 0.6rem; +} + +.history-item-date { + color: var(--muted); + font-size: 0.75rem; + white-space: nowrap; + padding-right: 0.2rem; +} + +@media (max-width: 400px) { + .history-item-date { display: none; } +} + +.history-delete { + width: 30px; + height: 30px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: #b8c0d0; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease; +} + +.history-delete:hover { + background: rgba(220, 38, 38, 0.08); + border-color: rgba(220, 38, 38, 0.2); + color: #dc2626; +} + +.history-delete:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Empty / login prompt */ +.history-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + padding: 2rem 1.5rem; + text-align: center; +} + +.history-empty p { + margin: 0; + color: var(--muted); + font-size: 0.88rem; +} + +.history-empty-cta { + margin-top: 0.2rem; + color: var(--accent); + font-weight: 600; + font-size: 0.9rem; +} + +@media (max-width: 820px) { + .example-grid { + grid-template-columns: 1fr; + } + + .home-method { + grid-template-columns: 1fr; + } + + .feature-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 560px) { + .container { + width: 100%; + padding: 0.75rem 1.2rem 3rem; + } + + .topbar { + margin-bottom: 1.2rem; + } + + .hero { + margin: 0.6rem 0 1rem; + } + + .hero h1 { + font-size: 1.65rem; + } + + .sub { + font-size: 0.93rem; + } + + .home-section { + margin-top: 1.8rem; + } + + /* Verdict hero: tighter on mobile */ + .verdict-hero { + padding: 1.4rem 1.25rem 1.3rem; + border-radius: 18px; + } + + .vh-title { + font-size: 1.6rem; + letter-spacing: -0.025em; + } + + .vh-conclusion { + font-size: 0.94rem; + } + + .vh-matchup { + margin-top: 1.3rem; + padding-top: 1.2rem; + gap: 0.85rem; + } + + .detail-card { + padding: 1rem 1.1rem; + border-radius: 16px; + margin-top: 0.75rem; + } + + .detail-card h2 { + font-size: 0.82rem; + margin-bottom: 0.85rem; + } + + /* Buttons: not too huge on mobile */ + .btn-buy { + padding: 0.9rem 1rem; + font-size: 0.92rem; + border-radius: 12px; + } + + .btn-share { + align-items: flex-start; + flex-direction: column; + gap: 0.2rem; + padding: 0.65rem 1rem; + font-size: 0.88rem; + border-radius: 12px; + } + + .share-hint { + text-align: left; + } + + .share-actions-wrap { + padding: 0.9rem 1.1rem 1.1rem; + } + + .btn-primary { + padding: 0.82rem 1rem; + font-size: 0.95rem; + border-radius: 12px; + margin-top: 0.9rem; + } + + .buy-store { + font-size: 0.75rem; + } + + /* Input on mobile */ + .vs-card { + padding: 0.9rem; + min-height: 148px; + } + + .opt-submit { + margin-top: 0.9rem; + } + + /* Share actions spacing */ + .share-actions { + gap: 0.55rem; + } + + /* Reason list */ + .reason-list li { + font-size: 0.9rem; + padding: 0.65rem 0; + } + + /* Back button */ + .btn-back { + font-size: 0.85rem; + margin-bottom: 0.7rem; + } + + /* Results body spacing */ + .results-body { + padding-bottom: 2.5rem; + } + + /* Topbar: tighten brand + nav gap */ + .brand { + font-size: 1.45rem; + } + + .topbar-right { + gap: 0.3rem; + } + + /* Input cards: compact */ + .opt-card { + min-height: 128px; + padding: 0.9rem 1rem; + } + + .vs-card input, + .opt-card input { + font-size: 1.15rem; + } + + .buy-timing-block { + border-radius: 16px; + } + + .ts-verdict { + font-size: 1rem; + } +} + +/* ── compare pages ─────────────────────────────────────────────────────── */ + +.compare-breadcrumb { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.9rem 1.2rem 0; + font-size: 0.8rem; + color: var(--muted); + max-width: 680px; + margin: 0 auto; +} + +.compare-breadcrumb a { + color: var(--muted); + text-decoration: none; +} + +.compare-breadcrumb a:hover { color: var(--fg); } + +/* related comparisons */ +.compare-related { + max-width: 680px; + margin: 0 auto 2rem; + padding: 1.4rem 1.2rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; +} + +.compare-related-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.compare-related-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.compare-related-link { + font-size: 0.9rem; + font-weight: 500; + color: var(--fg); + text-decoration: none; +} + +.compare-related-link:hover { text-decoration: underline; } + +.compare-related-desc { + margin: 0.15rem 0 0; + font-size: 0.78rem; + color: var(--muted); +} + +/* CTA */ +.compare-cta { + max-width: 680px; + margin: 0 auto 3rem; + padding: 1.4rem 1.2rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + text-align: center; +} + +.compare-cta-copy { + margin: 0 0 1rem; + font-size: 0.9rem; + color: var(--muted); +} + +.compare-cta-btn { + display: inline-block; + padding: 0.7rem 1.4rem; + font-size: 0.9rem; +} + +/* index page */ +.compare-index-section { + margin-bottom: 2rem; +} + +.compare-index-category { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .07em; + color: var(--muted); + margin: 0 0 0.8rem; +} + +.compare-index-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.compare-index-item { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--card); +} + +.compare-index-link { + display: block; + padding: 0.85rem 1rem; + text-decoration: none; + color: var(--fg); +} + +.compare-index-link:hover { background: var(--hover, rgba(0,0,0,.03)); border-radius: 10px; } + +.compare-index-title { + font-size: 0.92rem; + font-weight: 600; +} + +.compare-index-desc { + margin: 0.2rem 0 0; + font-size: 0.78rem; + color: var(--muted); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Analyze Overlay — "Axis에게 맡기기" 클릭 후 프리미엄 분석 로딩 화면 + ═══════════════════════════════════════════════════════════════════════════ */ + +@keyframes overlayFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalRise { + from { + opacity: 0; + transform: translateY(28px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes indeterminate { + 0% { transform: translateX(-100%) scaleX(0.4); } + 40% { transform: translateX(30%) scaleX(0.8); } + 100% { transform: translateX(350%) scaleX(0.4); } +} + +@keyframes pillGlow { + 0%, 100% { border-color: rgba(255,255,255,0.12); } + 50% { border-color: rgba(91,120,245,0.5); box-shadow: 0 0 18px rgba(91,120,245,0.25); } +} + +.analyze-overlay { + position: fixed; + inset: 0; + background: rgba(6, 8, 16, 0.90); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + animation: overlayFadeIn 0.22s ease; +} + +.analyze-modal { + width: min(440px, 88vw); + padding: 2.8rem 2rem 2.4rem; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 28px; + text-align: center; + animation: modalRise 0.32s cubic-bezier(0.18, 0, 0.08, 1); +} + +.analyze-logo { + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba(255,255,255,0.22); + margin-bottom: 2rem; +} + +.analyze-comparing { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 0.8rem; + margin-bottom: 2.2rem; + min-height: 2.6rem; +} + +.analyze-pill { + display: inline-block; + padding: 0.55rem 1.05rem; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 100px; + font-size: 0.95rem; + font-weight: 600; + color: #e8ecf8; + animation: pillGlow 2.4s ease-in-out infinite; +} + +.analyze-vs-badge { + flex-shrink: 0; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255,255,255,0.25); + padding: 0 0.1rem; +} + +.analyze-bar { + width: 100%; + height: 2px; + background: rgba(255,255,255,0.05); + border-radius: 2px; + overflow: hidden; + margin: 0 auto 1.8rem; + max-width: 240px; +} + +.analyze-progress { + height: 100%; + width: 40%; + background: linear-gradient(90deg, transparent 0%, #5b78f5 40%, #9474ff 70%, transparent 100%); + border-radius: 2px; + transform-origin: left; + animation: indeterminate 1.5s ease-in-out infinite; +} + +.analyze-status { + color: rgba(255,255,255,0.3); + font-size: 0.82rem; + letter-spacing: 0.02em; +} + +/* ── 버튼 로딩 상태 ──────────────────────────────────────────────────── */ + +@keyframes spinBtn { + to { transform: rotate(360deg); } +} + +.btn-loading { + opacity: 0.7 !important; + cursor: not-allowed; +} + +.btn-loading-inner { + display: inline-flex; + align-items: center; + gap: 0.55rem; +} + +.btn-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(255,255,255,0.25); + border-top-color: white; + border-radius: 50%; + animation: spinBtn 0.7s linear infinite; + flex-shrink: 0; +} + +/* ── 결과 없는 상태 (/results 직접 접근) ───────────────────────────── */ + +.results-empty-state { + min-height: 60vh; + display: flex; + align-items: center; + justify-content: center; +} + +.results-empty-inner { + text-align: center; + padding: 3rem 1.5rem; + max-width: 380px; +} + +.results-empty-icon { + font-size: 2.5rem; + display: block; + margin-bottom: 1.2rem; + opacity: 0.7; +} + +.results-empty-title { + font-size: 1.4rem; + font-weight: 700; + margin-bottom: 0.75rem; + color: var(--fg); +} + +.results-empty-sub { + color: var(--muted); + font-size: 0.95rem; + line-height: 1.65; + margin-bottom: 2rem; +} + +.results-empty-cta { + display: inline-block; + width: auto; + padding: 0.85rem 2rem; + font-size: 1rem; + text-decoration: none; + margin-top: 0; +} + +/* ── Fit score bars (스펙 우위 비율) ─────────────────────────────────────── */ +.fit-bars { + margin-top: 1.6rem; + padding-top: 1.4rem; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.fit-bar-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 140px); + align-items: center; + gap: 0.75rem; +} + +@media (max-width: 480px) { + .fit-bar-row { + grid-template-columns: minmax(0, 1fr) minmax(0, 90px); + gap: 0.5rem; + } +} + +.fit-bar-name { + font-size: 0.82rem; + font-weight: 600; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.fit-winner .fit-bar-name { + color: var(--fg); + font-weight: 700; +} + +.fit-bar-track { + width: 100%; + height: 6px; + background: var(--border); + border-radius: 99px; + overflow: hidden; +} + +.fit-bar-fill { + height: 100%; + background: var(--muted); + border-radius: 99px; + transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); +} + +.fit-winner .fit-bar-fill { + background: linear-gradient(90deg, var(--accent), #6c8fff); +} + +.fit-bar-note { + font-size: 0.7rem; + color: var(--muted); + opacity: 0.55; + margin: 0.4rem 0 0; + line-height: 1.4; +} + + +/* ---- coverage note ---- */ +.coverage-note { + margin: 0.45rem 0 0; + color: var(--muted); + font-size: 0.78rem; + font-weight: 500; + line-height: 1.4; + letter-spacing: 0.01em; +} + +.coverage-note-inline { + margin: 0.7rem 0 0.35rem; +} + +.coverage-note-home { + margin: 0.55rem auto 0; + max-width: 36rem; + text-align: center; +} + +/* ---- spec graphs (compare bars) ---- */ +.spec-graph-section { + display: grid; + gap: 1rem; +} + +.spec-graph-list { + display: grid; + gap: 1.05rem; +} + +.spec-graph-selector { + display: grid; + gap: 0.55rem; +} + +.spec-graph-selector-label { + font-size: 0.8rem; + font-weight: 700; + color: color-mix(in srgb, var(--text) 62%, var(--muted)); +} + +.spec-graph-chips { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.spec-graph-chip { + border: 1px solid var(--border-light); + background: color-mix(in srgb, var(--bg) 88%, var(--bg-subtle)); + color: color-mix(in srgb, var(--text) 72%, var(--muted)); + border-radius: 9999px; + padding: 0.38rem 0.72rem; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.spec-graph-chip:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-light)); + color: var(--text); +} + +.spec-graph-chip.is-on { + background: color-mix(in srgb, var(--accent) 14%, var(--bg)); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border-light)); + color: var(--accent); +} + +.spec-graph-chip:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.spec-bar-card { + padding: 0.95rem 1rem 0.85rem; + border: 1px solid var(--border-light); + border-radius: 12px; + background: var(--bg-subtle); +} + +.spec-graph-label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.85rem; +} + +.spec-graph-label { + margin: 0; + font-size: 0.95rem; + font-weight: 800; + color: var(--text); +} + +.spec-bar-compare { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(4.5rem, 1fr)); + gap: 0.85rem; + align-items: end; + min-height: 11.5rem; +} + +.spec-bar-col { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 0.4rem; + justify-items: center; + min-width: 0; +} + +.spec-bar-value { + font-size: 0.8rem; + font-weight: 800; + color: color-mix(in srgb, var(--text) 62%, var(--muted)); + font-variant-numeric: tabular-nums; + text-align: center; + line-height: 1.2; +} + +.spec-bar-track { + width: 100%; + max-width: 3.2rem; + height: 8.5rem; + border-radius: 9999px; + background: color-mix(in srgb, var(--border-light) 88%, var(--border)); + display: flex; + align-items: flex-end; + overflow: hidden; + padding: 0.22rem; +} + +.spec-bar-fill { + width: 100%; + border-radius: 9999px; + background: color-mix(in srgb, var(--text) 22%, var(--border)); + transition: height 0.7s cubic-bezier(0.22, 1, 0.36, 1); + min-height: 0.55rem; +} + +/* Better value for this metric (higher or lower, per schema) */ +.spec-bar-fill.is-best { + background: linear-gradient(180deg, var(--accent-2), var(--accent)); +} + +.spec-bar-name { + font-size: 0.78rem; + font-weight: 700; + color: var(--text); + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + + + + +@media (max-width: 640px) { + .spec-bar-card { + padding: 0.8rem 0.75rem 0.7rem; + } + + .spec-graph-label-row { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .spec-bar-compare { + gap: 0.55rem; + min-height: 10.5rem; + } + + .spec-bar-track { + height: 7.6rem; + max-width: 2.7rem; + } + + .spec-bar-value { + font-size: 0.74rem; + } + + .spec-bar-name { + font-size: 0.72rem; + } +} diff --git a/app/styles/home.css b/app/styles/home.css new file mode 100644 index 0000000..f67effb --- /dev/null +++ b/app/styles/home.css @@ -0,0 +1,664 @@ +/* ===== Home: compact, input-first hero ===== */ +.hero { + text-align: center; + margin: 1.4rem 0 1.6rem; +} + +.hero.compact { + margin-top: 2rem; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 0.45rem; + background: rgba(255, 255, 255, 0.72); + border: 1px solid var(--border); + color: var(--muted); + padding: 0.34rem 0.85rem 0.34rem 0.7rem; + border-radius: 9999px; + font-size: 0.8rem; + font-weight: 600; + box-shadow: var(--shadow-sm); +} + +.badge::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 9999px; + background: var(--accent); + box-shadow: 0 0 0 3px rgba(52, 84, 232, 0.18); +} + +[data-theme="dark"] .badge { + background: rgba(255, 255, 255, 0.05); +} + +.hero h1 { + margin: 0.85rem 0 0.7rem; + font-size: clamp(1.85rem, 4.4vw, 2.9rem); + font-weight: 700; + line-height: 1.18; + letter-spacing: -0.025em; +} + +.hero h1 span { + color: var(--accent); +} + +.sub { + color: var(--muted); + font-size: 1.02rem; + max-width: 520px; + margin: 0 auto; + line-height: 1.65; + white-space: pre-line; +} + +/* ===== Home sections ===== */ +.home-section { + margin-top: 2.6rem; +} + +.section-copy { + max-width: 640px; + margin-bottom: 1.1rem; +} + +.section-copy.center { + margin-left: auto; + margin-right: auto; + text-align: center; +} + +.section-kicker { + margin: 0 0 0.4rem; + color: var(--accent); + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.section-copy h2 { + margin: 0; + font-size: clamp(1.35rem, 2.6vw, 1.75rem); + font-weight: 800; + line-height: 1.25; + letter-spacing: -0.02em; +} + +.section-copy p:not(.section-kicker) { + margin: 0.55rem 0 0; + color: var(--muted); + font-size: 0.95rem; + line-height: 1.6; +} + +/* ===== Example chips (one-tap prefill) ===== */ +.example-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.8rem; +} + +.example-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.3rem; + width: 100%; + text-align: left; + font: inherit; + cursor: pointer; + border: 1px solid var(--border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + box-shadow: var(--shadow-sm); + padding: 1rem 1.1rem 0.9rem; + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} + +[data-theme="dark"] .example-card { + background: rgba(255, 255, 255, 0.04); +} + +.example-card:hover { + transform: translateY(-2px); + border-color: var(--accent); + box-shadow: 0 14px 30px rgba(52, 84, 232, 0.16); +} + +.example-cat { + color: var(--accent); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.example-query { + font-size: 1.02rem; + font-weight: 700; + line-height: 1.3; + letter-spacing: -0.01em; +} + +.example-note { + color: var(--muted); + font-size: 0.86rem; + line-height: 1.5; +} + +.example-go { + margin-top: 0.55rem; + color: var(--muted); + font-size: 0.82rem; + font-weight: 600; + transition: color 0.16s ease, transform 0.16s ease; +} + +.example-card:hover .example-go { + color: var(--accent); + transform: translateX(2px); +} + +/* ===== Method band ===== */ +/* ── popular comparison grid ── */ +.home-compare-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.65rem; +} + +.home-compare-card { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--card); + transition: box-shadow .15s; +} + +.home-compare-card:hover { + box-shadow: 0 2px 12px rgba(0,0,0,.08); +} + +[data-theme="dark"] .home-compare-card:hover { + box-shadow: 0 2px 12px rgba(0,0,0,.3); +} + +.home-compare-link { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.9rem 1rem; + text-decoration: none; + color: var(--fg); +} + +.home-compare-cat { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--muted); +} + +.compare-data-note { + margin: 0.4rem 0 0; + font-size: 0.85rem; + color: var(--muted); +} + +.home-compare-rank { + font-size: 0.7rem; + font-weight: 800; + color: var(--accent); + opacity: 0.7; +} + +.home-compare-title { + font-size: 0.88rem; + font-weight: 700; + line-height: 1.35; +} + +.home-compare-arrow { + font-size: 0.8rem; + color: var(--muted); + margin-top: 0.1rem; +} + +.home-compare-more { + margin-top: 0.9rem; + text-align: center; + font-size: 0.85rem; +} + +.home-compare-more a { + color: var(--muted); + text-decoration: none; +} + +.home-compare-more a:hover { + color: var(--fg); +} + +/* ── end popular comparison grid ── */ + +.home-method { + display: grid; + grid-template-columns: minmax(220px, 0.8fr) 1.2fr; + gap: 1.4rem; + align-items: start; +} + +.method-grid { + display: grid; + gap: 0.7rem; +} + +.method-item { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 0.85rem; + row-gap: 0.25rem; + padding: 0.95rem 1.1rem; + border: 1px solid var(--border); + border-radius: 16px; + background: rgba(255, 255, 255, 0.74); + box-shadow: var(--shadow-sm); +} + +[data-theme="dark"] .method-item { + background: rgba(255, 255, 255, 0.04); +} + +.method-item span { + grid-row: span 2; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 9px; + background: rgba(52, 84, 232, 0.1); + color: var(--accent); + font-size: 0.82rem; + font-weight: 800; +} + +[data-theme="dark"] .method-item span { + background: rgba(91, 120, 245, 0.16); +} + +.method-item h3 { + margin: 0; + font-size: 0.98rem; +} + +.method-item p { + margin: 0; + color: var(--muted); + line-height: 1.55; + font-size: 0.9rem; +} + +.method-em { + font-weight: 600; + color: var(--fg); +} + +/* ── Feature grid (replaces method 01/02/03 cards) ── */ +.feature-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.55rem; + align-content: start; +} + +.feature-item { + display: flex; + flex-direction: column; + gap: 0.18rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(52, 84, 232, 0.04); +} + +[data-theme="dark"] .feature-item { + background: rgba(91, 120, 245, 0.07); + border-color: var(--border); +} + +.feature-item strong { + font-size: 0.86rem; + font-weight: 700; + color: var(--fg); + line-height: 1.3; +} + +.feature-item span { + font-size: 0.78rem; + color: var(--muted); + line-height: 1.4; +} + +/* ── Rank list (popular comparisons) ── */ +.home-rank-wrap { + position: relative; + max-height: 305px; + overflow: hidden; +} + +.home-rank-list { + list-style: none; + margin: 0; + padding: 0; +} + +.home-rank-item { + border-bottom: 1px solid var(--border-light); +} + +.home-rank-item:first-child { + border-top: 1px solid var(--border-light); +} + +.home-rank-link { + display: flex; + align-items: center; + gap: 0.9rem; + padding: 0.78rem 0.4rem; + color: var(--fg); + text-decoration: none; + background: none; + border: none; + width: 100%; + cursor: pointer; + font-family: inherit; + text-align: left; + transition: color 0.15s ease; +} + +.home-rank-link:hover { + color: var(--accent); +} + +.home-rank-link:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.home-rank-link.home-rank-loading { + color: var(--accent); +} + +.home-rank-num { + font-size: 0.78rem; + font-weight: 800; + color: var(--accent); + min-width: 18px; + text-align: right; + opacity: 0.8; + flex-shrink: 0; +} + +.home-rank-text { + font-size: 0.93rem; + font-weight: 600; + line-height: 1.35; + flex: 1; +} + +.home-rank-arrow { + font-size: 0.8rem; + color: var(--muted); + flex-shrink: 0; + transition: transform 0.15s ease; +} + +.home-rank-link:hover .home-rank-arrow { + transform: translateX(3px); + color: var(--accent); +} + +@keyframes rankSpin { + to { transform: rotate(360deg); } +} + +.home-rank-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--accent); + border-top-color: transparent; + border-radius: 50%; + animation: rankSpin 0.7s linear infinite; + flex-shrink: 0; +} + +.home-rank-fade { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 88px; + background: linear-gradient(to bottom, transparent, var(--bg) 90%); + pointer-events: none; +} + +.vs-shell { + margin-top: 0.5rem; +} + +.vs-grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1rem; + align-items: stretch; +} + +.vs-divider { + align-self: center; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + opacity: 0.5; + padding: 0 0.25rem; + user-select: none; +} + +.vs-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.55rem; + min-height: 180px; + padding: 1.2rem; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--card); + box-shadow: var(--shadow-md); + overflow: hidden; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} + +.vs-card:focus-within { + border-color: var(--accent); + box-shadow: 0 18px 44px rgba(52, 84, 232, 0.18); + transform: translateY(-2px); +} + +.vs-card input { + border: none; + outline: none; + font-size: 1.35rem; + font-weight: 600; + background: transparent; + padding: 0; + z-index: 1; +} + +.vs-card input::placeholder { + color: #c7cdd8; + font-weight: 500; +} + +.vs-tag { + width: 28px; + height: 28px; + border-radius: 9999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + font-weight: 700; + color: white; + z-index: 1; +} + +.vs-card-a .vs-tag { + background: #3454e8; +} + +.vs-card-b .vs-tag { + background: #7c5cff; +} + +.vs-label { + color: var(--muted); + font-size: 0.9rem; + z-index: 1; +} + +.vs-watermark { + position: absolute; + right: 0.2rem; + bottom: -1.4rem; + font-size: 8rem; + font-weight: 800; + line-height: 1; + opacity: 0.08; + pointer-events: none; +} + +.vs-card-a .vs-watermark { + color: #3454e8; +} + +.vs-card-b .vs-watermark { + color: #7c5cff; +} + +@media (max-width: 560px) { + .vs-grid { + grid-template-columns: 1fr; + } + + .vs-divider { + text-align: center; + padding: 0.2rem 0; + } +} + +.btn-primary { + width: 100%; + margin-top: 1.2rem; + border: none; + border-radius: 14px; + padding: 0.9rem 1rem; + color: white; + font-size: 1rem; + font-weight: 700; + background: linear-gradient(135deg, #1a2238 0%, #2d3f86 55%, #4b54c9 100%); + cursor: pointer; + transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease; + box-shadow: 0 8px 20px rgba(45, 63, 134, 0.22); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 14px 28px rgba(45, 63, 134, 0.3); +} + +.btn-primary:active:not(:disabled) { + transform: translateY(0); +} + +.btn-primary.large { + padding: 1.05rem 1rem; + font-size: 1.05rem; +} + +.btn-primary:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; +} + +.btn-outline { + width: 100%; + margin-top: 0.8rem; + border: 1px solid var(--border); + border-radius: 14px; + padding: 0.85rem 1rem; + text-align: center; + background: white; + cursor: pointer; + font-weight: 600; + transition: border-color 0.16s ease, color 0.16s ease, transform 0.16s ease; +} + +.btn-outline:hover { + border-color: var(--accent); + color: var(--accent); + transform: translateY(-1px); +} + +.btn-primary.block { + display: block; + text-align: center; +} + +.history-card, +.result-card, +.detail-card { + border: 1px solid var(--border); + border-radius: 20px; + background: var(--card); + box-shadow: var(--shadow-sm); +} + +[data-theme="dark"] .history-card, +[data-theme="dark"] .result-card, +[data-theme="dark"] .detail-card { + background: var(--card); +} + +.history-card { + margin-top: 1.5rem; + padding: 0; + overflow: hidden; +} + +.result-card, +.detail-card { + margin-top: 1rem; + padding: 1.4rem 1.5rem; +} + +.detail-card h2 { + margin: 0 0 1.1rem; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} diff --git a/app/styles/results.css b/app/styles/results.css new file mode 100644 index 0000000..c0b7c18 --- /dev/null +++ b/app/styles/results.css @@ -0,0 +1,915 @@ +/* ════════════════════════════════════════ + Verdict hero — 결과 첫 블록 (프리미엄) + ════════════════════════════════════════ */ +.verdict-hero { + position: relative; + overflow: hidden; + margin-top: 0; + padding: 1.8rem 1.9rem 1.7rem; + border: 1px solid var(--border); + border-radius: 22px; + background: var(--card); + box-shadow: var(--shadow-md); +} + +/* 상단 accent 라인 */ +.verdict-hero::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); +} + +/* AXIS 선택 배지 */ +.vh-badge { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.34rem 0.75rem 0.34rem 0.6rem; + border-radius: 9999px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--accent); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.vh-badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); +} + +.vh-title { + margin: 0.85rem 0 0.5rem; + font-size: clamp(1.7rem, 5vw, 2.55rem); + font-weight: 900; + line-height: 1.12; + letter-spacing: -0.035em; + color: var(--text); + word-break: break-word; + overflow-wrap: break-word; +} + +.vh-conclusion { + margin: 0; + max-width: 34rem; + color: var(--muted); + font-size: 1.02rem; + line-height: 1.62; + word-break: break-word; + overflow-wrap: break-word; +} + +/* 승부 막대 영역 */ +.vh-matchup { + margin-top: 1.6rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-light); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.vh-row { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.vh-row-head { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; +} + +.vh-row-name { + font-size: 0.92rem; + font-weight: 600; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.vh-row-win .vh-row-name { + color: var(--text); + font-weight: 800; +} + +.vh-row-tag { + flex-shrink: 0; + padding: 0.12rem 0.5rem; + border-radius: 9999px; + background: var(--accent); + color: #fff; + font-size: 0.64rem; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.vh-bar-track { + height: 8px; + border-radius: 9999px; + background: var(--border-light); + overflow: hidden; +} + +.vh-bar-fill { + height: 100%; + border-radius: 9999px; + background: var(--border); + transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1); +} + +.vh-row-win .vh-bar-track { + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.vh-row-win .vh-bar-fill { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); +} + +.vh-bar-note { + margin: 0.1rem 0 0; + font-size: 0.72rem; + color: var(--muted); + opacity: 0.6; + line-height: 1.4; +} + +/* result-card / result-conclusion — admin hero 등에서 재사용되는 기본형 */ +.result-card { + padding: 1.7rem 1.8rem; + text-align: center; +} + +.result-conclusion { + margin: 0.5rem auto 0; + max-width: 30rem; + color: var(--muted); + font-size: 1rem; + line-height: 1.62; +} + +.detail-card ul { + margin: 0; + padding-left: 1.1rem; +} + +/* Reason list */ +.reason-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.reason-list li { + display: flex; + align-items: flex-start; + gap: 0.75rem; + line-height: 1.6; + font-size: 0.95rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-light); +} + +.reason-list li:last-child { border-bottom: none; } + +.reason-mark { + flex-shrink: 0; + width: 20px; + height: 20px; + margin-top: 0.2rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.58rem; + font-weight: 900; + color: white; + background: linear-gradient(135deg, #3454e8 0%, #7c5cff 100%); + box-shadow: 0 2px 6px rgba(52, 84, 232, 0.3); +} + + +/* ---- spec section ---- */ +.spec-section { + padding-bottom: 0; +} + +.spec-source-note { + margin: 0 0 0.9rem; + color: var(--muted); + font-size: 0.82rem; + font-weight: 500; +} + +/* Scroll wrapper for horizontal overflow — extends to card edges */ +.spec-table-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin: 0 -1.5rem; + padding: 0 1.5rem 1.4rem; +} + +/* Official link icon inside column header */ +.cmp-head-name { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.cmp-head-link { + font-size: 0.75rem; + color: var(--muted); + opacity: 0.7; + flex-shrink: 0; + transition: opacity 0.15s; +} + +.cmp-head-link:hover { + opacity: 1; + color: var(--accent); +} + +.cmp-source-chip { + display: inline-flex; + align-items: center; + gap: 0.18rem; + max-width: 9.5rem; + padding: 0.16rem 0.45rem; + border-radius: 9999px; + border: 1px solid rgba(52, 84, 232, 0.16); + background: rgba(52, 84, 232, 0.08); + color: var(--accent); + font-size: 0.66rem; + font-weight: 800; + line-height: 1.2; + white-space: nowrap; +} + +.cmp-source-chip.source-authorized_importer { + border-color: rgba(16, 185, 129, 0.2); + background: rgba(16, 185, 129, 0.09); + color: #059669; +} + +.cmp-source-chip:hover { + border-color: currentColor; +} + +.admin-hero h1 { + font-size: clamp(2rem, 4vw, 3rem); +} + +.admin-source-card { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.admin-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 8rem; + gap: 0.8rem; +} + +.admin-field { + display: flex; + flex-direction: column; + gap: 0.45rem; + font-size: 0.84rem; + font-weight: 700; + color: var(--text); +} + +.admin-field input, +.admin-field select, +.admin-field textarea { + width: 100%; + border: 1.5px solid var(--border); + border-radius: 12px; + background: var(--bg); + color: var(--text); + font: inherit; + box-sizing: border-box; +} + +.admin-field input, +.admin-field select { + min-height: 3rem; + padding: 0 0.9rem; +} + +.admin-field textarea { + min-height: 22rem; + padding: 0.9rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.86rem; + line-height: 1.55; + resize: vertical; +} + +.admin-field input:focus, +.admin-field select:focus, +.admin-field textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--ring); +} + +.admin-actions { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.8rem; + align-items: center; +} + +.admin-actions .btn-primary, +.admin-actions .btn-outline { + margin-top: 0; +} + +.admin-validity { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.5rem; + border-radius: 9999px; + padding: 0 0.85rem; + font-size: 0.84rem; + font-weight: 800; + white-space: nowrap; +} + +.admin-validity.valid { + color: #059669; + background: rgba(16, 185, 129, 0.09); +} + +.admin-validity.invalid { + color: #dc2626; + background: rgba(220, 38, 38, 0.08); +} + +.admin-source-summary { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + color: var(--muted); + font-size: 0.88rem; +} + +.admin-source-summary span, +.admin-source-summary a { + display: inline-flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 9999px; + padding: 0.34rem 0.7rem; + background: rgba(52, 84, 232, 0.06); + font-weight: 700; +} + +.admin-source-summary a { + color: var(--accent); +} + +.admin-message { + margin: 0; + color: var(--muted); + font-size: 0.92rem; + line-height: 1.5; +} + +/* Empty spec state */ +.spec-empty { + padding: 1.2rem 0 1.4rem; + text-align: center; +} + +.spec-empty p { + margin: 0 0 0.9rem; + color: var(--muted); + font-size: 0.92rem; + line-height: 1.55; +} + +.spec-empty-links { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + justify-content: center; +} + +.spec-empty-links a { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.85rem; + border-radius: 9999px; + border: 1px solid var(--border); + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + transition: border-color 0.15s, color 0.15s; +} + +.spec-empty-links a:hover { + border-color: var(--accent); + color: var(--accent); +} + +.hint { + margin: 0.8rem 0 0; + color: var(--muted); + font-size: 0.9rem; + text-align: center; +} + +.hint.error { + color: #dc2626; +} + +.guest { + display: inline-block; +} + +/* ---- back button ---- */ +.btn-back { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.92rem; + font-weight: 500; + color: var(--muted); + margin-bottom: 1.2rem; + transition: color 0.16s ease; +} + +.btn-back:hover { + color: var(--text); +} + +/* ---- settings bar ---- */ +.settings-bar { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Language dropdown */ +.lang-dropdown { + position: relative; +} + +.lang-trigger { + display: flex; + align-items: center; + gap: 0.3rem; + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.38rem 0.7rem; + background: white; + font-size: 0.82rem; + font-weight: 600; + color: var(--text); + cursor: pointer; + transition: border-color 0.14s ease, color 0.14s ease; + white-space: nowrap; +} + +.lang-trigger:hover { + border-color: var(--accent); + color: var(--accent); +} + +.lang-globe { + font-size: 0.9rem; + opacity: 0.6; +} + +.lang-caret { + font-size: 0.55rem; + color: var(--muted); + margin-left: 0.05rem; +} + +.lang-menu { + position: absolute; + right: 0; + top: calc(100% + 0.4rem); + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow-md); + padding: 0.3rem; + min-width: 110px; + z-index: 30; +} + +.lang-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: 0.5rem 0.7rem; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + color: var(--text); + cursor: pointer; + transition: background 0.12s ease; +} + +.lang-option:hover { + background: var(--bg); +} + +.lang-option.active { + font-weight: 700; + color: var(--accent); +} + +.lang-check { + font-size: 0.72rem; + font-weight: 800; + color: var(--accent); +} + +[data-theme="dark"] .lang-trigger { + background: var(--card); +} + +.theme-toggle { + width: 34px; + height: 34px; + border: 1px solid var(--border); + border-radius: 10px; + background: white; + font-size: 1rem; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: border-color 0.14s ease; +} + +[data-theme="dark"] .theme-toggle { + background: var(--card); + color: var(--text); +} + +.theme-toggle:hover { + border-color: var(--accent); +} + +/* ---- user dropdown menu ---- */ +.user-nav { + position: relative; +} + +.user-trigger { + display: flex; + align-items: center; + gap: 0.5rem; + border: 1px solid var(--border); + border-radius: 9999px; + padding: 0.35rem 0.7rem 0.35rem 0.4rem; + background: white; + cursor: pointer; +} + +.user-avatar { + width: 28px; + height: 28px; + border-radius: 9999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #3454e8, #7c5cff); + color: white; + font-size: 0.85rem; + font-weight: 700; + flex-shrink: 0; +} + +.user-avatar.lg { + width: 40px; + height: 40px; + font-size: 1.1rem; +} + +.user-caret { + color: var(--muted); + font-size: 0.7rem; +} + +.user-dropdown { + position: absolute; + right: 0; + top: calc(100% + 0.5rem); + width: 250px; + background: white; + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: 0 16px 40px rgba(17, 24, 39, 0.12); + padding: 0.5rem; + z-index: 20; +} + +.user-dropdown-head { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.6rem 0.6rem 0.8rem; + border-bottom: 1px solid var(--border); +} + +.user-dropdown-id { + display: flex; + flex-direction: column; + min-width: 0; +} + +.user-dropdown-id strong { + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-dropdown-id span { + color: var(--muted); + font-size: 0.78rem; +} + +.user-dropdown-list { + display: flex; + flex-direction: column; + padding: 0.4rem 0; +} + +.user-dropdown-list a { + padding: 0.6rem 0.6rem; + border-radius: 10px; + font-size: 0.92rem; +} + +.user-dropdown-list a:hover { + background: var(--bg); +} + +.user-dropdown-signout { + width: 100%; + text-align: left; + border: none; + border-top: 1px solid var(--border); + background: transparent; + color: #dc2626; + font-size: 0.92rem; + padding: 0.7rem 0.6rem 0.5rem; + cursor: pointer; +} + +/* ---- account page ---- */ +.account-card { + display: flex; + flex-direction: column; + gap: 0; + padding: 0.4rem 1.2rem; +} + +.account-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.9rem 0; + border-bottom: 1px solid var(--border); +} + +.account-row:last-child { + border-bottom: none; +} + +.account-key { + color: var(--muted); + font-size: 0.9rem; +} + +.account-val { + font-weight: 600; + text-align: right; +} + +.hint-inline { + color: var(--muted); + font-weight: 400; + font-size: 0.85rem; +} + +.plan-badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 9999px; + font-size: 0.8rem; + font-weight: 700; +} + +.plan-badge.free { + background: var(--bg); + color: var(--muted); +} + +.plan-badge.pro { + background: linear-gradient(135deg, #3454e8, #7c5cff); + color: white; +} + +.upsell { + text-align: center; +} + +.account-actions-row { + margin-top: 1.2rem; +} + +/* ---- membership / pricing ---- */ +.membership .feature-table, +.membership > .hint { + max-width: 720px; + margin-left: auto; + margin-right: auto; +} + +.plan-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-top: 1rem; +} + +.plan-grid.three { + grid-template-columns: repeat(3, 1fr); + align-items: stretch; +} + +.plan-grid.three .plan-card { + display: flex; + flex-direction: column; +} + +.plan-grid.three .plan-points { + flex: 1; +} + +@media (max-width: 760px) { + .plan-grid, + .plan-grid.three { + grid-template-columns: 1fr; + } +} + +.plan-badge.plus { + background: rgba(52, 84, 232, 0.12); + color: var(--accent); +} + +.plan-card { + position: relative; + border: 1px solid var(--border); + border-radius: 20px; + background: white; + padding: 1.4rem; + box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06); +} + +.plan-card.featured { + border-color: #3454e8; + box-shadow: 0 16px 40px rgba(52, 84, 232, 0.18); +} + +.plan-ribbon { + position: absolute; + top: -0.7rem; + right: 1.2rem; + background: linear-gradient(135deg, #3454e8, #7c5cff); + color: white; + font-size: 0.72rem; + font-weight: 700; + padding: 0.25rem 0.7rem; + border-radius: 9999px; +} + +.plan-head h2 { + margin: 0; + font-size: 1.3rem; +} + +.plan-price { + margin: 0.5rem 0 0.2rem; + font-size: 2rem; + font-weight: 800; +} + +.plan-price span { + font-size: 0.95rem; + font-weight: 500; + color: var(--muted); +} + +.plan-tagline { + margin: 0 0 1rem; + color: var(--muted); + font-size: 0.9rem; +} + +.plan-points { + list-style: none; + padding: 0; + margin: 0 0 1.2rem; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.plan-points li { + position: relative; + padding-left: 1.4rem; + font-size: 0.92rem; +} + +.plan-points li::before { + content: "✓"; + position: absolute; + left: 0; + color: #3454e8; + font-weight: 700; +} + +.plan-current { + text-align: center; + color: var(--muted); + font-size: 0.9rem; + margin: 0; +} + +.feature-table { + margin-top: 1.2rem; +} + +.feat-grid { + display: flex; + flex-direction: column; +} + +.feat-row { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + align-items: center; + gap: 0.6rem; + padding: 0.7rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.92rem; +} + +.feat-grid.three .feat-row { + grid-template-columns: 1.6fr 1fr 1fr 1fr; +} + +.feat-row:last-child { + border-bottom: none; +} + +.feat-row > span:not(:first-child) { + text-align: center; +} + +.feat-header { + font-weight: 700; + color: var(--muted); +} + +.feat-yes { + color: #3454e8; + font-weight: 700; +} + +.feat-no { + color: #c7cdd8; +} diff --git a/components/results-view.tsx b/components/results-view.tsx index c4cfd89..9c6eede 100644 --- a/components/results-view.tsx +++ b/components/results-view.tsx @@ -7,8 +7,8 @@ import ContextCard from "@/components/context-card"; import TimingSection from "@/components/timing-section"; import PriceComparison from "@/components/price-comparison"; import { getDictionary, type Locale } from "@/lib/i18n"; -import { resolveFieldByLabel } from "@/lib/specs/schema"; -import { coverageNoteYear } from "@/lib/specs/coverage"; +import { fieldLabelForLocale, resolveFieldByLabel } from "@/lib/specs/schema"; +import { localizeSpecValue } from "@/lib/specs/localize-value"; import SpecGraphs, { type SpecGraph } from "@/components/spec-graphs"; import type { Category } from "@/lib/types"; @@ -47,6 +47,42 @@ function normalize(result: ComparisonResult, query: string) { return { options, rows, sources, sourceMeta, analyses }; } +/** Remap row labels + values to the active UI locale (works even on stale session payloads). */ +function localizeRows( + rows: NormalizedRow[], + category: Category, + locale: Locale +): NormalizedRow[] { + return rows.map((row) => { + const field = resolveFieldByLabel(category, row.key); + if (!field) return row; + return { + key: fieldLabelForLocale(field, locale), + values: row.values.map((v) => { + const localized = localizeSpecValue(field.key, v ?? "—", locale); + return localizeEmbeddedUnits(localized, locale); + }), + }; + }); +} + +function localizeEmbeddedUnits(value: string, locale: Locale): string { + if (locale === "ko" || !value || value === "—") return value; + let next = value; + if (locale === "en") { + next = next + .replace(/(\d+(?:\.\d+)?)\s*인치/g, "$1 in") + .replace(/(\d+(?:\.\d+)?)\s*시간/g, "$1 h") + .replace(/(\d+(?:\.\d+)?)\s*원/g, "₩$1"); + } else if (locale === "ja") { + next = next + .replace(/(\d+(?:\.\d+)?)\s*인치/g, "$1インチ") + .replace(/(\d+(?:\.\d+)?)\s*시간/g, "$1時間") + .replace(/원/g, "ウォン"); + } + return next.replace(/\s+/g, " ").trim(); +} + function computeFitScores( options: string[], comparison: ComparisonRow[], @@ -103,6 +139,7 @@ function buildSpecGraphs( graphs.push({ key: row.key, + better: field.better, values: values.map((num, i) => ({ label: options[i] ?? `Option ${i + 1}`, raw: row.values[i] ?? "—", @@ -129,7 +166,8 @@ export default function ResultsView({ slug, region, }: Props) { - const { options, rows, sources, sourceMeta, analyses } = normalize(result, query); + const { options, rows: rawRows, sources, sourceMeta, analyses } = normalize(result, query); + const rows = localizeRows(rawRows, result.category, locale); const selectedIndex = options.findIndex((o) => o === result.selectedOption); const cols = options.length; const t = getDictionary(locale).results; @@ -139,7 +177,6 @@ export default function ResultsView({ const specGraphs = isBlockedResult ? [] : buildSpecGraphs(options, rows, result.category); - const coverageYear = coverageNoteYear(); const showVerifyBadge = result.verification != null && @@ -170,7 +207,6 @@ export default function ResultsView({

{result.selectedOption}

{result.oneLineConclusion ?? t.defaultConclusion}

- {/* 승부 막대: 각 옵션의 스펙 우위를 % 없이 막대 길이로만 표현 */} {options.length > 1 && !isBlockedResult && (
{options.map((opt, i) => ( @@ -217,7 +253,6 @@ export default function ResultsView({ )} - {specGraphs.length > 0 && ( g.key).join("|")} @@ -225,12 +260,11 @@ export default function ResultsView({ labels={{ title: t.specGraphs, select: t.specGraphSelect, - numericOnly: t.specGraphNumericOnly, }} /> )} - {/* ── 3. Spec table ── */} + {/* ── Spec table ── */}

{showVerifyBadge ? t.specComparison : t.specComparisonPending}

@@ -245,7 +279,6 @@ export default function ResultsView({ )}
-

{t.coverageNote(coverageYear)}

{rows.length > 0 ? (
@@ -302,11 +335,7 @@ export default function ResultsView({ )}
- - {/* ── 3b. Spec graphs ── */} - - - {/* ── 4. Per-option analysis ── */} + {/* ── Per-option analysis ── */} {analyses.some(Boolean) && (

{t.perItemAnalysis}

@@ -343,7 +372,7 @@ export default function ResultsView({
)} - {/* ── 5. Why chosen (추천이유 — 아래로 이동) ── */} + {/* Why chosen */} {!isBlockedResult && (

{t.whyChosen}

diff --git a/components/session-results.tsx b/components/session-results.tsx index 00dc363..8ec7d37 100644 --- a/components/session-results.tsx +++ b/components/session-results.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; import Link from "next/link"; import ResultsView from "@/components/results-view"; import type { ComparisonResult } from "@/lib/types"; @@ -30,6 +29,10 @@ function optionsFromPayload(payload: Payload): string[] { .filter(Boolean); } +function payloadLocale(payload: Payload): Locale { + return payload.locale ?? payload.result.locale ?? "ko"; +} + export default function SessionResults({ locale: localeProp }: { locale?: Locale }) { const { locale: themeLocale } = useTheme(); const locale = localeProp ?? themeLocale; @@ -38,7 +41,8 @@ export default function SessionResults({ locale: localeProp }: { locale?: Locale const [payload, setPayload] = useState(null); const [ready, setReady] = useState(false); const [isRefreshingLocale, setIsRefreshingLocale] = useState(false); - const router = useRouter(); + const [refreshError, setRefreshError] = useState(false); + const [retryToken, setRetryToken] = useState(0); useEffect(() => { let cancelled = false; @@ -63,14 +67,27 @@ export default function SessionResults({ locale: localeProp }: { locale?: Locale }, []); useEffect(() => { - if (!ready || !payload || payload.locale === locale) return; + if (!ready || !payload) return; + if (payloadLocale(payload) === locale) { + queueMicrotask(() => { + setRefreshError(false); + setIsRefreshingLocale(false); + }); + return; + } const options = optionsFromPayload(payload); - if (options.length < 2) return; + if (options.length < 2) { + queueMicrotask(() => setRefreshError(true)); + return; + } let cancelled = false; queueMicrotask(() => { - if (!cancelled) setIsRefreshingLocale(true); + if (!cancelled) { + setIsRefreshingLocale(true); + setRefreshError(false); + } }); fetch("/api/compare", { @@ -83,16 +100,24 @@ export default function SessionResults({ locale: localeProp }: { locale?: Locale return (await response.json()) as CompareResponse; }) .then((body) => { - if (cancelled || !body?.result) return; - const next = { + if (cancelled) return; + if (!body?.result) { + setRefreshError(true); + return; + } + const next: Payload = { query: body.result.options.join(" vs "), options: body.result.options, locale, result: body.result }; setPayload(next); + setRefreshError(false); sessionStorage.setItem(SESSION_RESULT_KEY, JSON.stringify(next)); }) + .catch(() => { + if (!cancelled) setRefreshError(true); + }) .finally(() => { if (!cancelled) setIsRefreshingLocale(false); }); @@ -100,14 +125,13 @@ export default function SessionResults({ locale: localeProp }: { locale?: Locale return () => { cancelled = true; }; - }, [locale, payload, ready]); + }, [locale, payload, ready, retryToken]); if (!ready) { return null; } if (!payload) { - // 결과 데이터 없이 /results 직접 접근한 경우 → 홈으로 부드럽게 유도 return (
@@ -122,7 +146,32 @@ export default function SessionResults({ locale: localeProp }: { locale?: Locale ); } - if (isRefreshingLocale) { + const localeMismatch = payloadLocale(payload) !== locale; + + if (localeMismatch && refreshError) { + return ( +
+
+

{dictionary.error.title}

+

{t.localeRefreshFailed}

+
+ + + {dictionary.error.backHome} + +
+
+
+ ); + } + + if (localeMismatch || isRefreshingLocale) { return (

{dictionary.error.loading}

diff --git a/components/settings-bar.tsx b/components/settings-bar.tsx index 93c1b37..6655a11 100644 --- a/components/settings-bar.tsx +++ b/components/settings-bar.tsx @@ -19,7 +19,6 @@ export default function SettingsBar() { return (
- {/* Language dropdown */}