diff --git a/src/api/auth.ts b/src/api/auth.ts index 915fe27..d7cfbce 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,28 +1,48 @@ +import { LoginRequest, LoginResponse, SignUpRequest } from "./auth.types"; import { apiFetch } from "./client"; -export type LoginRequest = { - loginId: string; - password: string; -}; - -export async function loginApi(body: LoginRequest): Promise { +export async function loginApi(body: LoginRequest): Promise { try { const res = await apiFetch("/api/v1/auth/login", { method: "POST", body: JSON.stringify(body), }); - console.log("res status:", res.status); - const json = (await res.json()) as any; + const json = (await res.json()) as LoginResponse; - const token: string | undefined = - json?.data?.accessToken ?? json?.accessToken ?? json?.data?.token; + const accessToken = json?.data?.accessToken; + const refreshToken = json?.data?.refreshToken; - if (!token) throw new Error("No accessToken in response"); + if (!accessToken || !refreshToken) { + throw new Error("Invalid login response: missing token"); + } - return token; + return json; } catch (e) { console.error("로그인 api 실패:", e); throw e; } } + +export async function signUpApi(body: SignUpRequest): Promise { + try { + const res = await apiFetch("/api/v1/auth/register", { + method: "POST", + body: JSON.stringify(body), + }); + + const json = (await res.json()) as LoginResponse; + + const accessToken = json?.data?.accessToken; + const refreshToken = json?.data?.refreshToken; + + if (!accessToken || !refreshToken) { + throw new Error("Invalid login response: missing token"); + } + + return json; + } catch (e) { + console.error("회원가입 api 실패:", e); + throw e; + } +} diff --git a/src/api/auth.types.ts b/src/api/auth.types.ts new file mode 100644 index 0000000..ac3fe68 --- /dev/null +++ b/src/api/auth.types.ts @@ -0,0 +1,18 @@ +import type { ApiResponse } from "./response.types"; + +// 로그인 api +export type LoginRequest = { + loginId: string; + password: string; +}; + +export type LoginData = { + accessToken: string; + refreshToken: string; +}; + +export type LoginResponse = ApiResponse; + +// 회원가입 api +export type SignUpRequest = { loginId: string; password: string }; +export type SignUpResponse = { userId: number }; diff --git a/src/api/client.ts b/src/api/client.ts index 2ac53e3..3392e7a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,24 +1,43 @@ -function getApiBaseUrl() { +import { AppError, type ApiErrorPayload } from "./response.types"; + +function getApiBaseUrl(): string { const url = process.env.EXPO_PUBLIC_API_BASE_URL?.trim(); - if (!url) throw new Error("Missing EXPO_PUBLIC_API_BASE_URL"); + if (!url) throw new AppError("Missing EXPO_PUBLIC_API_BASE_URL"); return url.replace(/\/$/, ""); } export async function apiFetch(path: string, init?: RequestInit) { - const API_BASE_URL = getApiBaseUrl(); + const baseUrl = getApiBaseUrl(); const safePath = path.startsWith("/") ? path : `/${path}`; - const res = await fetch(`${API_BASE_URL}${safePath}`, { - ...init, - headers: { - "Content-Type": "application/json", - ...(init?.headers ?? {}), - }, - }); + let res: Response; + try { + res = await fetch(`${baseUrl}${safePath}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + } catch (e) { + throw new AppError("Network error", { httpStatus: 0 }); + } if (!res.ok) { const text = await res.text().catch(() => ""); - throw new Error(`HTTP ${res.status} ${res.statusText} ${text}`); + let apiPayload: ApiErrorPayload | undefined; + + try { + const parsed = text ? JSON.parse(text) : null; + apiPayload = parsed?.data as ApiErrorPayload | undefined; + } catch { + // ignore + } + + throw new AppError(apiPayload?.message ?? `HTTP ${res.status}`, { + httpStatus: res.status, + api: apiPayload, + }); } return res; diff --git a/src/api/response.types.ts b/src/api/response.types.ts new file mode 100644 index 0000000..7274721 --- /dev/null +++ b/src/api/response.types.ts @@ -0,0 +1,31 @@ +export type ApiResponse = { + data: T; +}; + +export type ApiErrorPayload = { + title: string; + errorCode: string; + message: string; + code: number; + status: string; + instance?: string; +}; + +export class AppError extends Error { + readonly kind = "AppError"; + readonly httpStatus?: number; + readonly api?: ApiErrorPayload; + + constructor( + message: string, + opts?: { httpStatus?: number; api?: ApiErrorPayload } + ) { + super(message); + this.httpStatus = opts?.httpStatus; + this.api = opts?.api; + } +} + +export function isAppError(e: unknown): e is AppError { + return e instanceof AppError; +} diff --git a/src/components/ui/AppModal.tsx b/src/components/ui/AppModal.tsx new file mode 100644 index 0000000..26ae12b --- /dev/null +++ b/src/components/ui/AppModal.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { Modal, Pressable, StyleSheet, View } from "react-native"; +import { Theme } from "../../theme/theme"; +import AppText from "./AppText"; +import Button from "./Button"; + +type Props = { + visible: boolean; + title: string; + message?: string; + primaryText?: string; + onPrimaryPress?: () => void; + onClose: () => void; +}; + +export default function AppModal({ + visible, + title, + message, + primaryText = "OK", + onPrimaryPress, + onClose, +}: Props) { + return ( + + + {}}> + + {title} + + + {message ? ( + + {message} + + ) : null} + + + +