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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/mobile/app/login.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -58,7 +59,7 @@ export default function LoginScreen() {
setResult({ message: '매직링크를 이메일로 보냈습니다. 메일함에서 링크를 열어 주세요.' });
}
} catch (err) {
setResult({ error: err instanceof Error ? err.message : '로그인에 실패했습니다.' });
setResult({ error: formatAuthError(err, '로그인에 실패했습니다.') });
}
});

Expand All @@ -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);
}
Expand Down
3 changes: 1 addition & 2 deletions apps/mobile/app/ticker/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
buildChartHtml,
type CreateEntryInput,
CreateEntrySchema,
type Entry,
Expand All @@ -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;

Expand Down
4 changes: 1 addition & 3 deletions apps/mobile/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module.exports = {
preset: 'jest-expo',
passWithNoTests: true,
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testMatch: ['**/__tests__/**/*.(spec|test).[jt]s?(x)'],
moduleNameMapper: {
Expand All @@ -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'],
};
6 changes: 6 additions & 0 deletions apps/web/e2e/search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';

test('검색 페이지는 로그인으로 리다이렉트한다', async ({ page }) => {
await page.goto('/search?q=test');
await expect(page).toHaveURL(/\/login/);
});
11 changes: 11 additions & 0 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'] } }],
});
22 changes: 15 additions & 7 deletions apps/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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 : '로그인 요청에 실패했습니다.' };
Expand All @@ -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);
Expand All @@ -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 로그인에 실패했습니다.'));
}
};

Expand Down Expand Up @@ -142,6 +144,15 @@ function LoginForm() {
</>
) : null}

{formError ? (
<div role='alert' className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800'>
<p className='font-medium'>
{mode === 'password' ? '로그인할 수 없습니다' : '요청을 처리할 수 없습니다'}
</p>
<p className='mt-1 leading-6'>{formError}</p>
</div>
) : null}

<button
type='submit'
disabled={pending || !configured}
Expand Down Expand Up @@ -179,9 +190,6 @@ function LoginForm() {
<p className='text-sm text-amber-700'>apps/web/.env 에 Supabase URL/KEY를 넣은 뒤 next dev를 재시작하세요.</p>
) : null}
{state.message ? <p className='text-sm text-emerald-700'>{state.message}</p> : null}
{(state.error ?? oauthError ?? callbackError) ? (
<p className='text-sm text-red-700'>{state.error ?? oauthError ?? callbackError}</p>
) : null}
</div>
</main>
);
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { redirect } from 'next/navigation';

import { SearchView } from '@/components/search-view';
import { searchEntries } from '@/lib/entries';
import { parseSearchQuery } from '@/lib/search-query';
import { getSupabaseEnv } from '@/lib/supabase/env';
import { createClient } from '@/lib/supabase/server';

type SearchPageProps = {
searchParams: Promise<{ q?: string | string[]; page?: string | string[] }>;
};

export default async function SearchPage({ searchParams }: SearchPageProps) {
const { configured } = getSupabaseEnv();
if (!configured) {
redirect('/');
}

const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
redirect('/login');
}

const params = await searchParams;
const query = parseSearchQuery(params.q);
const result = await searchEntries(supabase, query, params.page);

return (
<SearchView
query={query}
hits={result.hits}
page={result.page}
hasMore={result.hasMore}
loadError={result.loadError}
email={user.email}
/>
);
}
77 changes: 77 additions & 0 deletions apps/web/src/app/ticker/[id]/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use server';

import { CreateEntrySchema } from '@ticker-journal/shared';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

import { createEntryRecord, deleteEntryRecord } from '@/lib/entries';
import { createClient } from '@/lib/supabase/server';

export type EntryActionState = { error: string | null };

export const createEntry = async (_prev: EntryActionState, formData: FormData): Promise<EntryActionState> => {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { error: '로그인이 필요합니다.' };

const tickerId = String(formData.get('ticker_id') ?? '');
const entryType = String(formData.get('entry_type') ?? 'memo');

try {
if (entryType === 'memo') {
const input = CreateEntrySchema.parse({
type: 'memo',
ticker_id: tickerId,
body: String(formData.get('body') ?? ''),
});
await createEntryRecord(supabase, user.id, input);
} else if (entryType === 'link') {
const title = String(formData.get('title') ?? '').trim();
const note = String(formData.get('note') ?? '').trim();
const input = CreateEntrySchema.parse({
type: 'link',
ticker_id: tickerId,
url: String(formData.get('url') ?? ''),
title: title ? title : null,
note: note ? note : null,
});
await createEntryRecord(supabase, user.id, input);
} else {
const reason = String(formData.get('reason') ?? '').trim();
const input = CreateEntrySchema.parse({
type: 'trade',
ticker_id: tickerId,
side: String(formData.get('side') ?? 'buy'),
traded_at: new Date().toISOString(),
reason: reason ? reason : null,
});
await createEntryRecord(supabase, user.id, input);
}
} catch (err) {
return { error: err instanceof Error ? err.message : '엔트리를 저장하지 못했습니다.' };
}

revalidatePath(`/ticker/${tickerId}`);
redirect(`/ticker/${tickerId}`);
};

export const deleteEntry = async (formData: FormData): Promise<void> => {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;

const entryId = String(formData.get('entry_id') ?? '');
const tickerId = String(formData.get('ticker_id') ?? '');
if (!entryId || !tickerId) return;

try {
await deleteEntryRecord(supabase, entryId);
revalidatePath(`/ticker/${tickerId}`);
} catch (err) {
console.error('delete entry failed:', err);
}
};
40 changes: 40 additions & 0 deletions apps/web/src/app/ticker/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { notFound, redirect } from 'next/navigation';
import { TickerDetailView } from '@/components/ticker-detail-view';
import { getTickerById, listEntriesForTicker } from '@/lib/entries';
import { getSupabaseEnv } from '@/lib/supabase/env';
import { createClient } from '@/lib/supabase/server';
import { parseTimelineFilter } from '@/lib/timeline-filter';

type TickerPageProps = {
params: Promise<{ id: string }>;
searchParams: Promise<{ filter?: string }>;
};

export default async function TickerPage({ params, searchParams }: TickerPageProps) {
const { configured } = getSupabaseEnv();
if (!configured) {
redirect('/');
}

const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
redirect('/login');
}

const { id } = await params;
const { filter: filterRaw } = await searchParams;
const filter = parseTimelineFilter(filterRaw);

const ticker = await getTickerById(supabase, id);
if (!ticker) {
notFound();
}

const entries = await listEntriesForTicker(supabase, id, filter);

return <TickerDetailView ticker={ticker} entries={entries} filter={filter} />;
}
4 changes: 4 additions & 0 deletions apps/web/src/components/home-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ describe('HomeView', () => {
/>,
);
expect(screen.getByText(/AAPL/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /AAPL/i })).toHaveAttribute(
'href',
'/ticker/11111111-1111-4111-8111-111111111111',
);
expect(screen.getByText('you@example.com')).toBeInTheDocument();
});

Expand Down
Loading
Loading