diff --git a/.conductor/settings.toml b/.conductor/settings.toml index aaa6043..bf77f4b 100644 --- a/.conductor/settings.toml +++ b/.conductor/settings.toml @@ -1,10 +1,18 @@ +#:schema https://conductor.build/schemas/settings.repo.schema.json "$schema" = "https://conductor.build/schemas/settings.repo.schema.json" + +# Copy gitignored local env files into each new workspace. 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)" +# Keep the root checkout current, seed the shared .context notes, then install deps. +setup = "git -C \"$CONDUCTOR_ROOT_PATH\" fetch --prune origin && git -C \"$CONDUCTOR_ROOT_PATH\" pull --ff-only || true; mkdir -p .context && touch .context/handoff.md .context/fe-notes.md .context/be-notes.md .context/security-report.md .context/todos.md && npm install" +# Next.js dev server on the workspace-assigned port (10-port range). run = "npm run dev -- -p $CONDUCTOR_PORT" +# Each workspace runs on its own port; Supabase/Vercel are remote, no shared local resource. run_mode = "concurrent" +# Drop the local build cache before archiving (gitignored, safe to delete). +archive = "rm -rf .next" [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." diff --git a/AGENTS.md b/AGENTS.md index 9642c8f..8389434 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,18 @@ Lead 최종 보고는 아래 형식을 쓴다. ## 공통 규칙 +- 기본은 자율 수행이다. 역할 범위 안 코드 수정, 검증 명령 실행, 문서/.context 기록, 작은 설정 수정은 PM 승인 없이 진행한다. +- 단, 아래 항목은 반드시 PM 승인 후 진행한다: + - 개인정보/시크릿/env 값 생성·수정·노출 + - DB schema/rules/migration 실제 적용 + - 인증 방식 변경 + - 결제/웹훅/권한 구조 변경 + - package.json/lockfile 변경 + - 새 유료 서비스/API 도입 + - 배포 실행 + - git push/merge/delete branch + - 대량 삭제 + - 프로젝트 밖 파일 수정 - package.json/lockfile 변경은 PM 승인 후. - DB schema/migration 변경은 PM 승인 후. - 인증 방식 변경은 PM 승인 후. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..b02fae5 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,159 @@ +# Axis Design System + +## 1. Atmosphere & Identity + +Axis should feel like a quiet decision desk for expensive electronics. The interface is not playful or marketplace-loud; it should read as evidence-first, compact, and composed. The signature is a clean analyst surface: soft blue emphasis, restrained cards, and recommendation moments that feel deliberate rather than promotional. + +## 2. Color + +### Palette + +| Role | Token | Light | Dark | Usage | +|------|-------|-------|------|-------| +| Surface / page | `--bg` | `#ffffff` | `#0d0f14` | Main page background | +| Surface / subtle | `--bg-subtle` | `#f8f9fc` | `#111318` | Tinted sections and promos | +| Surface / card | `--card` | `#ffffff` | `#161921` | Cards, panels, dialogs | +| Text / primary | `--text` | `#0f172a` | `#e2e8f4` | Headings and dense body text | +| Text / secondary | `--muted` | `#64748b` | `#8891a6` | Helper copy and metadata | +| Accent / primary | `--accent` | `#3454e8` | `#5b78f5` | Primary actions, focus, key badges | +| Accent / secondary | `--accent-2` | `#7c5cff` | `#9474ff` | Secondary emphasis only | +| Border / default | `--border` | `#e2e8f0` | `#252a3a` | Cards, dividers, controls | +| Border / subtle | `--border-light` | `#f1f5f9` | `#1e2230` | Soft inner separators | +| Focus ring | `--ring` | `rgba(52, 84, 232, 0.14)` | `rgba(91, 120, 245, 0.2)` | Focus and active halo | + +### Rules + +- Accent blue is functional, not decorative. It belongs on actions, key states, and result emphasis. +- Light mode stays mostly white; dark mode can carry the atmospheric radial tint already in `app/globals.css`. +- New colors must be added here first before they appear in code. + +## 3. Typography + +### Scale + +| Level | Size | Weight | Line Height | Tracking | Usage | +|-------|------|--------|-------------|----------|-------| +| Display | `clamp(2rem, 5vw, 3.25rem)` | 800 | 1.05 | `-0.03em` | Hero result titles | +| H1 | `2.25rem` | 800 | 1.1 | `-0.025em` | Major page headers | +| H2 | `1.5rem` | 700 | 1.2 | `-0.02em` | Section headers | +| H3 | `1.125rem` | 700 | 1.3 | `-0.01em` | Card titles | +| Body / lg | `1rem` | 500 | 1.65 | `0` | Strong supporting text | +| Body | `0.9375rem` | 400 | 1.65 | `0` | Default body copy | +| Body / sm | `0.875rem` | 500 | 1.55 | `0` | Dense UI text | +| Caption | `0.75rem` | 600 | 1.4 | `0.04em` | Labels and metadata | +| Overline | `0.6875rem` | 700 | 1.35 | `0.08em` | Upper label accents | + +### Font Stack + +- Primary: `Pretendard, Inter, system-ui, -apple-system, "Apple SD Gothic Neo", sans-serif` +- Mono: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace` + +### Rules + +- Pretendard is the primary face. Use the existing stack rather than introducing a new font family. +- Recommendation titles can be large, but helper paragraphs should stay compact and readable. +- Use balanced wrapping on promotional and share-facing headings where possible. + +## 4. Spacing & Layout + +### Base Unit + +All spacing derives from a 4px base. + +| Token | Value | Usage | +|-------|-------|-------| +| `--space-1` | `4px` | Tight icon spacing | +| `--space-2` | `8px` | Inline gaps, pills | +| `--space-3` | `12px` | Small card padding | +| `--space-4` | `16px` | Standard control padding | +| `--space-5` | `20px` | Compact panel padding | +| `--space-6` | `24px` | Default card padding | +| `--space-8` | `32px` | Section rhythm | +| `--space-10` | `40px` | Large internal spacing | +| `--space-12` | `48px` | Major section breaks | + +### Grid + +- Main result content width: `680px` +- Narrow utility width: `640px` +- Mobile layout should stack without horizontal overflow. +- Promotional or share cards should stay inside the existing result column rather than breaking into a separate hero system. + +### Rules + +- Prefer contained single-column layouts for results and share pages. +- Promo bands and CTA blocks should use asymmetry through internal layout, not page-level width changes. +- Long product names must wrap cleanly instead of relying on truncation in narrow viewports. + +## 5. Components + +### Detail Card + +- **Structure**: framed section with header, content, optional badge or helper copy +- **Variants**: default, spec, share/promo +- **Spacing**: `--space-5` to `--space-6` +- **States**: default only; nested controls provide hover/focus +- **Accessibility**: headings map to section purpose +- **Motion**: none required beyond button interactions + +### Result CTA Block + +- **Structure**: timing panel followed by share and buy controls inside one bordered container +- **Variants**: standard result flow, public share flow +- **Spacing**: tight vertical rhythm with one strong preview panel above controls +- **States**: hover, focus, copied, loading +- **Accessibility**: buttons remain semantic `button`/`a`; helper note stays readable on mobile +- **Motion**: subtle shadow and border-color shifts only + +### Share Preview Card + +- **Structure**: kicker, selected product title, one-line conclusion, compared options +- **Variants**: embedded in result CTA, public shared-result banner +- **Spacing**: `--space-4` internal padding, `--space-2` to `--space-3` between metadata chips +- **States**: static container with interactive child CTA only +- **Accessibility**: compared options remain plain text, not decorative-only +- **Motion**: none + +### Watch Button + +- **Structure**: compact inline button with status glyph and optional follow-up prompt +- **Variants**: idle, active, permission prompt, denied +- **Spacing**: compact; fits inside dense result surfaces +- **States**: default, hover, active/on, prompt open +- **Accessibility**: `aria-pressed` required; prompt copy must remain keyboard reachable +- **Motion**: glyph fill and background transitions only + +## 6. Motion & Interaction + +### Timing + +| Type | Duration | Easing | Usage | +|------|----------|--------|-------| +| Micro | `120ms` | `ease-out` | Hover and pressed feedback | +| Standard | `180ms` | `ease` | Border, color, and shadow transitions | +| Emphasis | `260ms` | `cubic-bezier(0.16, 1, 0.3, 1)` | Larger CTA surface polish if needed | + +### Rules + +- Animate `transform`, `opacity`, `border-color`, `box-shadow`, and `background-color` only. +- Keep focus-visible obvious with the existing accent ring language. +- Public share surfaces should feel stable; avoid decorative motion that competes with the recommendation. + +## 7. Depth & Surface + +### Strategy + +`mixed` + +Axis uses thin borders for structure plus restrained shadows for lift. Cards should look grounded, not floating. + +| Level | Value | Usage | +|-------|-------|-------| +| Border | `1px solid var(--border)` | Default card framing | +| Subtle shadow | `var(--shadow-sm)` | Resting cards and CTA blocks | +| Medium shadow | `var(--shadow-md)` | Elevated promo or share preview panels | + +### Rules + +- Shadow never replaces hierarchy; border and tonal contrast still do the structural work. +- Promotional cards can use a tinted surface, but not a loud gradient that breaks the app’s evidence-first tone. diff --git a/app/globals.css b/app/globals.css index 18dd62e..109d3a8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -168,6 +168,53 @@ a { margin-bottom: 0.9rem; } +.shared-entry-card { + display: grid; + gap: 1rem; + margin: 0 0 0.9rem; + padding: 1.15rem 1.2rem; + border: 1px solid var(--border); + border-radius: 20px; + background: + linear-gradient(180deg, rgba(52, 84, 232, 0.06), rgba(52, 84, 232, 0.02)), + var(--bg-subtle); + box-shadow: var(--shadow-sm); +} + +.shared-entry-copy { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.shared-entry-kicker { + margin: 0; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} + +.shared-entry-title { + margin: 0; + font-size: 1.2rem; + line-height: 1.25; + text-wrap: balance; +} + +.shared-entry-sub { + margin: 0; + font-size: 0.9rem; + line-height: 1.6; + color: var(--muted); +} + +.shared-entry-cta { + width: 100%; + justify-content: center; +} + .results-body > .result-card { margin-top: 0; } @@ -181,7 +228,7 @@ a { margin: .9rem 0 0; border: 1px solid var(--border); border-radius: 20px; - overflow: hidden; + overflow: visible; background: var(--card); box-shadow: var(--shadow-sm); } @@ -2565,45 +2612,144 @@ a { .share-actions { display: flex; flex-direction: column; - gap: 0.7rem; + gap: 0.85rem; margin: 0; } -.btn-buy { +.share-preview-card { + display: flex; + flex-direction: column; + gap: 0.65rem; + padding: 1rem 1rem 0.95rem; + border: 1px solid var(--border); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(52, 84, 232, 0.05), rgba(52, 84, 232, 0.01)), + var(--bg-subtle); +} + +.share-preview-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.share-preview-kicker { + margin: 0; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} + +.share-preview-badge { + border: 1px solid rgba(52, 84, 232, 0.16); + border-radius: 9999px; + background: rgba(52, 84, 232, 0.08); + color: var(--accent); + font-size: 0.73rem; + font-weight: 700; + padding: 0.2rem 0.55rem; + white-space: nowrap; +} + +.share-preview-title { + margin: 0; + font-size: 1.08rem; + line-height: 1.25; + text-wrap: balance; +} + +.share-preview-summary { + margin: 0; + font-size: 0.9rem; + line-height: 1.55; + color: var(--muted); +} + +.share-preview-query { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.share-query-chip { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.3rem 0.65rem; + border-radius: 9999px; + background: var(--card); + border: 1px solid var(--border); + color: var(--fg); + font-size: 0.78rem; + font-weight: 600; +} + +.share-action-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.65rem; +} + +.btn-buy, +.btn-share { display: flex; align-items: center; gap: 0.65rem; width: 100%; - padding: 1rem 1.15rem; + min-height: 3.5rem; + padding: 0.95rem 1rem; border-radius: 14px; - border: none; - background: #0f172a; - color: white; - font-size: 0.98rem; + border: 1px solid transparent; + font-size: 0.94rem; font-weight: 700; text-align: left; cursor: pointer; text-decoration: none; - transition: opacity 0.15s ease; + transition: border-color 0.16s ease, color 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, transform 0.16s ease; } -[data-theme="dark"] .btn-buy { - background: #e2e8f4; - color: #0d0f14; +.btn-share { + background: var(--card); + color: var(--fg); + justify-content: center; } -[data-theme="dark"] .btn-buy .buy-icon { - background: rgba(0, 0, 0, 0.12); - color: #0d0f14; +.btn-share-primary { + border-color: transparent; + background: var(--accent); + color: #ffffff; + box-shadow: 0 10px 24px rgba(52, 84, 232, 0.18); } -[data-theme="dark"] .btn-buy .buy-store { - background: rgba(0, 0, 0, 0.1); - color: #0d0f14; +.btn-share-primary:hover { + color: #ffffff; + transform: translateY(-1px); + box-shadow: 0 14px 28px rgba(52, 84, 232, 0.22); +} + +.btn-share-primary:disabled { + opacity: 0.7; + cursor: default; + transform: none; + box-shadow: none; } -.btn-buy:hover { - opacity: 0.88; +.btn-buy { + background: var(--card); + color: var(--fg); + border-color: var(--border); + justify-content: flex-start; +} + +.btn-buy:hover, +.btn-share:not(.btn-share-primary):hover { + border-color: var(--accent); + color: var(--accent); + box-shadow: 0 2px 10px rgba(52, 84, 232, 0.1); text-decoration: none; } @@ -2614,7 +2760,7 @@ a { display: inline-flex; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.15); + background: rgba(52, 84, 232, 0.08); flex-shrink: 0; font-size: 0.8rem; } @@ -2622,56 +2768,20 @@ a { .buy-text { flex: 1; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.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; + line-height: 1.35; + overflow-wrap: anywhere; + text-wrap: balance; } .buy-store { - margin-left: auto; font-size: 0.8rem; font-weight: 600; - opacity: 0.88; - background: rgba(255, 255, 255, 0.2); + color: var(--muted); + background: var(--bg-subtle); border-radius: 8px; padding: 0.2rem 0.55rem; white-space: nowrap; -} - -.share-row { - display: flex; - gap: 0.55rem; -} - -.btn-share { - flex: 1; - padding: 0.75rem 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:hover { - border-color: var(--accent); - color: var(--accent); - box-shadow: 0 2px 8px rgba(52, 84, 232, 0.1); + flex-shrink: 0; } .affiliate-note { @@ -2830,6 +2940,21 @@ a { .ts-cycle svg { flex-shrink: 0; opacity: .6; } +.ts-watch-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.7rem 0.85rem; + margin-top: 1rem; +} + +.ts-watch-note { + margin: 0; + font-size: 0.8rem; + line-height: 1.45; + color: var(--muted); +} + /* ════════════════════════════════════════ ContextCard — 맞춤 재분석 ════════════════════════════════════════ */ @@ -3193,7 +3318,7 @@ a { .watch-btn { display: inline-flex; align-items: center; - gap: 0.3rem; + gap: 0.45rem; flex-shrink: 0; border: 1px solid var(--border); border-radius: 9999px; @@ -3217,6 +3342,32 @@ a { background: rgba(52, 84, 232, 0.08); } +.watch-btn-icon { + width: 16px; + height: 16px; + border-radius: 9999px; + border: 1.6px solid currentColor; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.watch-btn-icon-core { + width: 6px; + height: 6px; + border-radius: 9999px; + background: currentColor; + transform: scale(0.35); + opacity: 0; + transition: transform 0.16s ease, opacity 0.16s ease; +} + +.watch-btn.on .watch-btn-icon-core { + transform: scale(1); + opacity: 1; +} + [data-theme="dark"] .watch-btn.on { background: rgba(91, 120, 245, 0.14); } @@ -3986,7 +4137,6 @@ a { } .btn-share { - padding: 0.65rem 1rem; font-size: 0.88rem; border-radius: 12px; } @@ -3995,6 +4145,18 @@ a { padding: 0.9rem 1.1rem 1.1rem; } + .share-action-grid { + grid-template-columns: 1fr; + } + + .shared-entry-card { + padding: 1rem 1rem 1.05rem; + } + + .shared-entry-title { + font-size: 1.05rem; + } + .btn-primary { padding: 0.82rem 1rem; font-size: 0.95rem; @@ -4021,6 +4183,18 @@ a { gap: 0.55rem; } + .share-preview-card { + padding: 0.9rem; + } + + .share-preview-title { + font-size: 1rem; + } + + .ts-watch-row { + align-items: flex-start; + } + /* Reason list */ .reason-list li { font-size: 0.9rem; diff --git a/app/share/[token]/page.tsx b/app/share/[token]/page.tsx index 2a913c0..bc9b35a 100644 --- a/app/share/[token]/page.tsx +++ b/app/share/[token]/page.tsx @@ -1,5 +1,4 @@ import { notFound } from "next/navigation"; -import Link from "next/link"; import { createSupabaseServerClient } from "@/lib/supabase-server"; import ResultsView from "@/components/results-view"; import type { ComparisonResult } from "@/lib/types"; @@ -60,7 +59,6 @@ export async function generateMetadata({ params }: Props): Promise { export default async function SharePage({ params }: Props) { const { token } = await params; const [data, locale] = await Promise.all([loadShared(token), getLocale()]); - const t = getDictionary(locale).share; if (!data) { notFound(); @@ -78,18 +76,8 @@ export default async function SharePage({ params }: Props) { result={result} locale={locale} shareToken={token} + sharedGuestCta={showWatermark} /> - - {showWatermark && ( -
-

