diff --git a/apps/mobile/app/login.tsx b/apps/mobile/app/login.tsx index 9c02bab..3c22f2e 100644 --- a/apps/mobile/app/login.tsx +++ b/apps/mobile/app/login.tsx @@ -1,4 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; +import { formatAuthError } from '@ticker-journal/shared'; import { Link, Redirect } from 'expo-router'; import { useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -58,7 +59,7 @@ export default function LoginScreen() { setResult({ message: '매직링크를 이메일로 보냈습니다. 메일함에서 링크를 열어 주세요.' }); } } catch (err) { - setResult({ error: err instanceof Error ? err.message : '로그인에 실패했습니다.' }); + setResult({ error: formatAuthError(err, '로그인에 실패했습니다.') }); } }); @@ -69,7 +70,7 @@ export default function LoginScreen() { try { await signInWithGoogle(); } catch (err) { - setResult({ error: err instanceof Error ? err.message : 'Google 로그인에 실패했습니다.' }); + setResult({ error: formatAuthError(err, 'Google 로그인에 실패했습니다.') }); } finally { setOauthPending(false); } diff --git a/apps/mobile/app/ticker/[id].tsx b/apps/mobile/app/ticker/[id].tsx index e19ff9e..4f1d19d 100644 --- a/apps/mobile/app/ticker/[id].tsx +++ b/apps/mobile/app/ticker/[id].tsx @@ -1,4 +1,5 @@ import { + buildChartHtml, type CreateEntryInput, CreateEntrySchema, type Entry, @@ -10,10 +11,8 @@ import { Redirect, useFocusEffect, useLocalSearchParams } from 'expo-router'; import { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { WebView } from 'react-native-webview'; - import { createEntry, deleteEntry, getTicker, listEntries } from '../../lib/api'; import { useAuth } from '../../lib/auth'; -import { buildChartHtml } from '../../lib/chart'; const FILTERS = TimelineFilterSchema.options; diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index a0dff79..c1fb3c0 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -1,5 +1,6 @@ module.exports = { preset: 'jest-expo', + passWithNoTests: true, setupFilesAfterEnv: ['/jest.setup.ts'], testMatch: ['**/__tests__/**/*.(spec|test).[jt]s?(x)'], moduleNameMapper: { @@ -8,7 +9,4 @@ module.exports = { transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-native-webview|@ticker-journal/shared)', ], - collectCoverageFrom: ['lib/chart.ts'], - coverageDirectory: 'coverage', - coverageReporters: ['text', 'text-summary', 'json-summary', 'html'], }; diff --git a/apps/web/e2e/search.spec.ts b/apps/web/e2e/search.spec.ts new file mode 100644 index 0000000..ad5bd66 --- /dev/null +++ b/apps/web/e2e/search.spec.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('검색 페이지는 로그인으로 리다이렉트한다', async ({ page }) => { + await page.goto('/search?q=test'); + await expect(page).toHaveURL(/\/login/); +}); diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index cc6a99f..84f7a01 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,5 +1,12 @@ import { defineConfig, devices } from '@playwright/test'; +/** E2E dev 서버용 — Auth 가드(/search → /login)를 세션 없이 재현. 실 Supabase 연결은 하지 않음. */ +const e2eSupabaseEnv = { + NEXT_PUBLIC_SUPABASE_URL: 'http://127.0.0.1:54321', + NEXT_PUBLIC_SUPABASE_ANON_KEY: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0', +}; + export default defineConfig({ testDir: './e2e', fullyParallel: true, @@ -14,6 +21,10 @@ export default defineConfig({ url: 'http://127.0.0.1:3000', reuseExistingServer: !process.env.CI, cwd: __dirname, + env: { + ...process.env, + ...e2eSupabaseEnv, + }, }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], }); diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index f7e9b9e..34d9318 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { formatAuthError } from '@ticker-journal/shared'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { Suspense, useActionState, useState } from 'react'; @@ -34,7 +35,7 @@ function LoginForm() { try { const supabase = createClient(); const { error } = await supabase.auth.signInWithPassword({ email, password }); - if (error) return { message: null, error: error.message }; + if (error) return { message: null, error: formatAuthError(error, '로그인에 실패했습니다.') }; window.location.href = '/'; return { message: null, error: null }; } catch (err) { @@ -51,7 +52,7 @@ function LoginForm() { email, options: { emailRedirectTo: `${window.location.origin}/auth/callback` }, }); - if (error) return { message: null, error: error.message }; + if (error) return { message: null, error: formatAuthError(error, '로그인 요청에 실패했습니다.') }; return { message: '매직링크를 보냈습니다. 메일함에서 링크를 열어 주세요.', error: null }; } catch (err) { return { message: null, error: err instanceof Error ? err.message : '로그인 요청에 실패했습니다.' }; @@ -65,6 +66,7 @@ function LoginForm() { const state = mode === 'password' ? pwState : mlState; const pending = pwPending || mlPending; + const formError = state.error ?? oauthError ?? callbackError; const handleGoogle = async () => { setOauthError(null); @@ -79,10 +81,10 @@ function LoginForm() { options: { redirectTo: `${window.location.origin}/auth/callback` }, }); if (error) { - setOauthError(error.message); + setOauthError(formatAuthError(error, 'Google 로그인에 실패했습니다.')); } } catch (err) { - setOauthError(err instanceof Error ? err.message : 'Google 로그인에 실패했습니다.'); + setOauthError(formatAuthError(err, 'Google 로그인에 실패했습니다.')); } }; @@ -142,6 +144,15 @@ function LoginForm() { ) : null} + {formError ? ( +
+

+ {mode === 'password' ? '로그인할 수 없습니다' : '요청을 처리할 수 없습니다'} +

+

{formError}

+
+ ) : null} + + + +
+

+ 로그인: {email}

- ) : ( - - )} -
+

관심종목

+ {loadError ? ( +
+

관심종목을 불러오지 못했습니다. 잠시 후 다시 시도하세요.

+

{loadError}

+
+ ) : tickers.length === 0 ? ( +

+ 아직 종목이 없습니다. 모바일 앱에서 먼저 추가하세요. +

+ ) : ( + + )} + + ) : (

시작

)} diff --git a/apps/web/src/components/search-view.test.tsx b/apps/web/src/components/search-view.test.tsx new file mode 100644 index 0000000..9af7b6a --- /dev/null +++ b/apps/web/src/components/search-view.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { SearchView } from '@/components/search-view'; + +describe('SearchView', () => { + it('조회 실패를 빈 결과와 구분한다', () => { + render(); + expect(screen.getByText(/검색에 실패했습니다/)).toBeInTheDocument(); + expect(screen.queryByText(/에 맞는 기록이 없습니다/)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/search-view.tsx b/apps/web/src/components/search-view.tsx new file mode 100644 index 0000000..1f23399 --- /dev/null +++ b/apps/web/src/components/search-view.tsx @@ -0,0 +1,108 @@ +import { APP_NAME } from '@ticker-journal/shared'; +import Link from 'next/link'; + +import type { SearchEntryHit } from '@/lib/search-query'; + +type SearchViewProps = { + query: string; + hits: SearchEntryHit[]; + page: number; + hasMore: boolean; + loadError?: string | null; + email?: string | null; +}; + +export function SearchView({ query, hits, page, hasMore, loadError = null, email = null }: SearchViewProps) { + const trimmed = query.trim(); + const prevPage = page > 1 ? page - 1 : null; + const nextPage = hasMore ? page + 1 : null; + + return ( +
+
+
+ + ← {APP_NAME} + +

아카이브 검색

+ {email ?

로그인: {email}

: null} +
+ +
+ + +
+ + {!trimmed ? ( +

+ 검색어를 입력하면 타임라인 기록을 찾습니다. +

+ ) : loadError ? ( +
+

검색에 실패했습니다.

+

{loadError}

+
+ ) : hits.length === 0 ? ( +

+ 「{trimmed}」에 맞는 기록이 없습니다. +

+ ) : ( +
    + {hits.map((hit) => ( +
  • + +

    + {hit.type} · {hit.ticker_symbol}{' '} + ({hit.ticker_market}) +

    +

    {hit.ticker_name ?? hit.ticker_symbol}

    +

    {hit.preview}

    + +
  • + ))} +
+ )} + + {trimmed && !loadError && (prevPage || nextPage) ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/ticker-detail-view.tsx b/apps/web/src/components/ticker-detail-view.tsx new file mode 100644 index 0000000..3e510a5 --- /dev/null +++ b/apps/web/src/components/ticker-detail-view.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { buildChartHtml, type Entry, type Ticker, TimelineFilterSchema } from '@ticker-journal/shared'; +import Link from 'next/link'; +import { useActionState, useMemo, useState } from 'react'; +import { useFormStatus } from 'react-dom'; + +import { createEntry, deleteEntry, type EntryActionState } from '@/app/ticker/[id]/actions'; +import { formatEntryPreview } from '@/lib/entry-format'; + +const FILTERS = TimelineFilterSchema.options; +const ENTRY_TYPES = ['memo', 'link', 'trade'] as const; +const INITIAL: EntryActionState = { error: null }; + +type TickerDetailViewProps = { + ticker: Ticker; + entries: Entry[]; + filter: (typeof FILTERS)[number]; +}; + +function SaveButton() { + const { pending } = useFormStatus(); + return ( + + ); +} + +export function TickerDetailView({ ticker, entries, filter }: TickerDetailViewProps) { + const chartHtml = useMemo(() => buildChartHtml(ticker.market, ticker.symbol), [ticker.market, ticker.symbol]); + const [modalOpen, setModalOpen] = useState(false); + const [entryType, setEntryType] = useState<(typeof ENTRY_TYPES)[number]>('memo'); + const [state, formAction] = useActionState(createEntry, INITIAL); + + const closeModal = () => { + setModalOpen(false); + setEntryType('memo'); + }; + + return ( +
+
+ + ← 홈 + + +
+

{ticker.symbol}

+

+ {ticker.name ?? '이름 없음'} · {ticker.market} +

+
+ +
+