diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..295afdee --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://api.heddy.site diff --git a/.gitignore b/.gitignore index 2276bafb..d80af40d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ lerna-debug.log* !.env.example !.env.*.example +# Local HTTPS certificates +certs/ + # Capacitor native platforms ios/ android/ diff --git a/README.md b/README.md index 5f007c61..e9b43c1b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ ``` ## 모바일 개발 환경 설정 + ```bash # iOS 플랫폼 최초 추가 pnpm ios @@ -37,6 +38,30 @@ pnpm dev:ios pnpm dev:android ``` +## HTTPS 개발 서버 + +데스크탑 개발 서버는 HTTPS로 실행됩니다. + +```bash +pnpm dev:desktop +# https://localhost:5173 +``` + +모바일 개발 서버와 Capacitor live reload는 WebView의 self-signed 인증서 차단을 피하기 위해 기본적으로 HTTP를 사용합니다. 모바일에서 HTTPS가 꼭 필요하면 환경 변수를 켜서 실행합니다. + +```bash +ENABLE_HTTPS=true pnpm dev:mobile +# https://localhost:5174 +``` + +첫 HTTPS 실행 시 로컬 self-signed 인증서가 `certs/`에 자동 생성됩니다. `openssl`이 없는 환경에서는 경고를 출력하고 HTTP로 폴백합니다. + +Capacitor live reload URL을 직접 지정해야 하면 다음처럼 실행합니다. + +```bash +CAPACITOR_SERVER_URL=http://:5174 pnpm dev:ios +``` + ## 빌드 ```bash @@ -58,4 +83,4 @@ pnpm build:android ```bash pnpm preview:desktop pnpm preview:mobile -``` \ No newline at end of file +``` diff --git a/capacitor.config.ts b/capacitor.config.ts index 4e781273..50429eee 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -1,16 +1,17 @@ /// -import type { CapacitorConfig } from '@capacitor/cli'; +import type { CapacitorConfig } from "@capacitor/cli"; -const isLive = process.env.CAPACITOR_LIVE === 'true'; +const isLive = process.env.CAPACITOR_LIVE === "true"; +const liveServerUrl = process.env.CAPACITOR_SERVER_URL ?? "http://localhost:5174"; const config: CapacitorConfig = { - appId: 'com.heddy.app', - appName: 'heddy', - webDir: 'dist/mobile', + appId: "com.heddy.app", + appName: "heddy", + webDir: "dist/mobile", ...(isLive && { server: { - url: 'http://localhost:5174', - cleartext: true, + url: liveServerUrl, + cleartext: liveServerUrl.startsWith("http://"), }, }), }; diff --git a/package.json b/package.json index 6a7f87c0..37dfe358 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.15.0", + "react-toastify": "^11.1.0", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/pakages/api/axios.ts b/pakages/api/axios.ts index 9a8ce31f..151f2bce 100644 --- a/pakages/api/axios.ts +++ b/pakages/api/axios.ts @@ -1,13 +1,14 @@ -import axios from 'axios' +import axios from "axios"; -const baseURL = import.meta.env.VITE_API_BASE_URL ?? '' +const DEFAULT_API_BASE_URL = "https://api.heddy.site"; +const baseURL = import.meta.env.VITE_API_BASE_URL?.trim() || DEFAULT_API_BASE_URL; export const api = axios.create({ baseURL, timeout: 10000, withCredentials: true, headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', + Accept: "application/json", + "Content-Type": "application/json", }, -}) +}); diff --git a/pakages/api/type.ts b/pakages/api/type.ts index 6f91f320..78343fc0 100644 --- a/pakages/api/type.ts +++ b/pakages/api/type.ts @@ -1,15 +1,17 @@ +export type ApiError = { + code?: string; + message?: string; +} | null; + export interface ApiResponse { - data: TData - message?: string - success?: boolean - statusCode?: number + success: boolean; + message?: string; + data: TData; + error?: ApiError; } export interface ApiErrorResponse { - message: string - errors?: TError - statusCode?: number + message: string; + errors?: TError; + statusCode?: number; } - -export const ApiResponse = undefined -export const ApiErrorResponse = undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c29cd89..6dcf38e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: react-router-dom: specifier: ^7.15.0 version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-toastify: + specifier: ^11.1.0 + version: 11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) zustand: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.14)(react@19.2.6) @@ -809,6 +812,10 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1443,6 +1450,12 @@ packages: react-dom: optional: true + react-toastify@11.1.0: + resolution: {integrity: sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==} + peerDependencies: + react: ^18 || ^19 + react-dom: ^18 || ^19 + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -2477,6 +2490,8 @@ snapshots: chownr@3.0.0: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3055,6 +3070,12 @@ snapshots: optionalDependencies: react-dom: 19.2.6(react@19.2.6) + react-toastify@11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + clsx: 2.1.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react@19.2.6: {} readable-stream@3.6.2: diff --git a/src/main/global.css b/src/main/global.css index a6020ce0..e6abe5ea 100644 --- a/src/main/global.css +++ b/src/main/global.css @@ -11,6 +11,11 @@ } @layer base { + html { + /* 390px 기준 16px, 좁을수록 줄어들고 최소 14px */ + font-size: clamp(14px, 4.1vw, 16px); + } + html, body, #root { @@ -38,6 +43,16 @@ } } +/* + iOS input focus zoom 방지 + - @layer base 안에 두면 Tailwind utilities(text-[0.75rem] 등)에 덮어써짐 + - @layer 밖에 두면 모든 레이어보다 우선순위가 높아 확실히 적용됨 + - 16px 미만 font-size 입력창에 포커스 시 iOS Safari/WebKit이 자동 확대하는 동작 차단 +*/ +input, select, textarea { + font-size: 16px; +} + @layer utilities { .scrollbar-thin { scrollbar-color: #c1c2c3 transparent; diff --git a/src/renderer/desktop/src/app/App.tsx b/src/renderer/desktop/src/app/App.tsx index 19702260..fd5e70ab 100644 --- a/src/renderer/desktop/src/app/App.tsx +++ b/src/renderer/desktop/src/app/App.tsx @@ -1,12 +1,29 @@ import { BrowserRouter } from "react-router-dom"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; import { AppRoutes } from "./routes"; +import { setupAuthInterceptor } from "@/entities/auth/model/setupAuthInterceptor"; + +setupAuthInterceptor(); const App = () => { return ( - - - + <> + + + + + + ); }; diff --git a/src/renderer/desktop/src/app/layouts/DesktopLayout.tsx b/src/renderer/desktop/src/app/layouts/DesktopLayout.tsx index 9a7969fd..6a22516e 100644 --- a/src/renderer/desktop/src/app/layouts/DesktopLayout.tsx +++ b/src/renderer/desktop/src/app/layouts/DesktopLayout.tsx @@ -10,11 +10,11 @@ const DesktopLayout = () => { return (
-
+
{!isAuthPage && }
diff --git a/src/renderer/desktop/src/app/routeGuards.tsx b/src/renderer/desktop/src/app/routeGuards.tsx new file mode 100644 index 00000000..51c2b492 --- /dev/null +++ b/src/renderer/desktop/src/app/routeGuards.tsx @@ -0,0 +1,54 @@ +import { useEffect } from "react"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; + +import { getAccessToken } from "@/entities/auth/model/token"; +import { showErrorToast } from "@/lib/toast"; + +const LOGIN_REQUIRED_TOAST_ID = "desktop-login-required-toast"; + +const getSafeRedirectPath = (value: string | null) => { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return "/"; + } + + if (value.startsWith("/login") || value.startsWith("/signup")) { + return "/"; + } + + return value; +}; + +const useIsAuthenticated = () => Boolean(getAccessToken()); + +export const ProtectedRoute = () => { + const location = useLocation(); + const isAuthenticated = useIsAuthenticated(); + + useEffect(() => { + if (isAuthenticated) { + return; + } + showErrorToast("로그인이 필요한 페이지입니다.", { + toastId: LOGIN_REQUIRED_TOAST_ID, + }); + }, [isAuthenticated]); + + if (!isAuthenticated) { + const redirectPath = `${location.pathname}${location.search}${location.hash}`; + return ; + } + + return ; +}; + +export const PublicOnlyRoute = () => { + const location = useLocation(); + const isAuthenticated = useIsAuthenticated(); + const redirectPath = getSafeRedirectPath(new URLSearchParams(location.search).get("redirect")); + + if (isAuthenticated) { + return ; + } + + return ; +}; diff --git a/src/renderer/desktop/src/app/routes.tsx b/src/renderer/desktop/src/app/routes.tsx index 43be3389..41c42375 100644 --- a/src/renderer/desktop/src/app/routes.tsx +++ b/src/renderer/desktop/src/app/routes.tsx @@ -1,6 +1,7 @@ import { Route, Routes } from "react-router-dom"; import { DesktopLayout } from "./layouts"; +import { ProtectedRoute, PublicOnlyRoute } from "./routeGuards"; import { LoginPage } from "@/pages/auth/login"; import { SignupTermsPage } from "@/pages/auth/signup"; import { CustomerPage } from "@/pages/customer"; @@ -14,15 +15,20 @@ const AppRoutes = () => { return ( }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + ); diff --git a/src/renderer/desktop/src/entities/auth/api/authApi.ts b/src/renderer/desktop/src/entities/auth/api/authApi.ts new file mode 100644 index 00000000..c3433612 --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/api/authApi.ts @@ -0,0 +1,88 @@ +import { isAxiosError } from "axios"; + +import { api } from "@/shared/api"; +import type { + LoginRequest, + LoginResponse, + OwnerSignupRequest, + SmsSendRequest, + SmsVerifyRequest, +} from "@/entities/auth/model/auth.types"; + +type AuthApiResponse = { + success: boolean; + message?: string; + data: T; + error: { code: string; message: string } | null; +}; + +type MaybeAuthApiResponse = Partial>; + +const isAuthApiResponse = (value: unknown): value is MaybeAuthApiResponse => + typeof value === "object" && value !== null && "success" in value; + +const assertSuccessfulResponse = (value: unknown, fallbackMessage: string) => { + if (isAuthApiResponse(value) && value.success === false) { + throw new Error(value.error?.message || value.message || fallbackMessage); + } +}; + +const getResponseErrorMessage = (value: unknown) => { + if (typeof value !== "object" || value === null) { + return null; + } + + const response = value as { error?: { message?: unknown } | null; message?: unknown }; + if (typeof response.error?.message === "string") { + return response.error.message; + } + if (typeof response.message === "string") { + return response.message; + } + + return null; +}; + +const getApiErrorMessage = (error: unknown, fallbackMessage: string) => { + if (isAxiosError(error)) { + return getResponseErrorMessage(error.response?.data) || fallbackMessage; + } + if (error instanceof Error) { + return error.message; + } + + return fallbackMessage; +}; + +const postPublicAuth = async ( + url: string, + body: TBody, + fallbackMessage: string, +) => { + try { + const res = await api.post(url, body); + assertSuccessfulResponse(res.data, fallbackMessage); + } catch (error) { + throw new Error(getApiErrorMessage(error, fallbackMessage), { cause: error }); + } +}; + +export const loginApi = async (body: LoginRequest): Promise => { + const res = await api.post>("/auth/login", body); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "로그인에 실패했습니다."); + } + return res.data.data; +}; + +export const sendSmsApi = async (body: SmsSendRequest): Promise => { + await postPublicAuth("/auth/sms/send", body, "인증번호 발송에 실패했습니다."); +}; + +export const verifySmsApi = async (body: SmsVerifyRequest): Promise => { + await postPublicAuth("/auth/sms/verify", body, "인증번호 확인에 실패했습니다."); +}; + +export const ownerSignupApi = async (body: OwnerSignupRequest): Promise => { + await postPublicAuth("/auth/signup/owner", body, "회원가입에 실패했습니다."); +}; diff --git a/src/renderer/desktop/src/entities/auth/model/auth.types.ts b/src/renderer/desktop/src/entities/auth/model/auth.types.ts new file mode 100644 index 00000000..34e9ac99 --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/model/auth.types.ts @@ -0,0 +1,36 @@ +export type LoginRequest = { + loginId: string; + password: string; +}; + +export type LoginResponse = { + accessToken: string; + refreshToken: string; +}; + +export type SmsPurpose = "SIGNUP" | "OWNER_SIGNUP" | "PASSWORD_RESET" | "PHONE_CHANGE"; + +export type SmsSendRequest = { + phoneNumber: string; + carrier: string; + purpose: SmsPurpose; +}; + +export type SmsVerifyRequest = { + phoneNumber: string; + code: string; + purpose: SmsPurpose; +}; + +export type OwnerSignupRequest = { + loginId: string; + password: string; + name: string; + phoneNumber: string; + storeName: string; + roadAddress: string; + detailAddress: string; + landline: string; + businessNumber: string; + storeEmail?: string; +}; diff --git a/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts b/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts new file mode 100644 index 00000000..2189a4ae --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts @@ -0,0 +1,145 @@ +import { api } from "@/shared/api"; +import { clearAccessToken, getAccessToken } from "@/entities/auth/model/token"; +import { markErrorToastHandled, showErrorToast } from "@/lib/toast"; + +let isInitialized = false; + +const PUBLIC_AUTH_PATHS = [ + "/auth/login", + "/auth/signup/owner", + "/auth/sms/send", + "/auth/sms/verify", +]; +const PUBLIC_PAGE_PATHS = ["/login", "/signup"]; +const AUTH_EXPIRED_TOAST_ID = "desktop-auth-expired-toast"; + +const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+\-.]*:\/\//i; + +const getRequestPath = (url?: string) => { + if (!url) return ""; + + const normalizedUrl = url.trim(); + if (!normalizedUrl) return ""; + + if (ABSOLUTE_URL_PATTERN.test(normalizedUrl)) { + try { + return new URL(normalizedUrl).pathname; + } catch { + return ""; + } + } + + const [pathWithoutQuery] = normalizedUrl.split(/[?#]/); + if (!pathWithoutQuery) return ""; + + return pathWithoutQuery.startsWith("/") ? pathWithoutQuery : `/${pathWithoutQuery}`; +}; + +const getResponseErrorMessage = (value: unknown) => { + if (typeof value !== "object" || value === null) { + return null; + } + + const response = value as { error?: { message?: unknown } | null; message?: unknown }; + if (typeof response.error?.message === "string") { + return response.error.message; + } + if (typeof response.message === "string") { + return response.message; + } + + return null; +}; + +const getStatusErrorMessage = (status?: number) => { + if (status === 401) { + return "로그인이 만료되었습니다. 다시 로그인해주세요."; + } + if (status === 403) { + return "접근 권한이 없습니다."; + } + if (status === 404) { + return "요청한 데이터를 찾을 수 없습니다."; + } + if (status && status >= 500) { + return "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."; + } + + return "요청을 처리하지 못했습니다. 잠시 후 다시 시도해주세요."; +}; + +const getCurrentPath = () => { + if (typeof window === "undefined") { + return "/"; + } + + return `${window.location.pathname}${window.location.search}${window.location.hash}`; +}; + +const isPublicPagePath = (path: string) => { + const pathname = path.split(/[?#]/)[0] || "/"; + + return PUBLIC_PAGE_PATHS.includes(pathname); +}; + +const redirectToLogin = () => { + if (typeof window === "undefined") { + return; + } + + const currentPath = getCurrentPath(); + + if (isPublicPagePath(currentPath)) { + return; + } + + const redirectQuery = currentPath === "/" ? "" : `?redirect=${encodeURIComponent(currentPath)}`; + + window.location.replace(`/login${redirectQuery}`); +}; + +export const setupAuthInterceptor = () => { + if (isInitialized) return; + isInitialized = true; + + api.interceptors.request.use(config => { + if (PUBLIC_AUTH_PATHS.includes(getRequestPath(config.url))) return config; + + const token = getAccessToken(); + if (token) { + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + + api.interceptors.response.use( + response => response, + error => { + const status = error?.response?.status as number | undefined; + const requestPath = getRequestPath(error?.config?.url); + const isPublicAuthRequest = PUBLIC_AUTH_PATHS.includes(requestPath); + const serverMessage = getResponseErrorMessage(error?.response?.data); + const message = serverMessage || getStatusErrorMessage(status); + + if (!error?.response) { + showErrorToast("서버에 연결할 수 없습니다. 네트워크 상태를 확인해주세요."); + markErrorToastHandled(error); + } else if (!isPublicAuthRequest) { + showErrorToast(message, { + toastId: status === 401 ? AUTH_EXPIRED_TOAST_ID : undefined, + }); + markErrorToastHandled(error); + } + + if (status === 401 && !isPublicAuthRequest) { + if (getAccessToken()) { + clearAccessToken(); + redirectToLogin(); + } + } + + return Promise.reject(error); + } + ); +}; diff --git a/src/renderer/desktop/src/entities/auth/model/token.ts b/src/renderer/desktop/src/entities/auth/model/token.ts new file mode 100644 index 00000000..fda87954 --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/model/token.ts @@ -0,0 +1,25 @@ +const ACCESS_TOKEN_KEY = "accessToken"; + +export const setAccessToken = (token: string) => { + if (typeof window === "undefined") { + return; + } + + localStorage.setItem(ACCESS_TOKEN_KEY, token); +}; + +export const getAccessToken = () => { + if (typeof window === "undefined") { + return null; + } + + return localStorage.getItem(ACCESS_TOKEN_KEY); +}; + +export const clearAccessToken = () => { + if (typeof window === "undefined") { + return; + } + + localStorage.removeItem(ACCESS_TOKEN_KEY); +}; diff --git a/src/renderer/desktop/src/entities/customer/api/customerApi.ts b/src/renderer/desktop/src/entities/customer/api/customerApi.ts new file mode 100644 index 00000000..4bddc370 --- /dev/null +++ b/src/renderer/desktop/src/entities/customer/api/customerApi.ts @@ -0,0 +1,169 @@ +import { isAxiosError } from "axios"; + +import { api } from "@/shared/api"; +import { isErrorToastHandled } from "@/lib/toast"; + +type ApiError = { code: string; message: string } | null; + +type ApiResponse = { + success: boolean; + message: string; + data: T; + error: ApiError; +}; + +const getResponseErrorMessage = (value: unknown) => { + if (typeof value !== "object" || value === null) { + return null; + } + + const response = value as { error?: { message?: unknown } | null; message?: unknown }; + if (typeof response.error?.message === "string") { + return response.error.message; + } + if (typeof response.message === "string") { + return response.message; + } + + return null; +}; + +const getApiErrorMessage = (error: unknown, fallbackMessage: string) => { + if (isAxiosError(error)) { + return getResponseErrorMessage(error.response?.data) || fallbackMessage; + } + if (error instanceof Error) { + return error.message; + } + + return fallbackMessage; +}; + +const requestApiData = async ( + request: () => Promise<{ data: ApiResponse }>, + fallbackMessage: string +) => { + try { + const res = await request(); + + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || fallbackMessage); + } + + return res.data.data; + } catch (error) { + if (isErrorToastHandled(error)) { + throw error; + } + + throw new Error(getApiErrorMessage(error, fallbackMessage), { cause: error }); + } +}; + +export type MetricsResponse = { + total_customer_count: number; + churn_risk_customer_count: number; + today_reservation_count: number; + today_visitor_count: number; + monthly_new_regular_customer_count: number; +}; + +export type CustomerGradeResponse = { + customer_id: string; + customer_grade: string; + visit_count_6months: number; + grade_updated_at: string; +}; + +export type TodayReservationResponse = { + reservation_id: string; + time: string; + customer_id: string; + customer_name: string; + service_name: string; + designer_name: string; + status: string; +}; + +export type DashboardResponse = { + metrics: MetricsResponse; + customer_grades: CustomerGradeResponse[]; + today_reservations: TodayReservationResponse[]; + calculated_at: string; +}; + +export type SalesPredictionPeriodType = "DAY" | "MONTH" | "QUARTER"; + +export type SalesPredictionParams = { + periodType?: SalesPredictionPeriodType; + predictionCount?: number; +}; + +export type SalesChartPoint = { + quarter: string; + sales: number; + order_count: number; + point_type: string; +}; + +export type QuarterlySalesPredictResponse = { + period_type: string; + base_quarter: string; + predicted_sales_total: number; + predicted_order_count_total: number; + chart: SalesChartPoint[]; + basis: string; +}; + +export type CustomerChurn = { + customer_id: string; + customer_name: string; + churn_score: number; + churn_level: string; + days_since_last_visit: number; +}; + +export type ShopChurnResponse = { + total: number; + customers: CustomerChurn[]; +}; + +export const getCustomerDashboard = async (shopId: number, date?: string) => { + return requestApiData( + () => + api.get>(`/shops/${shopId}/analytics/dashboard`, { + params: { + date, + }, + }), + "고객 요약 정보를 불러오지 못했습니다." + ); +}; + +export const getQuarterlySalesPredict = async ( + shopId: number, + params: SalesPredictionParams = {} +) => { + return requestApiData( + () => + api.get>( + `/shops/${shopId}/analytics/sales/beauty-salon/quarterly-predict`, + { + params, + } + ), + "매출 예측 정보를 불러오지 못했습니다." + ); +}; + +export const getShopChurnCustomers = async (shopId: number) => { + return requestApiData( + () => + api.get>(`/shops/${shopId}/analytics/churn`, { + params: { + limit: 1000, + }, + }), + "고객 목록을 불러오지 못했습니다." + ); +}; diff --git a/src/renderer/desktop/src/entities/employee/api/employeeApi.ts b/src/renderer/desktop/src/entities/employee/api/employeeApi.ts new file mode 100644 index 00000000..1f45b082 --- /dev/null +++ b/src/renderer/desktop/src/entities/employee/api/employeeApi.ts @@ -0,0 +1,71 @@ +import { api } from "@/shared/api"; + +type ApiError = { code: string; message: string } | null; + +type ApiResponse = { + success: boolean; + message: string; + data: T; + error: ApiError; +}; + +export type DesignerResponse = { + designer_id: number; + login_id?: string | null; + name: string; + profile_image_url?: string | null; +}; + +export type ShopDetailResponse = { + shop_id: number; + shop_name: string; + address?: string | null; + phone_number?: string | null; + description?: string | null; + open_hours?: string | null; + tags?: string[]; + designers: DesignerResponse[]; + created_at?: string; +}; + +export type InviteDesignerResponse = { + invite_token: string; + expires_at: string; +}; + +export const getShopDetail = async (shopId: number) => { + const res = await api.get>(`/shops/${shopId}`); + + if (!res.data.success) { + throw new Error( + res.data.error?.message || res.data.message || "매장 직원 정보를 불러오지 못했습니다." + ); + } + + return res.data.data; +}; + +export const inviteDesigner = async (shopId: number, loginId: string) => { + const res = await api.post>( + `/shops/${shopId}/designers/invite`, + { + login_id: loginId, + } + ); + + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "직원 초대에 실패했습니다."); + } + + return res.data.data; +}; + +export const removeDesigner = async (shopId: number, designerId: number) => { + const res = await api.delete>(`/shops/${shopId}/designers/${designerId}`); + + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "직원을 삭제하지 못했습니다."); + } + + return res.data.data; +}; diff --git a/src/renderer/desktop/src/entities/reservation/api/reservationApi.ts b/src/renderer/desktop/src/entities/reservation/api/reservationApi.ts new file mode 100644 index 00000000..8cc3bd11 --- /dev/null +++ b/src/renderer/desktop/src/entities/reservation/api/reservationApi.ts @@ -0,0 +1,118 @@ +import { api } from "@/shared/api"; + +type ApiError = { code: string; message: string } | null; + +type ApiResponse = { + success: boolean; + message: string; + data: T; + error: ApiError; +}; + +export type ReservationApiStatus = "APPROVED" | "REJECTED" | "CHANGE_REQUEST" | (string & {}); + +export type NoShowPredictionResponse = { + no_show_score?: number; + risk_level?: string; + no_show_probability?: number; + model_version?: string; +}; + +export type ReservationResponse = { + reservation_id: number; + customer_id: number; + customer_name?: string | null; + customer_phone_number?: string; + shop_id: number; + shop_name: string; + designer_id: number; + designer_name: string; + reserved_at: string; + changed_time?: string | null; + service_tags?: string[]; + service_name?: string; + status: string; + memo?: string | null; + created_at: string; + no_show_prediction?: NoShowPredictionResponse | null; +}; + +type ShopReservationsResponse = { + reservations: ReservationResponse[]; +}; + +export type GetReservationsParams = { + shopId: number; + date: string; + endDate?: string; + designerId?: number; + status?: string; +}; + +export type CreateReservationRequest = { + shop_id: number; + designer_id: number; + reserved_at: string; + service_tags: string[]; + memo?: string; +}; + +export type UpdateReservationStatusRequest = { + status: ReservationApiStatus; + designer_id?: number; + changed_time?: string; +}; + +export const getReservations = async ({ + shopId, + date, + endDate, + designerId, + status, +}: GetReservationsParams) => { + const res = await api.get>("/reservations", { + params: { + shop_id: shopId, + date, + end_date: endDate, + designer_id: designerId, + status, + }, + }); + + if (!res.data.success) { + throw new Error( + res.data.error?.message || res.data.message || "예약 목록을 불러오지 못했습니다." + ); + } + + return res.data.data.reservations; +}; + +export const createReservation = async (body: CreateReservationRequest) => { + const res = await api.post>("/reservations/register", body); + + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "예약을 등록하지 못했습니다."); + } + + return res.data.data; +}; + +export const updateReservationStatus = async ( + reservationId: number, + body: UpdateReservationStatusRequest +) => { + const res = await api.patch>( + `/reservations/${reservationId}/update-status`, + body + ); + + if (!res.data.success) { + throw new Error( + res.data.error?.message || res.data.message || "예약 상태를 변경하지 못했습니다." + ); + } + + return res.data.data; +}; diff --git a/src/renderer/desktop/src/entities/treatment-record/api/treatmentRecordApi.ts b/src/renderer/desktop/src/entities/treatment-record/api/treatmentRecordApi.ts new file mode 100644 index 00000000..31455712 --- /dev/null +++ b/src/renderer/desktop/src/entities/treatment-record/api/treatmentRecordApi.ts @@ -0,0 +1,85 @@ +import { api } from "@/shared/api"; + +type ApiError = { code: string; message: string } | null; + +type ApiResponse = { + success: boolean; + message: string; + data: T; + error: ApiError; +}; + +export type RegisterTreatmentRecordRequest = { + shopId: number; + phoneNumber: string; + treatmentDate: string; + price: number; + title?: string; + serviceTags?: string[]; + memo?: string; + beforeImage?: File | null; + afterImage?: File | null; +}; + +export type RegisterTreatmentRecordResponse = { + record_id: number; + customer_id: number; + title?: string; + service_tags?: string[]; + treatment_date: string; + created_at: string; + next_visit_date?: string; + price: number; +}; + +export const registerTreatmentRecord = async ({ + shopId, + phoneNumber, + treatmentDate, + price, + title, + serviceTags, + memo, + beforeImage, + afterImage, +}: RegisterTreatmentRecordRequest) => { + const formData = new FormData(); + + if (beforeImage) { + formData.append("before_image", beforeImage); + } + + if (afterImage) { + formData.append("after_image", afterImage); + } + + const res = await api.post>( + "/treatment-records/register", + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + params: { + shop_id: shopId, + phone_number: phoneNumber, + title, + treatment_date: treatmentDate, + service_tags: serviceTags, + memo, + price, + }, + paramsSerializer: { + indexes: null, + }, + } + ); + + if (!res.data.success) { + throw new Error( + res.data.error?.message || res.data.message || "시술 기록을 등록하지 못했습니다." + ); + } + + return res.data.data; +}; diff --git a/src/renderer/desktop/src/entities/user/api/userApi.ts b/src/renderer/desktop/src/entities/user/api/userApi.ts new file mode 100644 index 00000000..83a60831 --- /dev/null +++ b/src/renderer/desktop/src/entities/user/api/userApi.ts @@ -0,0 +1,34 @@ +import { api } from "@/shared/api"; + +type ApiError = { code: string; message: string } | null; + +type ApiResponse = { + success: boolean; + message: string; + data: T; + error: ApiError; +}; + +export type ShopMember = { + shopId: number; + shopName: string; + memberRole: string; +}; + +export type MeResponse = { + userId: number; + name: string; + shopMembers: ShopMember[]; +}; + +export const getMe = async () => { + const res = await api.get>("/users/me"); + + if (!res.data.success) { + throw new Error( + res.data.error?.message || res.data.message || "사용자 정보를 불러오지 못했습니다." + ); + } + + return res.data.data; +}; diff --git a/src/renderer/desktop/src/features/auth/login/model/login.ts b/src/renderer/desktop/src/features/auth/login/model/login.ts new file mode 100644 index 00000000..bd2edfbd --- /dev/null +++ b/src/renderer/desktop/src/features/auth/login/model/login.ts @@ -0,0 +1,54 @@ +import { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { loginApi } from "@/entities/auth/api/authApi"; +import { setAccessToken } from "@/entities/auth/model/token"; +import { showErrorToast } from "@/lib/toast"; + +const getSafeRedirectPath = (value: string | null) => { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return "/"; + } + + if (value.startsWith("/login") || value.startsWith("/signup")) { + return "/"; + } + + return value; +}; + +export const useLoginForm = () => { + const [id, setId] = useState(""); + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + + const handleLogin = async () => { + if (!id.trim() || !password.trim()) { + showErrorToast("아이디와 비밀번호를 입력해주세요."); + return; + } + + setIsLoading(true); + try { + const { accessToken } = await loginApi({ loginId: id, password }); + const redirectPath = getSafeRedirectPath( + new URLSearchParams(location.search).get("redirect") + ); + + setAccessToken(accessToken); + navigate(redirectPath, { replace: true }); + } catch (err) { + if (err instanceof Error) { + showErrorToast(err.message); + } else { + showErrorToast("로그인 중 오류가 발생했습니다. 다시 시도해주세요."); + } + } finally { + setIsLoading(false); + } + }; + + return { id, setId, password, setPassword, isLoading, handleLogin }; +}; diff --git a/src/renderer/desktop/src/features/auth/login/ui/LoginForm.tsx b/src/renderer/desktop/src/features/auth/login/ui/LoginForm.tsx index 0af85f01..b6a772b6 100644 --- a/src/renderer/desktop/src/features/auth/login/ui/LoginForm.tsx +++ b/src/renderer/desktop/src/features/auth/login/ui/LoginForm.tsx @@ -1,14 +1,24 @@ import { Link } from "react-router-dom"; import { lightTheme } from "@design-tokens"; +import { useLoginForm } from "@/features/auth/login/model/login"; const LoginForm = () => { - const inputStyle = { + const { id, setId, password, setPassword, isLoading, handleLogin } = useLoginForm(); + + const inputStyle = (value: string) => ({ backgroundColor: lightTheme.background.neutral, - color: lightTheme.label.neutral, - }; + color: value ? lightTheme.label.normal : lightTheme.label.assistive, + }); return ( -
+ { + event.preventDefault(); + handleLogin(); + }} + >
@@ -41,7 +53,9 @@ const LoginForm = () => { type="password" placeholder="비밀번호" className="h-[47px] rounded-[10px] px-3.5 font-['Pretendard'] text-xs font-normal leading-[130%] outline-none placeholder:text-[#c1c2c3] focus:ring-2 focus:ring-[#41be8e]/30" - style={inputStyle} + style={inputStyle(password)} + value={password} + onChange={event => setPassword(event.target.value)} />
@@ -66,9 +80,14 @@ const LoginForm = () => { ); diff --git a/src/renderer/desktop/src/features/auth/signup/model/validation.ts b/src/renderer/desktop/src/features/auth/signup/model/validation.ts new file mode 100644 index 00000000..b33a5ba5 --- /dev/null +++ b/src/renderer/desktop/src/features/auth/signup/model/validation.ts @@ -0,0 +1,17 @@ +const PASSWORD_PATTERN = + /^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*?_~])[a-zA-Z0-9!@#$%^&*?_~]{8,100}$/; + +const PHONE_NUMBER_PATTERN = /^01[016789]\d{7,8}$/; + +export const toDigits = (value: string) => value.replace(/\D/g, ""); + +export const isValidLoginId = (value: string) => { + const trimmedValue = value.trim(); + return trimmedValue.length >= 4 && trimmedValue.length <= 20; +}; + +export const isValidPassword = (value: string) => PASSWORD_PATTERN.test(value); + +export const isValidPhoneNumber = (value: string) => PHONE_NUMBER_PATTERN.test(toDigits(value)); + +export const isValidVerificationCode = (value: string) => /^\d{6}$/.test(value.trim()); diff --git a/src/renderer/desktop/src/features/auth/signup/ui/AddressFields.tsx b/src/renderer/desktop/src/features/auth/signup/ui/AddressFields.tsx index 40cf5736..2f72ac29 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/AddressFields.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/AddressFields.tsx @@ -5,8 +5,8 @@ import { inputClassName, inputStyle, primaryRingStyle, - SignupInlineButton, -} from "@/features/auth/signup/ui/SignupFormControls"; +} from "@/features/auth/signup/ui/SignupFormStyles"; +import { SignupInlineButton } from "@/features/auth/signup/ui/SignupFormControls"; import type { AddressFieldsProps } from "@/features/auth/signup/ui/types"; const AddressFields = ({ @@ -15,7 +15,7 @@ const AddressFields = ({ onAddressChange, onAddressDetailChange, }: AddressFieldsProps) => ( -
+