diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 67f03afa..650edabe 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,34 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import express from "express"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; const base = process.env.BASE || (prod ? "/front_7th_chapter4-1/vanilla/" : "/"); -const app = express(); - -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.trim(), - ); -}); - -// Start http server -app.listen(port, () => { - console.log(`React Server started at http://localhost:${port}`); -}); +async function createServer() { + const app = express(); + + let vite; + let templateHtml; + let ssrModule; + + // 환경 분기 + if (prod) { + // compression + sirv + const compression = (await import("compression")).default; + const sirv = (await import("sirv")).default; + + app.use(compression()); + + const distPath = path.resolve(__dirname, "dist"); + templateHtml = fs.readFileSync(path.resolve(distPath, "vanilla/index.html"), "utf-8"); + ssrModule = await import(path.resolve(distPath, "vanilla-ssr/main-server.js")); + + app.use(base, sirv(path.resolve(distPath, "vanilla"), { extensions: [] })); + } else { + // Vite dev server + middleware + const { createServer: createViteServer } = await import("vite"); + vite = await createViteServer({ + server: { middlewareMode: true }, + appType: "custom", + base, + }); + app.use(vite.middlewares); + } + + // 렌더링 파이프라인 + app.use("*all", async (req, res) => { + try { + const url = req.originalUrl.replace(base, "/"); + const query = req.query; + + let template; + let render; + + if (prod) { + template = templateHtml; + render = ssrModule.render; + } else { + // 개발 환경: 매 요청마다 템플릿과 모듈 새로 로드 + template = fs.readFileSync(path.resolve(__dirname, "index.html"), "utf-8"); + template = await vite.transformIndexHtml(url, template); + const mod = await vite.ssrLoadModule("/src/main-server.js"); + render = mod.render; + } + + // SSR 렌더링 + const { html: appHtml = "", head: appHead = "" } = (await render(url, query)) || {}; + + // Template 치환 + const finalHtml = template.replace("", appHead).replace("", appHtml); + + res.status(200).set({ "Content-Type": "text/html" }).send(finalHtml); + } catch (e) { + if (!prod && vite) { + vite.ssrFixStacktrace(e); + } + console.error(e.stack); + res.status(500).end(e.stack); + } + }); + + app.listen(port, () => { + console.log(`Vanilla Server started at http://localhost:${port}`); + }); +} + +createServer(); diff --git a/packages/vanilla/src/components/CartModal.js b/packages/vanilla/src/components/CartModal.js index f9695180..bbb947bd 100644 --- a/packages/vanilla/src/components/CartModal.js +++ b/packages/vanilla/src/components/CartModal.js @@ -1,4 +1,4 @@ -import { CartItem } from "./CartItem"; +import { CartItem } from "./CartItem.js"; export function CartModal({ items = [], selectedAll = false, isOpen = false }) { if (!isOpen) { diff --git a/packages/vanilla/src/components/ProductList.js b/packages/vanilla/src/components/ProductList.js index e32e49b1..62b9558e 100644 --- a/packages/vanilla/src/components/ProductList.js +++ b/packages/vanilla/src/components/ProductList.js @@ -1,4 +1,4 @@ -import { ProductCard, ProductCardSkeleton } from "./ProductCard"; +import { ProductCard, ProductCardSkeleton } from "./ProductCard.js"; const loadingSkeleton = Array(6).fill(0).map(ProductCardSkeleton).join(""); diff --git a/packages/vanilla/src/components/index.js b/packages/vanilla/src/components/index.js index ef27b3d5..3e5ad533 100644 --- a/packages/vanilla/src/components/index.js +++ b/packages/vanilla/src/components/index.js @@ -1,8 +1,8 @@ -export * from "./ProductCard"; -export * from "./SearchBar"; -export * from "./ProductList"; -export * from "./CartItem"; -export * from "./CartModal"; -export * from "./Toast"; -export * from "./Logo"; -export * from "./Footer"; +export * from "./ProductCard.js"; +export * from "./SearchBar.js"; +export * from "./ProductList.js"; +export * from "./CartItem.js"; +export * from "./CartModal.js"; +export * from "./Toast.js"; +export * from "./Logo.js"; +export * from "./Footer.js"; diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js new file mode 100644 index 00000000..88c2e2f8 --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,109 @@ +/** + * 서버 사이드용 라우터 + */ +import { createObserver } from "./createObserver.js"; +import { Router } from "./Router.js"; + +export class ServerRouter { + #routes; + #route; + #observer = createObserver(); + #baseUrl; + #query; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + this.#query = {}; + } + + get baseUrl() { + return this.#baseUrl; + } + + get query() { + return this.#query; + } + + set query(newQuery) { + this.#query = { ...this.#query, ...newQuery }; + } + + get params() { + return this.#route?.params ?? {}; + } + + get route() { + return this.#route; + } + + get target() { + return this.#route?.handler; + } + + subscribe(fn) { + this.#observer.subscribe(fn); + } + + /** + * 라우트 등록 + */ + addRoute(path, handler) { + const paramNames = []; + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + + this.#routes.set(path, { + regex, + paramNames, + handler, + }); + } + + #findRoute(url) { + const pathname = url.split("?")[0]; + for (const [routePath, route] of this.#routes) { + const match = pathname.match(route.regex); + if (match) { + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + ...route, + params, + path: routePath, + }; + } + } + return null; + } + + /** + * 서버에서 URL로 라우팅 초기화 + */ + navigate(url, query = {}) { + this.#query = query; + this.#route = this.#findRoute(url); + } + + push() { + // 서버에서는 no-op + } + + start() { + // 서버에서는 no-op + } + + static parseQuery = Router.parseQuery; + static stringifyQuery = Router.stringifyQuery; + static getUrl = Router.getUrl; +} diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..5378f0d2 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -4,10 +4,14 @@ * @param {Storage} storage - 기본값은 localStorage * @returns {Object} { get, set, reset } */ -export const createStorage = (key, storage = window.localStorage) => { +export const createStorage = (key, storage) => { + const resolvedStorage = + storage ?? (typeof window !== "undefined" && window?.localStorage ? window.localStorage : null); + const get = () => { + if (!resolvedStorage) return null; try { - const item = storage.getItem(key); + const item = resolvedStorage.getItem(key); return item ? JSON.parse(item) : null; } catch (error) { console.error(`Error parsing storage item for key "${key}":`, error); @@ -16,16 +20,18 @@ export const createStorage = (key, storage = window.localStorage) => { }; const set = (value) => { + if (!resolvedStorage) return; try { - storage.setItem(key, JSON.stringify(value)); + resolvedStorage.setItem(key, JSON.stringify(value)); } catch (error) { console.error(`Error setting storage item for key "${key}":`, error); } }; const reset = () => { + if (!resolvedStorage) return; try { - storage.removeItem(key); + resolvedStorage.removeItem(key); } catch (error) { console.error(`Error removing storage item for key "${key}":`, error); } diff --git a/packages/vanilla/src/lib/createStore.js b/packages/vanilla/src/lib/createStore.js index 19c74f82..9337ba3f 100644 --- a/packages/vanilla/src/lib/createStore.js +++ b/packages/vanilla/src/lib/createStore.js @@ -1,4 +1,4 @@ -import { createObserver } from "./createObserver"; +import { createObserver } from "./createObserver.js"; /** * Redux-style Store 생성 함수 diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index a598ef30..1ae59e67 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -1,4 +1,5 @@ -export * from "./createObserver"; -export * from "./createStore"; -export * from "./createStorage"; -export * from "./Router"; +export * from "./createObserver.js"; +export * from "./createStore.js"; +export * from "./createStorage.js"; +export * from "./Router.js"; +export * from "./ServerRouter.js"; diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..55163c92 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,162 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; +import { ServerRouter } from "./lib/index.js"; +import { renderHomePageView, renderNotFoundPageView, renderProductDetailPageView } from "./views/pages.js"; + +// 서버용 API 함수들 (fetch 대신 직접 데이터 반환) +import items from "./mocks/items.json"; + +// 서버용 라우터 생성 (server.js에서 이미 base URL을 제거하고 전달함) +const createServerRouter = () => { + const router = new ServerRouter(""); + router.addRoute("/", () => "home"); + router.addRoute("/product/:id/", () => "product"); + router.addRoute(".*", () => "notfound"); + return router; }; + +// 서버용 API 함수들 +const getProductsFromData = (params = {}) => { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.current ?? params.page ?? 1; + + let filtered = [...items]; + + if (search) { + const searchLower = search.toLowerCase(); + filtered = filtered.filter( + (item) => item.title.toLowerCase().includes(searchLower) || item.brand?.toLowerCase().includes(searchLower), + ); + } + + if (category1) { + filtered = filtered.filter((item) => item.category1 === category1); + } + if (category2) { + filtered = filtered.filter((item) => item.category2 === category2); + } + + if (sort === "price_asc") { + filtered.sort((a, b) => Number(a.lprice) - Number(b.lprice)); + } else if (sort === "price_desc") { + filtered.sort((a, b) => Number(b.lprice) - Number(a.lprice)); + } + + const total = filtered.length; + const start = (page - 1) * limit; + const products = filtered.slice(start, start + Number(limit)); + + return { products, pagination: { total, page, limit } }; +}; + +const getProductFromData = (productId) => { + return items.find((item) => item.productId === productId) || null; +}; + +const getCategoriesFromData = () => { + const categories = {}; + items.forEach((item) => { + if (item.category1) { + if (!categories[item.category1]) { + categories[item.category1] = {}; + } + if (item.category2 && !categories[item.category1][item.category2]) { + categories[item.category1][item.category2] = {}; + } + } + }); + return categories; +}; + +// SSR에서 공용 PageWrapper를 사용하기 위한 최소 layout state +const ssrCart = { items: [], selectedAll: false, isOpen: false }; +const ssrUi = { cartModal: { isOpen: false }, toast: { isVisible: false, message: "", type: "info" } }; + +// 데이터 프리페칭 함수 +async function prefetchData(route, params, query) { + if (route?.path === "/") { + // mockGetProducts + mockGetCategories + const [{ products, pagination }, categories] = await Promise.all([ + Promise.resolve(getProductsFromData(query)), + Promise.resolve(getCategoriesFromData()), + ]); + + return { + products, + categories, + totalCount: pagination.total, + }; + } else if (route?.path === "/product/:id/") { + // mockGetProduct(params.id) + const product = getProductFromData(params.id); + + let relatedProducts = []; + if (product?.category2) { + const { products } = getProductsFromData({ category2: product.category2, limit: 20 }); + relatedProducts = products.filter((p) => p.productId !== params.id); + } + + return { + currentProduct: product, + relatedProducts, + }; + } + + return null; +} + +export async function render(url, query = {}) { + // 1. 라우트 매칭 + const router = createServerRouter(); + router.navigate(url, query); + const route = router.route; + const params = router.params; + + // 2. 데이터 프리페칭 + const initialData = await prefetchData(route, params, query); + + // 3. HTML 생성 및 SEO 타이틀 설정 + let html = ""; + let title = "쇼핑몰"; + + if (route?.path === "/") { + html = renderHomePageView({ + productState: { + products: initialData?.products ?? [], + categories: initialData?.categories ?? {}, + totalCount: initialData?.totalCount ?? 0, + loading: false, + error: null, + }, + query, + cart: ssrCart, + ui: ssrUi, + }); + title = "쇼핑몰 - 홈"; + } else if (route?.path === "/product/:id/") { + html = renderProductDetailPageView({ + productState: { + currentProduct: initialData?.currentProduct ?? null, + relatedProducts: initialData?.relatedProducts ?? [], + loading: false, + error: initialData?.currentProduct ? null : "요청하신 상품이 존재하지 않습니다.", + }, + cart: ssrCart, + ui: ssrUi, + }); + if (initialData?.currentProduct) { + title = `${initialData.currentProduct.title} - 쇼핑몰`; + } + } else { + html = renderNotFoundPageView({ cart: ssrCart, ui: ssrUi }); + title = "페이지를 찾을 수 없습니다 - 쇼핑몰"; + } + + // 4. head 스크립트 생성 (SEO 메타태그 + 초기 데이터) + const head = ` + ${title} + + `.trim(); + + return { html, head, initialData }; +} diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..effda033 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -3,6 +3,8 @@ import { initRender } from "./render"; import { registerAllEvents } from "./events"; import { loadCartFromStorage } from "./services"; import { router } from "./router"; +import { productStore } from "./stores"; +import { PRODUCT_ACTIONS } from "./stores"; import { BASE_URL } from "./constants.js"; const enableMocking = () => @@ -15,7 +17,42 @@ const enableMocking = () => }), ); +/** + * 서버에서 전달된 초기 상태로 클라이언트 상태 복원 (Hydration) + */ +function hydrateFromServer() { + // 서버 데이터 복원 + if (window.__INITIAL_DATA__) { + const data = window.__INITIAL_DATA__; + + if (data.products) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: data, + }); + } + + if (data.currentProduct) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, + payload: data.currentProduct, + }); + if (data.relatedProducts) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: data.relatedProducts, + }); + } + } + + delete window.__INITIAL_DATA__; + } +} + function main() { + // 서버 상태 복원 (hydration) + hydrateFromServer(); + registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..ba118ef1 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -1,8 +1,7 @@ -import { ProductList, SearchBar } from "../components"; import { productStore } from "../stores"; import { router, withLifecycle } from "../router"; import { loadProducts, loadProductsAndCategories } from "../services"; -import { PageWrapper } from "./PageWrapper.js"; +import { renderHomePageView } from "../views/pages.js"; export const HomePage = withLifecycle( { @@ -21,30 +20,10 @@ export const HomePage = withLifecycle( const productState = productStore.getState(); const { search: searchQuery, limit, sort, category1, category2 } = router.query; const { products, loading, error, totalCount, categories } = productState; - const category = { category1, category2 }; - const hasMore = products.length < totalCount; - return PageWrapper({ - headerLeft: ` -