- axis{t.watermarkText} -

- - {t.watermarkCta} - -
- )} ); } diff --git a/components/results-view.tsx b/components/results-view.tsx index cd66371..15d31a5 100644 --- a/components/results-view.tsx +++ b/components/results-view.tsx @@ -19,6 +19,7 @@ type Props = { hidePrices?: boolean; slug?: string; region?: string; + sharedGuestCta?: boolean; }; type NormalizedRow = { key: string; values: string[] }; @@ -88,11 +89,14 @@ export default function ResultsView({ locale = "ko", slug, region, + sharedGuestCta = false, }: Props) { const { options, rows, sources, sourceMeta, analyses } = normalize(result, query); const selectedIndex = options.findIndex((o) => o === result.selectedOption); const cols = options.length; - const t = getDictionary(locale).results; + const dictionary = getDictionary(locale); + const t = dictionary.results; + const share = dictionary.share; const isBlockedResult = result.status === "not_found" || result.status === "verification_pending"; const fitScores = computeFitScores(options, rows, result.category); @@ -116,6 +120,19 @@ export default function ResultsView({
{t.back} + {sharedGuestCta ? ( +
+
+

{share.sharedKicker}

+

{query}

+

{share.sharedLead(result.selectedOption)}

+
+ + {share.watermarkCta} + +
+ ) : null} + {/* ── 1. Verdict hero ── */}
@@ -164,6 +181,8 @@ export default function ResultsView({ guestPayload={!comparisonId ? { query, result } : undefined} slug={slug} region={region} + query={query} + summary={result.oneLineConclusion ?? t.defaultConclusion} />
diff --git a/components/share-actions.tsx b/components/share-actions.tsx index 2f198fb..a782b53 100644 --- a/components/share-actions.tsx +++ b/components/share-actions.tsx @@ -14,8 +14,19 @@ type Props = { guestPayload?: { query: string; result: ComparisonResult }; slug?: string; region?: string; + query?: string; + summary?: string; }; +function splitQuery(query: string | undefined): readonly string[] { + if (!query) return []; + return query + .split(/\s+vs\s+|\s+대\s+/i) + .map((value) => value.trim()) + .filter(Boolean) + .slice(0, 6); +} + export default function ShareActions({ selectedOption, category, @@ -25,6 +36,8 @@ export default function ShareActions({ guestPayload, slug, region, + query, + summary, }: Props) { const [token, setToken] = useState(shareToken ?? ""); const [sharing, setSharing] = useState(false); @@ -32,6 +45,7 @@ export default function ShareActions({ const buyLink = primaryBuyLink(selectedOption, category, locale); const t = getDictionary(locale).share; + const queryParts = splitQuery(query); async function getShareUrl(): Promise { if (token) return `${location.origin}/share/${token}`; @@ -102,26 +116,46 @@ export default function ShareActions({ return (
- - - {t.buyOn(selectedOption)} - {buyLink.label} - - - +
+
+

{t.shareCardLabel}

+ {t.shareCardBadge} +
+

{selectedOption}

+

{summary ?? t.shareCardFallback}

+ {queryParts.length > 0 ? ( +
+ {queryParts.map((part, index) => ( + + {part} + + ))} +
+ ) : null} +
+ +
+ + + + + {t.buyOn(selectedOption)} + {buyLink.label} + +

{t.affiliateNote}

diff --git a/components/timing-section.tsx b/components/timing-section.tsx index c7a4fdc..4e57708 100644 --- a/components/timing-section.tsx +++ b/components/timing-section.tsx @@ -2,6 +2,9 @@ import { useEffect, useState } from "react"; import type { PriceApiResult } from "@/app/api/price/route"; +import WatchButton from "@/components/watch-button"; +import { getDictionary, type Locale } from "@/lib/i18n"; +import { localeToRegion } from "@/lib/pricing/types"; type TimingVerdict = "buy_now" | "wait_short" | "wait_model" | "collecting"; @@ -56,12 +59,14 @@ const VERDICT_CONFIG: Record(null); const [loading, setLoading] = useState(true); + const watchText = getDictionary(locale).watch; + const region = localeToRegion(locale); useEffect(() => { fetch(`/api/price?name=${encodeURIComponent(productName)}&locale=${locale}`) @@ -128,6 +133,20 @@ export default function TimingSection({ productName, locale = "ko" }: Props) { {hint.label} 출시 예정: {hint.month} )} + + {price ? ( +
+ +

{watchText.alertNote}

+
+ ) : null}
); } diff --git a/components/vs-input.tsx b/components/vs-input.tsx index 4cfecc5..c3d70e0 100644 --- a/components/vs-input.tsx +++ b/components/vs-input.tsx @@ -56,12 +56,10 @@ export default function VsInput({ maxOptions = 2, locale = "ko" }: { maxOptions? const [isLoading, setIsLoading] = useState(false); const [loadingOptions, setLoadingOptions] = useState([]); const [error, setError] = useState(""); - const [isMounted, setIsMounted] = useState(false); + const isMounted = typeof document !== "undefined"; const router = useRouter(); const formRef = useRef(null); - useEffect(() => { setIsMounted(true); }, []); - const canAdd = options.length < Math.min(maxOptions, LETTERS.length); useEffect(() => { diff --git a/components/watch-button.tsx b/components/watch-button.tsx index 710fe34..1744252 100644 --- a/components/watch-button.tsx +++ b/components/watch-button.tsx @@ -139,7 +139,9 @@ export default function WatchButton({ aria-pressed={watched} onClick={() => void handleClick()} > - {watched ? "★" : "☆"} + + + {watched ? labelOn : label} diff --git a/lib/ai/complete.ts b/lib/ai/complete.ts index 0e6ceb3..3277a96 100644 --- a/lib/ai/complete.ts +++ b/lib/ai/complete.ts @@ -1,3 +1,5 @@ +import { resolveProviderConfigs } from "@/lib/ai/provider-config"; + /** * Generic JSON completion across providers (prompt-agnostic). * @@ -14,11 +16,6 @@ const AI_TIMEOUT_MS = 30_000; export type CompleteFn = (system: string, user: string) => Promise; -type ProviderConfig = - | { kind: "openai"; apiKey: string; baseUrl: string; model: string } - | { kind: "gemini"; apiKey: string; model: string } - | { kind: "anthropic"; apiKey: string; model: string }; - async function fetchWithTimeout(url: string, init: RequestInit): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), AI_TIMEOUT_MS); @@ -29,107 +26,79 @@ async function fetchWithTimeout(url: string, init: RequestInit): Promise { - const cfg = resolveProviderConfig(); - if (!cfg) return null; + const configs = resolveProviderConfigs(); + if (configs.length === 0) return null; - try { - if (cfg.kind === "openai") { - const res = await fetchWithTimeout(`${cfg.baseUrl}/v1/chat/completions`, { + for (const cfg of configs) { + try { + if (cfg.kind === "openai") { + const res = await fetchWithTimeout(`${cfg.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${cfg.apiKey}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + model: cfg.model, + temperature: 0, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: system }, + { role: "user", content: user } + ] + }) + }); + if (!res.ok) continue; + const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> }; + const content = data.choices?.[0]?.message?.content ?? null; + if (content) return content; + continue; + } + + if (cfg.kind === "gemini") { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${cfg.model}:generateContent?key=${cfg.apiKey}`; + const res = await fetchWithTimeout(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: `${system}\n\n${user}` }] }], + generationConfig: { temperature: 0, responseMimeType: "application/json" } + }) + }); + if (!res.ok) continue; + const data = (await res.json()) as { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; + }; + const content = data.candidates?.[0]?.content?.parts?.[0]?.text ?? null; + if (content) return content; + continue; + } + + const res = await fetchWithTimeout("https://api.anthropic.com/v1/messages", { method: "POST", headers: { - Authorization: `Bearer ${cfg.apiKey}`, - "Content-Type": "application/json" + "x-api-key": cfg.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json" }, body: JSON.stringify({ model: cfg.model, + max_tokens: 1500, temperature: 0, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: system }, - { role: "user", content: user } - ] + system, + messages: [{ role: "user", content: user }] }) }); - if (!res.ok) return null; - const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> }; - return data.choices?.[0]?.message?.content ?? null; + if (!res.ok) continue; + const data = (await res.json()) as { content?: Array<{ type?: string; text?: string }> }; + const content = data.content?.find((b) => b.type === "text")?.text ?? null; + if (content) return content; + } catch (error) { + console.error("[completeJson]", cfg.provider, error); } - - if (cfg.kind === "gemini") { - const url = `https://generativelanguage.googleapis.com/v1beta/models/${cfg.model}:generateContent?key=${cfg.apiKey}`; - const res = await fetchWithTimeout(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contents: [{ role: "user", parts: [{ text: `${system}\n\n${user}` }] }], - generationConfig: { temperature: 0, responseMimeType: "application/json" } - }) - }); - if (!res.ok) return null; - const data = (await res.json()) as { - candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; - }; - return data.candidates?.[0]?.content?.parts?.[0]?.text ?? null; - } - - // anthropic - const res = await fetchWithTimeout("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "x-api-key": cfg.apiKey, - "anthropic-version": "2023-06-01", - "content-type": "application/json" - }, - body: JSON.stringify({ - model: cfg.model, - max_tokens: 1500, - temperature: 0, - system, - messages: [{ role: "user", content: user }] - }) - }); - if (!res.ok) return null; - const data = (await res.json()) as { content?: Array<{ type?: string; text?: string }> }; - return data.content?.find((b) => b.type === "text")?.text ?? null; - } catch { - return null; } + + return null; }; diff --git a/lib/ai/decide.ts b/lib/ai/decide.ts index 0b2e9e4..360b7af 100644 --- a/lib/ai/decide.ts +++ b/lib/ai/decide.ts @@ -1,18 +1,10 @@ import { GoogleGenerativeAI } from "@google/generative-ai"; import { buildAxisSystemPrompt, buildAxisUserPrompt } from "@/lib/ai/axis-prompt"; +import { resolveProviderConfigs, type ProviderConfig } from "@/lib/ai/provider-config"; import type { AiDecisionInput, AiDecisionPayload } from "@/lib/ai/types"; const AI_TIMEOUT_MS = 20_000; -/** - * Resolved provider configuration — derived once per request, never mutates - * process.env (which caused cross-request pollution in concurrent environments). - */ -type ProviderConfig = - | { kind: "openai"; apiKey: string; baseUrl: string; model: string } - | { kind: "gemini"; apiKey: string; model: string } - | { kind: "anthropic"; apiKey: string; model: string }; - /** True if at least one provider API key is configured (regardless of call success). */ export function isAiConfigured() { return Boolean( @@ -33,42 +25,6 @@ async function fetchWithTimeout(url: string, init: RequestInit) { } } -function resolveProviderConfig(): ProviderConfig | null { - const preferred = process.env.AI_PROVIDER?.toLowerCase(); - - if ((preferred === "groq" || !preferred) && process.env.GROQ_API_KEY) { - return { - kind: "openai", - apiKey: process.env.GROQ_API_KEY, - baseUrl: "https://api.groq.com/openai", - model: process.env.OPENAI_MODEL ?? "llama-3.1-8b-instant" - }; - } - if ((preferred === "openai" || !preferred) && process.env.OPENAI_API_KEY) { - return { - kind: "openai", - apiKey: process.env.OPENAI_API_KEY, - baseUrl: (process.env.OPENAI_BASE_URL ?? "https://api.openai.com").replace(/\/$/, ""), - model: process.env.OPENAI_MODEL ?? "gpt-4o-mini" - }; - } - if ((preferred === "gemini" || !preferred) && process.env.GEMINI_API_KEY) { - return { - kind: "gemini", - apiKey: process.env.GEMINI_API_KEY, - model: process.env.GEMINI_MODEL ?? "gemini-2.0-flash" - }; - } - if ((preferred === "anthropic" || !preferred) && process.env.ANTHROPIC_API_KEY) { - return { - kind: "anthropic", - apiKey: process.env.ANTHROPIC_API_KEY, - model: process.env.ANTHROPIC_MODEL ?? "claude-3-5-haiku-20241022" - }; - } - return null; -} - /** Extract the outermost {...} object from a string that may have surrounding prose. */ function extractJsonObject(text: string): string | null { const start = text.indexOf("{"); @@ -187,22 +143,27 @@ async function callAnthropicConfig(cfg: Extract { - const cfg = resolveProviderConfig(); - if (!cfg) return null; - - let raw: AiDecisionPayload | null = null; - try { - raw = - cfg.kind === "openai" - ? await callOpenAiConfig(cfg, input) - : cfg.kind === "gemini" - ? await callGeminiConfig(cfg, input) - : await callAnthropicConfig(cfg, input); - } catch (err) { - console.error("[runAiDecision]", cfg.kind, err); - return null; + const configs = resolveProviderConfigs(); + if (configs.length === 0) return null; + + for (const cfg of configs) { + try { + const raw = + cfg.kind === "openai" + ? await callOpenAiConfig(cfg, input) + : cfg.kind === "gemini" + ? await callGeminiConfig(cfg, input) + : await callAnthropicConfig(cfg, input); + + if (!raw) { + continue; + } + + return { ...raw, selectedOption: normalizeSelectedOption(raw.selectedOption, input.options) }; + } catch (error) { + console.error("[runAiDecision]", cfg.provider, error); + } } - if (!raw) return null; - return { ...raw, selectedOption: normalizeSelectedOption(raw.selectedOption, input.options) }; + return null; } diff --git a/lib/ai/provider-config.ts b/lib/ai/provider-config.ts new file mode 100644 index 0000000..d75ad87 --- /dev/null +++ b/lib/ai/provider-config.ts @@ -0,0 +1,76 @@ +export type ProviderName = "groq" | "openai" | "gemini" | "anthropic"; + +export type ProviderConfig = + | { provider: "groq" | "openai"; kind: "openai"; apiKey: string; baseUrl: string; model: string } + | { provider: "gemini"; kind: "gemini"; apiKey: string; model: string } + | { provider: "anthropic"; kind: "anthropic"; apiKey: string; model: string }; + +const DEFAULT_PROVIDER_ORDER: readonly ProviderName[] = [ + "groq", + "openai", + "gemini", + "anthropic" +]; + +function buildProviderConfig(provider: ProviderName): ProviderConfig | null { + if (provider === "groq") { + if (!process.env.GROQ_API_KEY) return null; + return { + provider, + kind: "openai", + apiKey: process.env.GROQ_API_KEY, + baseUrl: "https://api.groq.com/openai", + model: process.env.OPENAI_MODEL ?? "llama-3.1-8b-instant" + }; + } + + if (provider === "openai") { + if (!process.env.OPENAI_API_KEY) return null; + return { + provider, + kind: "openai", + apiKey: process.env.OPENAI_API_KEY, + baseUrl: (process.env.OPENAI_BASE_URL ?? "https://api.openai.com").replace(/\/$/, ""), + model: process.env.OPENAI_MODEL ?? "gpt-4o-mini" + }; + } + + if (provider === "gemini") { + if (!process.env.GEMINI_API_KEY) return null; + return { + provider, + kind: "gemini", + apiKey: process.env.GEMINI_API_KEY, + model: process.env.GEMINI_MODEL ?? "gemini-2.0-flash" + }; + } + + if (!process.env.ANTHROPIC_API_KEY) return null; + return { + provider, + kind: "anthropic", + apiKey: process.env.ANTHROPIC_API_KEY, + model: process.env.ANTHROPIC_MODEL ?? "claude-3-5-haiku-20241022" + }; +} + +function providerOrderFromPreference(preferred: string | undefined): readonly ProviderName[] { + const normalized = preferred?.toLowerCase(); + if (!normalized) return DEFAULT_PROVIDER_ORDER; + if (!DEFAULT_PROVIDER_ORDER.includes(normalized as ProviderName)) { + return DEFAULT_PROVIDER_ORDER; + } + + const preferredProvider = normalized as ProviderName; + return [ + preferredProvider, + ...DEFAULT_PROVIDER_ORDER.filter((provider) => provider !== preferredProvider) + ]; +} + +export function resolveProviderConfigs(): ProviderConfig[] { + const orderedProviders = providerOrderFromPreference(process.env.AI_PROVIDER); + return orderedProviders + .map(buildProviderConfig) + .filter((config): config is ProviderConfig => config !== null); +} diff --git a/lib/i18n/en.ts b/lib/i18n/en.ts index f240229..c9ff6b9 100644 --- a/lib/i18n/en.ts +++ b/lib/i18n/en.ts @@ -217,6 +217,11 @@ export const en = { buyStore: "Coupang", buyStoreGlobal: "Amazon", affiliateNote: "Affiliate link — same price for you. Revenue helps keep Axis free.", + shareCardLabel: "Share-ready summary", + shareCardBadge: "Axis pick", + shareCardFallback: "Send the verdict and the compared lineup together.", + sharedKicker: "Shared comparison", + sharedLead: (product: string) => `This shared result picked ${product}. Re-run the same comparison or make the call with your own context.`, watermarkText: "compared on", watermarkCta: "Try it yourself →", shareTitle: (product: string) => `Axis picks: ${product}`, diff --git a/lib/i18n/ja.ts b/lib/i18n/ja.ts index 3390c4d..1787107 100644 --- a/lib/i18n/ja.ts +++ b/lib/i18n/ja.ts @@ -217,6 +217,11 @@ export const ja = { buyStore: "Coupang", buyStoreGlobal: "Amazon", affiliateNote: "このリンクはアフィリエイトリンクです。Axisの運営に役立てられます。追加費用はかかりません。", + shareCardLabel: "共有しやすい要約", + shareCardBadge: "Axisのおすすめ", + shareCardFallback: "結論と比較対象をそのまま共有できます。", + sharedKicker: "共有された比較", + sharedLead: (product: string) => `${product}をおすすめした結果です。同じ比較をやり直したり、自分の条件で判断し直せます。`, watermarkText: "でこの比較をしました。", watermarkCta: "自分でも試してみる →", shareTitle: (product: string) => `Axisのおすすめ: ${product}`, diff --git a/lib/i18n/ko.ts b/lib/i18n/ko.ts index 630555c..1e0998d 100644 --- a/lib/i18n/ko.ts +++ b/lib/i18n/ko.ts @@ -226,6 +226,11 @@ export const ko = { buyStore: "쿠팡", buyStoreGlobal: "Amazon", affiliateNote: "제휴 링크로 추가 비용 없이 동일 가격 구매 가능. 링크 수익은 Axis 운영에 사용됩니다.", + shareCardLabel: "공유용 한 줄 요약", + shareCardBadge: "Axis 추천", + shareCardFallback: "추천 결과와 비교 구성을 함께 공유할 수 있어요.", + sharedKicker: "공유된 비교", + sharedLead: (product: string) => `${product}을(를) 추천한 결과예요. 같은 질문으로 다시 비교하거나 내 상황에 맞게 다시 판단할 수 있어요.`, watermarkText: "로 비교한 결과예요.", watermarkCta: "나도 비교해보기 →", shareTitle: (product: string) => `Axis 추천: ${product}`, diff --git a/lib/specs/dataset/laptops.ts b/lib/specs/dataset/laptops.ts index df5929c..293ae63 100644 --- a/lib/specs/dataset/laptops.ts +++ b/lib/specs/dataset/laptops.ts @@ -231,6 +231,86 @@ export const laptops: VerifiedProduct[] = [ ports: "Thunderbolt 4 ×2, USB-A, HDMI 2.1, microSD", launch_price_krw: "219만 9천원부터", release_date: "2025년 1월" } }, + { + id: "galaxy-book5-pro-16", + canonicalName: "갤럭시 북5 프로 16", + nameEn: "Galaxy Book5 Pro 16", + aliases: [ + "갤럭시북5 프로 16", "갤럭시 북5 프로 16", "갤럭시북5프로16", "galaxy book5 pro 16", + "갤럭시북5 프로16", "북5 프로 16" + ], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=75724442", + fetchedAt: "2026-06", + tier: 2, + specs: { + model_name: "Galaxy Book5 Pro 16", os: "Windows 11 Home", cpu: "Intel Core Ultra 5 226V", + gpu: "Intel Arc 130V", ram_gb: "16", storage_gb: "512", display_inch: "16.0", + brightness_nits: "400", panel: "OLED (Dynamic AMOLED 2X)", resolution: "2880×1800", + refresh_hz: "120", weight_g: "1560", battery_wh: "76", + ports: "Thunderbolt 4 ×2, USB-A, HDMI 2.1, microSD", release_date: "2025년 2월" + } + }, + { + id: "galaxy-book4-edge-14", + canonicalName: "갤럭시 북4 엣지 14", + nameEn: "Galaxy Book4 Edge 14", + aliases: [ + "갤럭시북4 엣지", "갤럭시 북4 엣지", "갤럭시북4엣지", "galaxy book4 edge", + "galaxy book4 edge 14", "갤럭시북4 엣지 14", "북4 엣지" + ], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=57290507", + fetchedAt: "2026-06", + tier: 2, + specs: { + model_name: "Galaxy Book4 Edge 14", os: "Windows 11 Home", cpu: "Snapdragon X Elite X1E-80-100", + gpu: "Adreno (3.8TFLOPS)", ram_gb: "16", storage_gb: "512", display_inch: "14.0", + brightness_nits: "400", panel: "OLED (Dynamic AMOLED 2X)", resolution: "2880×1800", + refresh_hz: "120", weight_g: "1160", battery_wh: "55.9", + release_date: "2024년 6월" + } + }, + + { + id: "dell-xps-13", + canonicalName: "델 XPS 13", + nameEn: "Dell XPS 13", + aliases: ["델 xps 13", "dell xps 13", "xps 13", "xps13"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=69871052", + fetchedAt: "2026-06", + tier: 2, + specs: { + model_name: "Dell XPS 13 9350", os: "Windows 11 Pro", cpu: "Intel Core Ultra 7 258V", + gpu: "Intel Arc 140V", ram_gb: "32", storage_gb: "1024", display_inch: "13.4", + brightness_nits: "400", resolution: "2880×1800", refresh_hz: "60", weight_g: "1180", + battery_wh: "55", launch_price_krw: "359만 9천원" + } + }, + { + id: "lenovo-thinkpad-x1-carbon", + canonicalName: "레노버 씽크패드 X1 카본", + nameEn: "Lenovo ThinkPad X1 Carbon", + aliases: [ + "레노버 씽크패드 x1 카본", "씽크패드 x1 카본", "씽크패드 x1", "thinkpad x1 carbon", + "lenovo thinkpad x1 carbon", "x1 carbon" + ], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=90703388", + fetchedAt: "2026-06", + tier: 2, + specs: { + model_name: "ThinkPad X1 Carbon Gen 13", os: "Windows 11 Pro", cpu: "Intel Core Ultra 7 258V", + gpu: "Intel Arc 140V", ram_gb: "32", storage_gb: "1024", display_inch: "14.0", + brightness_nits: "400", resolution: "2880×1800", refresh_hz: "120", weight_g: "986", + battery_wh: "57", launch_price_krw: "323만 9천원" + } + }, // ══════════════════════════════════════════════════════════════════════════ // MacBook Air M1 / M2 (2020–2023) diff --git a/tests/ai-decide.test.ts b/tests/ai-decide.test.ts index 4e86da6..9a8a93b 100644 --- a/tests/ai-decide.test.ts +++ b/tests/ai-decide.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { parseAiJson, normalizeSelectedOption } from "@/lib/ai/decide"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseAiJson, normalizeSelectedOption, runAiDecision } from "@/lib/ai/decide"; const validPayload = JSON.stringify({ selectedOption: "A", @@ -9,6 +9,13 @@ const validPayload = JSON.stringify({ detail: "d" }); +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); +}); + describe("parseAiJson", () => { it("parses clean JSON", () => { expect(parseAiJson(validPayload)?.selectedOption).toBe("A"); @@ -54,3 +61,35 @@ describe("normalizeSelectedOption", () => { expect(normalizeSelectedOption("모두", ["A옵션", "B옵션"])).toBe("모두"); }); }); + +describe("runAiDecision", () => { + it("falls back from Groq to OpenAI when Groq is rate-limited", async () => { + process.env.AI_PROVIDER = "groq"; + process.env.GROQ_API_KEY = "test-groq-key"; + process.env.OPENAI_API_KEY = "test-openai-key"; + + const fetchMock = vi + .fn(async (..._args: [string, RequestInit?]) => new Response("unused", { status: 500 })) + .mockResolvedValueOnce(new Response("rate limited", { status: 429 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + choices: [{ message: { content: validPayload } }] + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await runAiDecision({ + options: ["A", "B"], + category: "laptop", + templateKeys: ["cpu"] + }); + + expect(result?.selectedOption).toBe("A"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0][0])).toBe("https://api.groq.com/openai/v1/chat/completions"); + expect(String(fetchMock.mock.calls[1][0])).toBe("https://api.openai.com/v1/chat/completions"); + }); +}); diff --git a/tests/ai-provider-config.test.ts b/tests/ai-provider-config.test.ts new file mode 100644 index 0000000..6fd3f8a --- /dev/null +++ b/tests/ai-provider-config.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resolveProviderConfigs } from "@/lib/ai/provider-config"; + +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +describe("resolveProviderConfigs", () => { + it("returns providers in default fallback order", () => { + delete process.env.AI_PROVIDER; + process.env.GROQ_API_KEY = "groq-key"; + process.env.OPENAI_API_KEY = "openai-key"; + process.env.GEMINI_API_KEY = "gemini-key"; + process.env.ANTHROPIC_API_KEY = "anthropic-key"; + + expect(resolveProviderConfigs().map((config) => config.provider)).toEqual([ + "groq", + "openai", + "gemini", + "anthropic" + ]); + }); + + it("puts the preferred provider first and keeps the rest as fallback", () => { + process.env.AI_PROVIDER = "gemini"; + process.env.GROQ_API_KEY = "groq-key"; + process.env.OPENAI_API_KEY = "openai-key"; + process.env.GEMINI_API_KEY = "gemini-key"; + + expect(resolveProviderConfigs().map((config) => config.provider)).toEqual([ + "gemini", + "groq", + "openai" + ]); + }); + + it("skips providers without keys", () => { + process.env.AI_PROVIDER = "groq"; + delete process.env.GROQ_API_KEY; + process.env.OPENAI_API_KEY = "openai-key"; + + expect(resolveProviderConfigs().map((config) => config.provider)).toEqual(["openai"]); + }); +}); diff --git a/tests/complete.test.ts b/tests/complete.test.ts index 551f784..c329a95 100644 --- a/tests/complete.test.ts +++ b/tests/complete.test.ts @@ -15,7 +15,7 @@ describe("completeJson", () => { delete process.env.OPENAI_API_KEY; delete process.env.OPENAI_BASE_URL; - const fetchMock = vi.fn(async () => + const fetchMock = vi.fn(async (..._args: [string, RequestInit?]) => new Response( JSON.stringify({ choices: [{ message: { content: "{\"ok\":true}" } }] @@ -29,6 +29,49 @@ describe("completeJson", () => { expect(result).toBe("{\"ok\":true}"); expect(fetchMock).toHaveBeenCalledOnce(); - expect(String(fetchMock.mock.calls[0][0])).toBe("https://api.groq.com/openai/v1/chat/completions"); + expect(String(fetchMock.mock.calls.at(0)?.[0])).toBe("https://api.groq.com/openai/v1/chat/completions"); + }); + + it("falls back from Groq to OpenAI when the first provider fails", async () => { + process.env.AI_PROVIDER = "groq"; + process.env.GROQ_API_KEY = "test-groq-key"; + process.env.OPENAI_API_KEY = "test-openai-key"; + + const fetchMock = vi + .fn(async (..._args: [string, RequestInit?]) => new Response("unused", { status: 500 })) + .mockResolvedValueOnce(new Response("rate limited", { status: 429 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + choices: [{ message: { content: "{\"provider\":\"openai\"}" } }] + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await completeJson("system", "user"); + + expect(result).toBe("{\"provider\":\"openai\"}"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls.at(0)?.[0])).toBe("https://api.groq.com/openai/v1/chat/completions"); + expect(String(fetchMock.mock.calls.at(1)?.[0])).toBe("https://api.openai.com/v1/chat/completions"); + }); + + it("returns null when every configured provider fails", async () => { + process.env.AI_PROVIDER = "groq"; + process.env.GROQ_API_KEY = "test-groq-key"; + process.env.OPENAI_API_KEY = "test-openai-key"; + + const fetchMock = vi + .fn(async (..._args: [string, RequestInit?]) => new Response("unused", { status: 500 })) + .mockResolvedValueOnce(new Response("rate limited", { status: 429 })) + .mockResolvedValueOnce(new Response("server error", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await completeJson("system", "user"); + + expect(result).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/dataset.test.ts b/tests/dataset.test.ts index 177be10..3e25b1c 100644 --- a/tests/dataset.test.ts +++ b/tests/dataset.test.ts @@ -19,7 +19,9 @@ describe("verified spec dataset", () => { it("resolves exact aliases", () => { expect(resolveVerifiedProduct("laptop", "맥북 에어 M3")?.canonicalName).toBe("맥북 에어 13 M3"); expect(resolveVerifiedProduct("laptop", "갤럭시북4 프로")?.canonicalName).toBe("갤럭시 북4 프로 14"); + expect(resolveVerifiedProduct("laptop", "갤럭시북5 프로 16")?.canonicalName).toBe("갤럭시 북5 프로 16"); expect(resolveVerifiedProduct("laptop", "LG 그램 16")?.canonicalName).toBe("LG 그램 16"); + expect(resolveVerifiedProduct("laptop", "씽크패드 X1 카본")?.canonicalName).toBe("레노버 씽크패드 X1 카본"); expect(resolveVerifiedProduct("smartphone", "아이폰 16")?.canonicalName).toBe("아이폰 16"); expect(resolveVerifiedProduct("smartphone", "갤럭시 S25")?.canonicalName).toBe("갤럭시 S25"); }); diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 8fa9d0d..e406c30 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -23,7 +23,7 @@ describe("searchWeb", () => { delete process.env.GOOGLE_SEARCH_API_KEY; delete process.env.GOOGLE_SEARCH_CX; - const fetchMock = vi.fn(async () => + const fetchMock = vi.fn(async (..._args: [string, RequestInit?]) => new Response( JSON.stringify({ web: { @@ -51,7 +51,10 @@ describe("searchWeb", () => { snippet: "Official specifications" } ]); - expect(String(fetchMock.mock.calls[0][0])).toContain("api.search.brave.com"); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("api.search.brave.com"), + expect.anything() + ); }); it("uses Google Custom Search when Google keys are configured", async () => { @@ -59,7 +62,7 @@ describe("searchWeb", () => { process.env.GOOGLE_SEARCH_API_KEY = "google-key"; process.env.GOOGLE_SEARCH_CX = "cx"; - const fetchMock = vi.fn(async () => + const fetchMock = vi.fn(async (..._args: [string, RequestInit?]) => new Response( JSON.stringify({ items: [ @@ -79,6 +82,9 @@ describe("searchWeb", () => { expect(configuredSearchProvider()).toBe("google"); expect(results[0]?.url).toBe("https://www.apple.com/macbook-air/specs/"); - expect(String(fetchMock.mock.calls[0][0])).toContain("customsearch"); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("customsearch"), + undefined + ); }); });