Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
38 changes: 38 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Deploy to GitHub Pages

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8

- name: Install dependencies
run: pnpm install

- name: Build
run: pnpm build
env:
NODE_ENV: production

- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"test:e2e:report": "npx playwright show-report",
"test:generate": "playwright codegen localhost:5173",
"prepare": "husky",
"gh-pages": "pnpm -F @hanghae-plus/shopping build && gh-pages -d ./packages/app/dist"
"gh-pages": "pnpm -F @hanghae-plus/shopping build && gh-pages -d ./packages/app/dist",
"build:prod": "NODE_ENV=production vite build",
"preview:prod": "NODE_ENV=production vite preview",
"deploy": "pnpm build:prod && pnpm preview:prod"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
Expand Down
122 changes: 122 additions & 0 deletions packages/app/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>상품 쇼핑몰</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: "#3b82f6",
secondary: "#6b7280",
},
},
},
};
</script>
<script type="text/javascript">
var pathSegmentsToKeep = 1;
var l = window.location;
l.replace(
l.protocol +
"//" +
l.hostname +
(l.port ? ":" + l.port : "") +
l.pathname
.split("/")
.slice(0, 1 + pathSegmentsToKeep)
.join("/") +
"/?/" +
l.pathname.slice(1).split("/").slice(pathSegmentsToKeep).join("/").replace(/&/g, "~and~") +
(l.search ? "&" + l.search.slice(1).replace(/&/g, "~and~") : "") +
l.hash,
);
</script>
</head>
<body class="bg-gray-50">
<!-- 메인 앱 컨테이너 -->
<div id="root"></div>

<!-- 리다이렉트 실패시 대체 콘텐츠 -->
<div id="fallback-content" style="display: none">
<!-- Header -->
<header class="bg-white shadow-sm sticky top-0 z-40">
<div class="max-w-md mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-gray-900">
<a href="/front_6th_chapter1-1/">404 페이지</a>
</h1>
</div>
</div>
</header>

<!-- Main Content -->
<main class="max-w-md mx-auto px-4 py-4">
<div class="text-center my-4 py-20 shadow-md p-6 bg-white rounded-lg">
<svg viewBox="0 0 320 180" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="blueGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color: #4285f4; stop-opacity: 1" />
<stop offset="100%" style="stop-color: #1a73e8; stop-opacity: 1" />
</linearGradient>
<filter id="softShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="8" flood-color="#000000" flood-opacity="0.1" />
</filter>
</defs>

<!-- 404 Numbers -->
<text
x="160"
y="85"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="48"
font-weight="600"
fill="url(#blueGradient)"
text-anchor="middle"
>
404
</text>

<!-- Icon decoration -->
<circle cx="80" cy="60" r="3" fill="#e8f0fe" opacity="0.8" />
<circle cx="240" cy="60" r="3" fill="#e8f0fe" opacity="0.8" />
<circle cx="90" cy="45" r="2" fill="#4285f4" opacity="0.5" />
<circle cx="230" cy="45" r="2" fill="#4285f4" opacity="0.5" />

<!-- Message -->
<text
x="160"
y="110"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14"
font-weight="400"
fill="#5f6368"
text-anchor="middle"
>
페이지를 찾을 수 없습니다
</text>

<!-- Subtle bottom accent -->
<rect x="130" y="130" width="60" height="2" rx="1" fill="url(#blueGradient)" opacity="0.3" />
</svg>

<a
href="/front_6th_chapter1-1/"
class="inline-block px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>홈으로</a
>
</div>
</main>

<!-- Footer -->
<footer class="bg-white shadow-sm sticky top-0 z-40">
<div class="max-w-md mx-auto py-8 text-center text-gray-500">
<p>© 2025 항해플러스 프론트엔드 쇼핑몰</p>
</div>
</footer>
</div>
</body>
</html>
54 changes: 37 additions & 17 deletions packages/app/src/components/toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,72 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react";
import { createPortal } from "react-dom";

import { useMemo, useAutoCallback } from "@hanghae-plus/lib/src/hooks";

import { Toast } from "./Toast";
import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer";
import { debounce } from "../../utils";

type ShowToast = (message: string, type: ToastType) => void;
type Hide = () => void;

const ToastContext = createContext<{
const ToastStateContext = createContext<{
message: string;
type: ToastType;
}>({
...initialState,
});

const ToastActionContext = createContext<{
show: ShowToast;
hide: Hide;
}>({
...initialState,
show: () => null,
hide: () => null,
});

const DEFAULT_DELAY = 3000;

const useToastContext = () => useContext(ToastContext);
export const useToastCommand = () => {
const { show, hide } = useToastContext();
return { show, hide };
};
const useToastStateContext = () => useContext(ToastStateContext);
const useToastActionContext = () => useContext(ToastActionContext);

export const useToastState = () => {
const { message, type } = useToastContext();
const { message, type } = useToastStateContext();
return { message, type };
};

export const useToastCommand = () => {
const { show, hide } = useToastActionContext();
return { show, hide };
};

export const ToastProvider = memo(({ children }: PropsWithChildren) => {
const [state, dispatch] = useReducer(toastReducer, initialState);
const { show, hide } = createActions(dispatch);
const visible = state.message !== "";

const hideAfter = debounce(hide, DEFAULT_DELAY);
const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]);

const showWithHide: ShowToast = (...args) => {
const visible = useMemo(() => state.message !== "", [state.message]);

const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);

const showWithHide: ShowToast = useAutoCallback((...args) => {
show(...args);
hideAfter();
};
});