- 쇼핑몰 -

- `.trim(), - children: ` - - ${SearchBar({ searchQuery, limit, sort, category, categories })} - - -
- ${ProductList({ - products, - loading, - error, - totalCount, - hasMore, - })} -
- `.trim(), + return renderHomePageView({ + productState: { products, loading, error, totalCount, categories }, + query: { search: searchQuery, limit, sort, category1, category2 }, }); }, ); diff --git a/packages/vanilla/src/pages/NotFoundPage.js b/packages/vanilla/src/pages/NotFoundPage.js index be69ed5f..43690920 100644 --- a/packages/vanilla/src/pages/NotFoundPage.js +++ b/packages/vanilla/src/pages/NotFoundPage.js @@ -1,39 +1,3 @@ -import { PageWrapper } from "./PageWrapper"; -import { Logo } from "../components"; +import { renderNotFoundPageView } from "../views/pages.js"; -export const NotFoundPage = () => - PageWrapper({ - headerLeft: Logo(), - children: ` -
- - - - - - - - - - - - - 404 - - - - - - - - - 페이지를 찾을 수 없습니다 - - - - - - 홈으로 -
- `.trim(), - }); +export const NotFoundPage = () => renderNotFoundPageView({}); diff --git a/packages/vanilla/src/pages/PageWrapper.js b/packages/vanilla/src/pages/PageWrapper.js index fc13328e..bb893e98 100644 --- a/packages/vanilla/src/pages/PageWrapper.js +++ b/packages/vanilla/src/pages/PageWrapper.js @@ -1,9 +1,10 @@ -import { cartStore, uiStore } from "../stores"; -import { CartModal, Footer, Toast } from "../components"; +import { cartStore, uiStore } from "../stores/index.js"; +import { CartModal, Footer, Toast } from "../components/index.js"; -export const PageWrapper = ({ headerLeft, children }) => { - const cart = cartStore.getState(); - const { cartModal, toast } = uiStore.getState(); +export const PageWrapper = ({ headerLeft, children, cart: cartInput, ui: uiInput }) => { + const cart = cartInput ?? cartStore.getState(); + const uiState = uiInput ?? uiStore.getState(); + const { cartModal, toast } = uiState; const cartSize = cart.items.length; const cartCount = ` diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..032a7e51 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -1,235 +1,7 @@ import { productStore } from "../stores"; import { loadProductDetailForPage } from "../services"; import { router, withLifecycle } from "../router"; -import { PageWrapper } from "./PageWrapper.js"; - -const loadingContent = ` -
-
-
-

상품 정보를 불러오는 중...

-
-
-`; - -const ErrorContent = ({ error }) => ` -
-
-
- - - -
-

상품을 찾을 수 없습니다

-

${error || "요청하신 상품이 존재하지 않습니다."}

- - - 홈으로 - -
-
-`; - -function ProductDetail({ product, relatedProducts = [] }) { - const { - productId, - title, - image, - lprice, - brand, - description = "", - rating = 0, - reviewCount = 0, - stock = 100, - category1, - category2, - } = product; - - const price = Number(lprice); - - // 브레드크럼 생성 - const breadcrumbItems = []; - if (category1) breadcrumbItems.push({ name: category1, category: "category1", value: category1 }); - if (category2) breadcrumbItems.push({ name: category2, category: "category2", value: category2 }); - - return ` - - ${ - breadcrumbItems.length > 0 - ? ` - - ` - : "" - } - - -
- -
-
- ${title} -
- - -
-

${brand}

-

${title}

- - - ${ - rating > 0 - ? ` -
-
- ${Array(5) - .fill(0) - .map( - (_, i) => ` - - - - `, - ) - .join("")} -
- ${rating}.0 (${reviewCount.toLocaleString()}개 리뷰) -
- ` - : "" - } - - -
- ${price.toLocaleString()}원 -
- - -
- 재고 ${stock.toLocaleString()}개 -
- - - ${ - description - ? ` -
- ${description} -
- ` - : "" - } -
-
- - -
-
- 수량 -
- - - - - -
-
- - - -
-
- - -
- -
- - - ${ - relatedProducts.length > 0 - ? ` -
-
-

관련 상품

-

같은 카테고리의 다른 상품들

-
-
-
- ${relatedProducts - .slice(0, 20) - .map( - (relatedProduct) => ` - - `, - ) - .join("")} -
-
-
- ` - : "" - } - `; -} +import { renderProductDetailPageView } from "../views/pages.js"; /** * 상품 상세 페이지 컴포넌트 @@ -244,23 +16,8 @@ export const ProductDetailPage = withLifecycle( () => { const { currentProduct: product, relatedProducts = [], error, loading } = productStore.getState(); - return PageWrapper({ - headerLeft: ` -
- -

상품 상세

-
- `.trim(), - children: loading - ? loadingContent - : error && !product - ? ErrorContent({ error }) - : ProductDetail({ product, relatedProducts }), + return renderProductDetailPageView({ + productState: { currentProduct: product, relatedProducts, error, loading }, }); }, ); diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..d13383ed 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,5 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { Router } from "../lib/index.js"; import { BASE_URL } from "../constants.js"; export const router = new Router(BASE_URL); diff --git a/packages/vanilla/src/storage/cartStorage.js b/packages/vanilla/src/storage/cartStorage.js index 7aa68383..acf9aee6 100644 --- a/packages/vanilla/src/storage/cartStorage.js +++ b/packages/vanilla/src/storage/cartStorage.js @@ -1,3 +1,3 @@ -import { createStorage } from "../lib"; +import { createStorage } from "../lib/index.js"; export const cartStorage = createStorage("shopping_cart"); diff --git a/packages/vanilla/src/storage/index.js b/packages/vanilla/src/storage/index.js index 122983be..27d82b35 100644 --- a/packages/vanilla/src/storage/index.js +++ b/packages/vanilla/src/storage/index.js @@ -1 +1 @@ -export * from "./cartStorage"; +export * from "./cartStorage.js"; diff --git a/packages/vanilla/src/stores/cartStore.js b/packages/vanilla/src/stores/cartStore.js index fe61f167..cc95a3de 100644 --- a/packages/vanilla/src/stores/cartStore.js +++ b/packages/vanilla/src/stores/cartStore.js @@ -1,5 +1,5 @@ -import { createStore } from "../lib"; -import { CART_ACTIONS } from "./actionTypes"; +import { createStore } from "../lib/index.js"; +import { CART_ACTIONS } from "./actionTypes.js"; import { cartStorage } from "../storage/index.js"; /** diff --git a/packages/vanilla/src/stores/index.js b/packages/vanilla/src/stores/index.js index 36fefd54..2e2c7dda 100644 --- a/packages/vanilla/src/stores/index.js +++ b/packages/vanilla/src/stores/index.js @@ -1,4 +1,4 @@ -export * from "./actionTypes"; -export * from "./productStore"; -export * from "./cartStore"; -export * from "./uiStore"; +export * from "./actionTypes.js"; +export * from "./productStore.js"; +export * from "./cartStore.js"; +export * from "./uiStore.js"; diff --git a/packages/vanilla/src/stores/productStore.js b/packages/vanilla/src/stores/productStore.js index 0f39343d..00c9f5c7 100644 --- a/packages/vanilla/src/stores/productStore.js +++ b/packages/vanilla/src/stores/productStore.js @@ -1,5 +1,5 @@ -import { createStore } from "../lib"; -import { PRODUCT_ACTIONS } from "./actionTypes"; +import { createStore } from "../lib/index.js"; +import { PRODUCT_ACTIONS } from "./actionTypes.js"; /** * 상품 스토어 초기 상태 diff --git a/packages/vanilla/src/stores/uiStore.js b/packages/vanilla/src/stores/uiStore.js index 606603d7..0a05f796 100644 --- a/packages/vanilla/src/stores/uiStore.js +++ b/packages/vanilla/src/stores/uiStore.js @@ -1,5 +1,5 @@ -import { createStore } from "../lib"; -import { UI_ACTIONS } from "./actionTypes"; +import { createStore } from "../lib/index.js"; +import { UI_ACTIONS } from "./actionTypes.js"; /** * UI 스토어 초기 상태 diff --git a/packages/vanilla/src/views/homeView.js b/packages/vanilla/src/views/homeView.js new file mode 100644 index 00000000..f25817d4 --- /dev/null +++ b/packages/vanilla/src/views/homeView.js @@ -0,0 +1,36 @@ +import { ProductList, SearchBar } from "../components/index.js"; +import { PageWrapper } from "../pages/PageWrapper.js"; + +export function renderHomePageView({ productState = {}, query = {}, cart, ui }) { + const { products = [], loading = false, error = null, totalCount = 0, categories = {} } = productState; + + const { search: searchQuery = "", limit = 20, sort = "price_asc", category1 = "", category2 = "" } = query; + + const category = { category1, category2 }; + const hasMore = products.length < totalCount; + + return PageWrapper({ + headerLeft: ` +

+ 쇼핑몰 +

+ `.trim(), + children: ` + + ${SearchBar({ searchQuery, limit, sort, category, categories })} + + +
+ ${ProductList({ + products, + loading, + error, + totalCount, + hasMore, + })} +
+ `.trim(), + cart, + ui, + }); +} diff --git a/packages/vanilla/src/views/notFoundView.js b/packages/vanilla/src/views/notFoundView.js new file mode 100644 index 00000000..5c04737a --- /dev/null +++ b/packages/vanilla/src/views/notFoundView.js @@ -0,0 +1,42 @@ +import { Logo } from "../components/index.js"; +import { PageWrapper } from "../pages/PageWrapper.js"; + +export function renderNotFoundPageView({ cart, ui }) { + return PageWrapper({ + headerLeft: Logo(), + children: ` +
+ + + + + + + + + + + + + 404 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+ `.trim(), + cart, + ui, + }); +} diff --git a/packages/vanilla/src/views/pages.js b/packages/vanilla/src/views/pages.js new file mode 100644 index 00000000..9f0d726a --- /dev/null +++ b/packages/vanilla/src/views/pages.js @@ -0,0 +1,3 @@ +export { renderHomePageView } from "./homeView.js"; +export { renderProductDetailPageView } from "./productDetailView.js"; +export { renderNotFoundPageView } from "./notFoundView.js"; diff --git a/packages/vanilla/src/views/productDetailView.js b/packages/vanilla/src/views/productDetailView.js new file mode 100644 index 00000000..2de8baec --- /dev/null +++ b/packages/vanilla/src/views/productDetailView.js @@ -0,0 +1,238 @@ +import { PageWrapper } from "../pages/PageWrapper.js"; + +function renderLoadingContent() { + return ` +
+
+
+

상품 정보를 불러오는 중...

+
+
+ `; +} + +function renderErrorContent({ error }) { + return ` +
+
+
+ + + +
+

상품을 찾을 수 없습니다

+

${error || "요청하신 상품이 존재하지 않습니다."}

+ + + 홈으로 + +
+
+ `; +} + +function renderBreadcrumb({ category1, category2 }) { + const breadcrumbItems = []; + if (category1) breadcrumbItems.push({ name: category1, category: "category1", value: category1 }); + if (category2) breadcrumbItems.push({ name: category2, category: "category2", value: category2 }); + + if (breadcrumbItems.length === 0) return ""; + + return ` + + `; +} + +function renderRating({ rating, reviewCount }) { + if (!rating || rating <= 0) return ""; + + return ` +
+
+ ${Array(5) + .fill(0) + .map( + (_, i) => ` + + + + `, + ) + .join("")} +
+ ${rating}.0 (${Number(reviewCount || 0).toLocaleString()}개 리뷰) +
+ `; +} + +function renderRelatedProducts({ relatedProducts = [] }) { + if (!relatedProducts || relatedProducts.length === 0) return ""; + + return ` +
+
+

관련 상품

+

같은 카테고리의 다른 상품들

+
+
+
+ ${relatedProducts + .slice(0, 20) + .map( + (p) => ` + + `, + ) + .join("")} +
+
+
+ `; +} + +function renderProductDetailBody({ product, relatedProducts = [] }) { + if (!product) return ""; + + const { + productId, + title, + image, + lprice, + brand, + description = "", + rating = 0, + reviewCount = 0, + stock = 100, + category1, + category2, + } = product; + + const price = Number(lprice); + + return ` + ${renderBreadcrumb({ category1, category2 })} + +
+
+
+ ${title} +
+ +
+

${brand}

+

${title}

+ + ${renderRating({ rating, reviewCount })} + +
+ ${price.toLocaleString()}원 +
+ +
+ 재고 ${Number(stock).toLocaleString()}개 +
+ + ${ + description + ? ` +
+ ${description} +
+ ` + : "" + } +
+
+ +
+
+ 수량 +
+ + + + + +
+
+ + +
+
+ +
+ +
+ + ${renderRelatedProducts({ relatedProducts })} + `; +} + +export function renderProductDetailPageView({ productState = {}, cart, ui }) { + const { currentProduct: product, relatedProducts = [], error = null, loading = false } = productState; + + return PageWrapper({ + headerLeft: ` +
+ +

상품 상세

+
+ `.trim(), + children: loading + ? renderLoadingContent() + : error && !product + ? renderErrorContent({ error }) + : renderProductDetailBody({ product, relatedProducts }), + cart, + ui, + }); +} diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..f6e06bdc 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,20 +1,77 @@ -import fs from "fs"; +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; -const render = () => { - return `
안녕하세요
`; -}; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DIST_DIR = path.resolve(__dirname, "../../dist/vanilla"); +const SSR_DIR = path.resolve(__dirname, "dist/vanilla-ssr"); + +// Mock 데이터 로드 +async function mockGetProducts(params = {}) { + const { limit = 20 } = params; + const itemsPath = path.resolve(__dirname, "src/mocks/items.json"); + const items = JSON.parse(await fs.readFile(itemsPath, "utf-8")); + return items.slice(0, limit); +} + +// 페이지 목록 생성 +async function getPages() { + const products = await mockGetProducts({ limit: 1000 }); // 모든 상품 + + return [ + { url: "/", filePath: `${DIST_DIR}/index.html` }, + { url: "/404", filePath: `${DIST_DIR}/404.html` }, + ...products.map((p) => ({ + url: `/product/${p.productId}/`, + filePath: `${DIST_DIR}/product/${p.productId}/index.html`, + })), + ]; +} + +// HTML 파일 저장 +async function saveHtmlFile(filePath, html) { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(filePath, html); +} async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + console.log("Starting Static Site Generation...\n"); + + // 1. 템플릿 + SSR 모듈 로드 + const templatePath = `${DIST_DIR}/index.html`; + try { + await fs.access(templatePath); + } catch { + console.error("Template not found. Run build:client-for-ssg first."); + process.exit(1); + } + + const template = await fs.readFile(templatePath, "utf-8"); + const { render } = await import(`${SSR_DIR}/main-server.js`); + + // 2. 페이지 목록 생성 + const pages = await getPages(); + console.log(`Generating ${pages.length} pages...\n`); + + // 3. 각 페이지 렌더링 + 저장 + for (const page of pages) { + try { + const { html: appHtml = "", head: appHead = "" } = await render(page.url, {}); + + const html = template.replace("", appHead).replace("", appHtml); - // 어플리케이션 렌더링하기 - const appHtml = render(); + await saveHtmlFile(page.filePath, html); + console.log(`${page.url} -> ${path.relative(DIST_DIR, page.filePath)}`); + } catch (error) { + console.error(`Failed to generate ${page.url}:`, error.message); + } + } - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + console.log(`\n Static Site Generation completed!`); + console.log(` Output: ${DIST_DIR}`); + console.log(` Total pages: ${pages.length}`); } // 실행 -generateStaticSite(); +generateStaticSite().catch(console.error);