const ToastStateContextValue = useMemo(
() => ({ message: state.message, type: state.type }),
[state.message, state.type],
);
const toastActionContextValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);

return (
<ToastContext value={{ show: showWithHide, hide, ...state }}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastContext>
<ToastActionContext.Provider value={toastActionContextValue}>
<ToastStateContext.Provider value={ToastStateContextValue}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastStateContext.Provider>
</ToastActionContext.Provider>
);
});
9 changes: 9 additions & 0 deletions packages/app/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import react from "@vitejs/plugin-react-oxc";
import { createViteConfig } from "../../createViteConfig";
import { resolve } from "path";

const base: string = process.env.NODE_ENV === "production" ? "/front_6th_chapter1-3/" : "";

export default createViteConfig({
base,
plugins: [react()],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
404: resolve(__dirname, "404.html"),
},
},
},
});
1 change: 1 addition & 0 deletions packages/lib/src/createObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const createObserver = () => {
// useSyncExternalStore 에서 활용할 수 있도록 subscribe 함수를 수정합니다.
const subscribe = (fn: Listener) => {
listeners.add(fn);
return () => unsubscribe(fn);
};

const unsubscribe = (fn: Listener) => {
Expand Down
29 changes: 27 additions & 2 deletions packages/lib/src/equals/deepEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
export const deepEquals = (a: unknown, b: unknown) => {
return a === b;
export const deepEquals = (a: unknown, b: unknown): boolean => {
// 1. 기본 타입이거나 null인 경우 처리
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
return a === b;
}

// 2. 둘 다 객체인 경우:
// - 배열인지 확인
if (Array.isArray(a) && Array.isArray(b)) {
// 배열의 길이가 다르면 false
if (a.length !== b.length) return false;
// 각 요소를 재귀적으로 비교
return a.every((item, index) => deepEquals(item, b[index]));
}
// - 객체의 키 개수가 다른 경우 처리
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;

// - 재귀적으로 각 속성에 대해 deepEquals 호출
for (const key of keysA) {
if (!keysB.includes(key) || !deepEquals((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
return false;
}
}

return true;
};
30 changes: 27 additions & 3 deletions packages/lib/src/equals/shallowEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
export const shallowEquals = (a: unknown, b: unknown) => {
return a === b;
};
// shallowEquals 함수는 두 값의 얕은 비교를 수행합니다.
export function shallowEquals(objA: unknown, objB: unknown): boolean {
// 1. 두 값이 정확히 같은지 확인 (참조가 같은 경우)
// 2. 둘 중 하나라도 객체가 아닌 경우 처리
// 3. 객체의 키 개수가 다른 ㅋ경우 처리
// 4. 모든 키에 대해 얕은 비교 수행

// 이 부분을 적절히 수정하세요.
if (objA === objB) return true;

if (typeof objA !== "object" || objA === null || typeof objB !== "object" || objB === null) return objA === objB;

const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;

for (const key of keysA) {
const valueA = (objA as Record<string, unknown>)[key];
const valueB = (objB as Record<string, unknown>)[key];

if (!keysB.includes(key) || valueA !== valueB) {
return false;
}
}

return true;
}
7 changes: 6 additions & 1 deletion packages/lib/src/hocs/deepMemo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { FunctionComponent } from "react";

import { memo } from "./memo";
import { deepEquals } from "../equals";

export function deepMemo<P extends object>(Component: FunctionComponent<P>) {
return Component;
// deepEquals 함수를 사용하여 props 비교
// 앞에서 만든 memo를 사용
return memo(Component, deepEquals);
}
19 changes: 17 additions & 2 deletions packages/lib/src/hocs/memo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { type FunctionComponent } from "react";
import { createElement, type FunctionComponent } from "react";
import { shallowEquals } from "../equals";

import { useRef } from "../hooks";

// memo HOC는 컴포넌트의 props를 얕은 비교하여 불필요한 리렌더링을 방지합니다.
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return Component;
const MemoizedComponent: FunctionComponent<P> = (props) => {
const prevPropsRef = useRef<P | null>(null);
const prevComponentRef = useRef<React.FunctionComponentElement<P> | null>(null);

if (prevPropsRef.current === null || !equals(prevPropsRef.current, props)) {
prevPropsRef.current = props;
prevComponentRef.current = createElement(Component, props);
}

return prevComponentRef.current;
};

return MemoizedComponent;
}
Loading