diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe0d794 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +.next/ +dist/ +.turbo/ +.env +.env.local +.env.*.local +*.tsbuildinfo +out/ + +# MinIO data (dev) +minio-data/ + +# PostgreSQL data (dev) +pgdata/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..3e775ef --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +auto-install-peers=true diff --git a/README.md b/README.md index e69de29..a107478 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,64 @@ +# ADVdoors + +Modern product catalog for Finnish doors (KASKI, SWEDOOR/JELD-WEN, ALAVUS, ABLOY). + +Built with **Next.js 15**, **Payload CMS 3**, **PostgreSQL 16**, and **MinIO**, managed as a **Turborepo + pnpm** monorepo. + +## Quick Start (Development) + +```bash +just setup # services + deps + .env +just dev # start Next.js dev server +``` + +Or do it all at once: + +```bash +just up # services + dev server +``` + +- **Storefront**: http://localhost:3000 +- **Admin panel**: http://localhost:3000/admin (create first user on first visit) +- **MinIO console**: http://localhost:9001 (minioadmin / minioadmin) +- **Payload API**: http://localhost:3000/api + +## Project Structure + +``` +apps/web/ — Next.js + Payload CMS (storefront + admin) +apps/scraper/ — Migration scraper CLI +packages/shared — Shared types and constants +docker/ — Docker Compose configs, Caddy, backups +``` + +## Production Deployment + +```bash +cp docker/.env.example docker/.env # edit with real secrets +just prod-up +``` + +Caddy handles SSL automatically. + +## Migration (Scraper) + +```bash +just scrape +``` + +## All Commands + +Run `just` to see the full list. Key ones: + +| Command | Description | +|---|---| +| `just setup` | Full dev setup (services + deps + .env) | +| `just up` | Start services + dev server | +| `just dev` | Dev server only (services must be running) | +| `just build` | Build all packages | +| `just scrape` | Run migration scraper | +| `just db-reset` | Wipe dev database and MinIO | +| `just prod-up` | Production build + start | +| `just prod-logs -f` | Tail production logs | + +See [docs/PLAN.md](docs/PLAN.md) for the full architecture plan. diff --git a/apps/scraper/package.json b/apps/scraper/package.json new file mode 100644 index 0000000..2d8e5f9 --- /dev/null +++ b/apps/scraper/package.json @@ -0,0 +1,21 @@ +{ + "name": "@advdoors/scraper", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc", + "scrape": "tsx src/index.ts" + }, + "dependencies": { + "@advdoors/shared": "workspace:*", + "cheerio": "^1", + "undici": "^7" + }, + "devDependencies": { + "@advdoors/tsconfig": "workspace:*", + "tsx": "^4", + "typescript": "^5" + } +} diff --git a/apps/scraper/src/config.ts b/apps/scraper/src/config.ts new file mode 100644 index 0000000..e56daec --- /dev/null +++ b/apps/scraper/src/config.ts @@ -0,0 +1,26 @@ +export const BASE_URL = "https://advdoors.ru"; + +export const CATALOG_PAGES = [ + { url: "/dveri/finskie-vhodnie-dveri", category: "Входные финские двери", brand: "ALAVUS" }, + { url: "/dveri/vhodnie-dveri-Kaski", category: "Входные двери KASKI", brand: "KASKI" }, + { url: "/dveri/vhodnie-dveri-Jeld-Wen", category: "Входные двери JELD-WEN", brand: "JELD-WEN" }, + { url: "/dveri/Alavus-vhodnie-dveri", category: "Входные двери ALAVUS", brand: "ALAVUS" }, + { url: "/dveri/vhodnye-dveri-so-steklom", category: "Со стеклом", brand: null }, + { url: "/dveri/vhodnye-dveri-bez-stekol", category: "Без стёкол", brand: null }, + { url: "/dveri/terrasnie-dveri-finskie", category: "Террасные двери", brand: null }, + { url: "/dveri/mezhkomnatnie-dveri-finskie", category: "Межкомнатные двери", brand: "SWEDOOR" }, + { url: "/dveri/gladkie-karkasnie-dveri", category: "Гладкие каркасные", brand: "SWEDOOR" }, + { url: "/dveri/filenchtie-karkasnie-dveri", category: "Филёнчатые каркасные", brand: "SWEDOOR" }, + { url: "/dveri/protivopozharnie-dveri", category: "Противопожарные", brand: "SWEDOOR" }, + { url: "/dveri/razdvizhnie-dveri", category: "Раздвижные двери", brand: "SWEDOOR" }, + { url: "/dveri/dveri-massiv-kraska", category: "Массив, окрашенные", brand: "SWEDOOR" }, + { url: "/dveri/dveri-massiv-lak", category: "Массив, лак", brand: "SWEDOOR" }, +]; + +export const PAYLOAD_API_URL = + process.env.PAYLOAD_API_URL || "http://localhost:3001/api"; + +export const PAYLOAD_EMAIL = process.env.PAYLOAD_EMAIL || "admin@advdoors.ru"; +export const PAYLOAD_PASSWORD = process.env.PAYLOAD_PASSWORD || ""; + +export const REQUEST_DELAY_MS = 500; diff --git a/apps/scraper/src/crawl.ts b/apps/scraper/src/crawl.ts new file mode 100644 index 0000000..f959dd9 --- /dev/null +++ b/apps/scraper/src/crawl.ts @@ -0,0 +1,133 @@ +import * as cheerio from "cheerio"; +import { request } from "undici"; +import { BASE_URL, REQUEST_DELAY_MS } from "./config.js"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const cp1251Decoder = new TextDecoder("windows-1251"); + +export async function fetchPage(path: string): Promise { + const url = path.startsWith("http") ? path : `${BASE_URL}${path}`; + console.log(` Fetching: ${url}`); + const { body } = await request(url); + const buf = Buffer.from(await body.arrayBuffer()); + const html = cp1251Decoder.decode(buf); + await sleep(REQUEST_DELAY_MS); + return cheerio.load(html); +} + +export interface CatalogListItem { + productUrl: string; + name: string; + price: number; + discountPrice: number | null; + availability: "in-stock" | "made-to-order" | "coming-soon"; +} + +export async function crawlCatalogPage( + path: string, +): Promise<{ items: CatalogListItem[]; nextPage: string | null }> { + const $ = await fetchPage(path); + const items: CatalogListItem[] = []; + + $(".tovitem, .tov-item, .item-card").each((_i, el) => { + const $el = $(el); + const linkEl = $el.find("a[href*='/item']").first(); + const href = linkEl.attr("href"); + if (!href) return; + + const name = + $el.find(".tov-name, .item-name, h3, h4").first().text().trim() || + linkEl.text().trim(); + + const priceText = $el.text(); + const prices = [...priceText.matchAll(/(\d[\d\s]*)\s*(?:руб|₽|РУБ)/gi)].map( + (m) => parseInt(m[1].replace(/\s/g, ""), 10), + ); + + const hasDiscount = prices.length >= 2; + const price = hasDiscount ? prices[0] : prices[0] || 0; + const discountPrice = hasDiscount ? prices[1] : null; + + const availText = $el.text().toLowerCase(); + const availability: CatalogListItem["availability"] = availText.includes( + "в наличии", + ) + ? "in-stock" + : availText.includes("на заказ") + ? "made-to-order" + : "in-stock"; + + items.push({ + productUrl: href.startsWith("http") ? href : `${BASE_URL}${href}`, + name, + price, + discountPrice, + availability, + }); + }); + + if (items.length === 0) { + $("a[href*='/item']").each((_i, el) => { + const href = $(el).attr("href"); + if (!href) return; + const name = $(el).text().trim(); + if (!name || items.some((item) => item.productUrl.includes(href))) return; + + const parent = $(el).parent().parent(); + const priceText = parent.text(); + const prices = [ + ...priceText.matchAll(/(\d[\d\s]*)\s*(?:руб|₽|РУБ)/gi), + ].map((m) => parseInt(m[1].replace(/\s/g, ""), 10)); + + const hasDiscount = prices.length >= 2; + const price = hasDiscount ? prices[0] : prices[0] || 0; + const discountPrice = hasDiscount ? prices[1] : null; + + const availText = parent.text().toLowerCase(); + const availability: CatalogListItem["availability"] = + availText.includes("в наличии") + ? "in-stock" + : availText.includes("на заказ") + ? "made-to-order" + : "in-stock"; + + items.push({ + productUrl: href.startsWith("http") ? href : `${BASE_URL}${href}`, + name, + price, + discountPrice, + availability, + }); + }); + } + + let nextPage: string | null = null; + $('a[href*="pagenum"]').each((_i, el) => { + const text = $(el).text().trim(); + if (text.includes(">>") || text.includes("Следующая")) { + const href = $(el).attr("href"); + if (href) { + nextPage = href.startsWith("http") ? href : `${BASE_URL}${href}`; + } + } + }); + + return { items, nextPage }; +} + +export async function crawlAllPages( + startPath: string, +): Promise { + const allItems: CatalogListItem[] = []; + let currentPath: string | null = startPath; + + while (currentPath) { + const { items, nextPage } = await crawlCatalogPage(currentPath); + allItems.push(...items); + console.log(` Found ${items.length} items on page`); + currentPath = nextPage; + } + + return allItems; +} diff --git a/apps/scraper/src/download-media.ts b/apps/scraper/src/download-media.ts new file mode 100644 index 0000000..c2567e8 --- /dev/null +++ b/apps/scraper/src/download-media.ts @@ -0,0 +1,32 @@ +import { request } from "undici"; +import path from "path"; + +export interface DownloadedImage { + buffer: Buffer; + filename: string; + contentType: string; +} + +export async function downloadImage( + imageUrl: string, + articleNumber: string, + index: number, +): Promise { + try { + const { body, headers } = await request(imageUrl); + const buffer = Buffer.from(await body.arrayBuffer()); + + if (buffer.length < 1000) return null; + + const ext = path.extname(new URL(imageUrl).pathname) || ".jpg"; + const filename = `${articleNumber}_${index}${ext}`; + const contentType = + headers["content-type"]?.toString() || `image/${ext.replace(".", "")}`; + + console.log(` Downloaded: ${filename} (${(buffer.length / 1024).toFixed(0)} KB)`); + return { buffer, filename, contentType }; + } catch (error) { + console.error(` Failed to download ${imageUrl}:`, error); + return null; + } +} diff --git a/apps/scraper/src/extract.ts b/apps/scraper/src/extract.ts new file mode 100644 index 0000000..e020521 --- /dev/null +++ b/apps/scraper/src/extract.ts @@ -0,0 +1,121 @@ +import { fetchPage } from "./crawl.js"; +import { BASE_URL } from "./config.js"; + +export interface ProductDetail { + name: string; + articleNumber: string; + price: number; + discountPrice: number | null; + availability: "in-stock" | "made-to-order" | "coming-soon"; + shortDescription: string; + technicalSpecs: string; + imageUrls: string[]; + options: Array<{ name: string; priceModifier: number; description: string }>; +} + +export async function extractProduct(url: string): Promise { + const $ = await fetchPage(url); + + const articleMatch = url.match(/item(\d+)/); + const articleNumber = articleMatch ? articleMatch[1] : ""; + + const name = $("h1").first().text().trim(); + + const bodyText = $("body").text(); + const allPrices = [...bodyText.matchAll(/(\d[\d\s]*)\s*(?:руб|₽|РУБ)/gi)].map( + (m) => parseInt(m[1].replace(/\s/g, ""), 10), + ); + const validPrices = allPrices.filter((p) => p > 1000); + + let price = validPrices[0] || 0; + let discountPrice: number | null = null; + + if (validPrices.length >= 2 && validPrices[1] < validPrices[0]) { + price = validPrices[0]; + discountPrice = validPrices[1]; + } + + const availText = bodyText.toLowerCase(); + const availability: ProductDetail["availability"] = availText.includes( + "в наличии", + ) + ? "in-stock" + : availText.includes("на заказ") + ? "made-to-order" + : "in-stock"; + + let technicalSpecs = ""; + const specHeaders = $("h3, h4, strong, b").filter( + (_i, el) => + $(el).text().toLowerCase().includes("техническое") || + $(el).text().toLowerCase().includes("описание"), + ); + if (specHeaders.length > 0) { + const specParent = specHeaders.first().parent(); + technicalSpecs = specParent.text().trim().slice(0, 5000); + } + + const imageUrls: string[] = []; + const seenPaths = new Set(); + const resizePrefixRe = /^\/[fi]w?\d+(?:h\d+)?\//; + + function normalizeImagePath(raw: string): string { + return raw.replace(resizePrefixRe, "/"); + } + + function addImage(raw: string): void { + if (!raw) return; + if (raw.includes("logo") || raw.includes("icon") || raw.includes("banner") || raw.includes("fav")) return; + if (!raw.includes("/pages/photos/") && !raw.includes("/pages/catalog/")) return; + + const canonical = normalizeImagePath(raw); + if (seenPaths.has(canonical)) return; + seenPaths.add(canonical); + + const highRes = `/iw800${canonical}`; + const fullUrl = `${BASE_URL}${highRes}`; + imageUrls.push(fullUrl); + } + + $("a[href]").each((_i, el) => { + const href = $(el).attr("href"); + if (href && /\.(jpe?g|png|webp)$/i.test(href)) { + addImage(href); + } + }); + + $("img").each((_i, el) => { + const src = $(el).attr("src") || $(el).attr("data-src"); + if (src) addImage(src); + }); + + const options: ProductDetail["options"] = []; + const optionMatches = [ + ...bodyText.matchAll( + /([^:•\n]+?):\s*\+?\s*(\d[\d\s.]*)\s*(?:рублей|руб)/gi, + ), + ]; + for (const match of optionMatches) { + const optName = match[1].trim(); + const optPrice = parseInt(match[2].replace(/[\s.]/g, ""), 10); + if (optName.length > 3 && optName.length < 100 && optPrice > 0) { + options.push({ + name: optName, + priceModifier: optPrice, + description: "", + }); + } + } + + return { + name, + articleNumber, + price, + discountPrice, + availability, + shortDescription: "", + technicalSpecs, + imageUrls, + options, + }; +} diff --git a/apps/scraper/src/import.ts b/apps/scraper/src/import.ts new file mode 100644 index 0000000..2b782cd --- /dev/null +++ b/apps/scraper/src/import.ts @@ -0,0 +1,198 @@ +import { request } from "undici"; +import { PAYLOAD_API_URL, PAYLOAD_EMAIL, PAYLOAD_PASSWORD, REQUEST_DELAY_MS } from "./config.js"; + +let authToken: string | null = null; + +const MAX_RETRIES = 3; +const RETRY_BASE_DELAY_MS = 2000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function login(): Promise { + if (!PAYLOAD_PASSWORD) { + throw new Error( + "PAYLOAD_PASSWORD is not set. Export it before running the scraper.", + ); + } + + const { body, statusCode } = await request(`${PAYLOAD_API_URL}/users/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: PAYLOAD_EMAIL, password: PAYLOAD_PASSWORD }), + }); + + const responseText = await body.text(); + if (statusCode >= 400) { + throw new Error(`Payload login failed (${statusCode}): ${responseText}`); + } + + const json = JSON.parse(responseText); + authToken = json.token; + console.log(`Authenticated as ${PAYLOAD_EMAIL}`); +} + +async function payloadRequest( + method: string, + endpoint: string, + data?: unknown, +) { + const url = `${PAYLOAD_API_URL}${endpoint}`; + const headers: Record = { + "Content-Type": "application/json", + }; + + if (authToken) { + headers["Authorization"] = `JWT ${authToken}`; + } + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const { body, statusCode } = await request(url, { + method: method as "GET" | "POST" | "PATCH", + headers, + body: data ? JSON.stringify(data) : undefined, + }); + + const responseText = await body.text(); + + const isRetryable = statusCode >= 500 || statusCode === 404; + if (statusCode >= 400) { + const looksLikeHtml = responseText.trimStart().startsWith("<") || responseText.includes(" { + const existing = await payloadRequest( + "GET", + `/categories?where[slug][equals]=${encodeURIComponent(slug)}&limit=1`, + ); + + if (existing.docs?.length > 0) { + return existing.docs[0].id; + } + + const created = await payloadRequest("POST", "/categories", { name, slug }); + console.log(` Created category: ${name}`); + return created.doc.id; +} + +export async function uploadMedia( + fileBuffer: Buffer, + filename: string, + contentType: string, + alt: string, +): Promise { + try { + const url = `${PAYLOAD_API_URL}/media`; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const form = new FormData(); + form.append("file", new File([fileBuffer], filename, { type: contentType })); + form.append("_payload", JSON.stringify({ alt })); + + const headers: Record = {}; + if (authToken) { + headers["Authorization"] = `JWT ${authToken}`; + } + + const res = await fetch(url, { + method: "POST", + headers, + body: form, + }); + + const responseText = await res.text(); + + if (!res.ok) { + const looksLikeHtml = + responseText.trimStart().startsWith("<") || + responseText.includes("= 500 || res.status === 404 || res.status === 408) && looksLikeHtml && attempt < MAX_RETRIES) { + const delay = RETRY_BASE_DELAY_MS * 2 ** attempt; + console.warn( + ` Media upload returned ${res.status}, retrying in ${delay}ms (${attempt + 1}/${MAX_RETRIES})...`, + ); + await sleep(delay); + continue; + } + throw new Error(`Payload media upload error ${res.status}: ${responseText}`); + } + + const json = JSON.parse(responseText); + console.log(` Media created: ${filename}`); + return json.doc?.id || null; + } + + throw new Error(`Media upload exhausted retries for ${filename}`); + } catch (error) { + console.error(` Failed to upload media ${filename}:`, error); + return null; + } +} + +export async function createProduct(data: { + name: string; + slug: string; + articleNumber: string; + brand: string; + category?: string; + price: number; + discountPrice?: number | null; + availability: string; + shortDescription?: string; + technicalSpecs?: string; + options?: Array<{ name: string; priceModifier: number; description?: string }>; + images?: string[]; +}): Promise { + try { + const existing = await payloadRequest( + "GET", + `/products?where[articleNumber][equals]=${encodeURIComponent(data.articleNumber)}&limit=1`, + ); + + if (existing.docs?.length > 0) { + console.log(` Skipping existing product: ${data.name} (${data.articleNumber})`); + return existing.docs[0].id; + } + + const payload: Record = { + name: data.name, + slug: data.slug, + articleNumber: data.articleNumber, + brand: data.brand, + price: data.price, + availability: data.availability, + }; + + if (data.category) payload.category = data.category; + if (data.discountPrice) payload.discountPrice = data.discountPrice; + if (data.shortDescription) payload.shortDescription = data.shortDescription; + if (data.options?.length) payload.options = data.options; + if (data.images?.length) payload.images = data.images; + + const created = await payloadRequest("POST", "/products", payload); + console.log(` Created product: ${data.name}`); + return created.doc?.id || null; + } catch (error) { + console.error(` Failed to create product ${data.name}:`, error); + return null; + } +} diff --git a/apps/scraper/src/index.ts b/apps/scraper/src/index.ts new file mode 100644 index 0000000..105b706 --- /dev/null +++ b/apps/scraper/src/index.ts @@ -0,0 +1,118 @@ +import { CATALOG_PAGES } from "./config.js"; +import { crawlAllPages, type CatalogListItem } from "./crawl.js"; +import { extractProduct } from "./extract.js"; +import { downloadImage } from "./download-media.js"; +import { login, findOrCreateCategory, createProduct, uploadMedia } from "./import.js"; + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-zа-яё0-9]+/gi, "-") + .replace(/^-+|-+$/g, "") + .replace(/-+/g, "-"); +} + +function detectBrand(name: string, fallback: string | null): string { + const upper = name.toUpperCase(); + if (upper.includes("KASKI")) return "KASKI"; + if (upper.includes("ALAVUS")) return "ALAVUS"; + if (upper.includes("SWEDOOR")) return "SWEDOOR"; + if (upper.includes("JELD-WEN") || upper.includes("JELDWEN")) return "JELD-WEN"; + if (upper.includes("MATTIOVI")) return "MATTIOVI"; + if (upper.includes("ABLOY")) return "ABLOY"; + return fallback || "ALAVUS"; +} + +async function main() { + console.log("=== ADVdoors Scraper ===\n"); + + await login(); + + const stats = { + categories: 0, + products: 0, + images: 0, + errors: 0, + }; + + for (const catalogPage of CATALOG_PAGES) { + console.log(`\n--- Crawling: ${catalogPage.category} (${catalogPage.url}) ---`); + + const categorySlug = slugify(catalogPage.category); + const categoryId = await findOrCreateCategory( + catalogPage.category, + categorySlug, + ); + stats.categories++; + + let items: CatalogListItem[]; + try { + items = await crawlAllPages(catalogPage.url); + } catch (error) { + console.error(` Failed to crawl ${catalogPage.url}:`, error); + stats.errors++; + continue; + } + + console.log(` Found ${items.length} products in category`); + + for (const item of items) { + try { + console.log(`\n Processing: ${item.name}`); + const detail = await extractProduct(item.productUrl); + + const brand = detectBrand(detail.name || item.name, catalogPage.brand); + const productSlug = slugify( + `${detail.name || item.name}-${detail.articleNumber}`, + ); + + const imageIds: string[] = []; + for (let i = 0; i < detail.imageUrls.length && i < 10; i++) { + const img = await downloadImage( + detail.imageUrls[i], + detail.articleNumber, + i, + ); + if (img) { + const alt = `${detail.name || item.name} — фото ${i + 1}`; + const mediaId = await uploadMedia(img.buffer, img.filename, img.contentType, alt); + if (mediaId) { + imageIds.push(mediaId); + stats.images++; + } + } + } + + await createProduct({ + name: detail.name || item.name, + slug: productSlug, + articleNumber: detail.articleNumber, + brand, + category: categoryId, + price: detail.price || item.price, + discountPrice: detail.discountPrice || item.discountPrice, + availability: detail.availability || item.availability, + shortDescription: detail.shortDescription, + options: detail.options, + images: imageIds.length > 0 ? imageIds : undefined, + }); + + stats.products++; + } catch (error) { + console.error(` Error processing ${item.name}:`, error); + stats.errors++; + } + } + } + + console.log("\n=== Scraping Complete ==="); + console.log(`Categories created: ${stats.categories}`); + console.log(`Products imported: ${stats.products}`); + console.log(`Images uploaded: ${stats.images}`); + console.log(`Errors: ${stats.errors}`); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/apps/scraper/tsconfig.json b/apps/scraper/tsconfig.json new file mode 100644 index 0000000..9771927 --- /dev/null +++ b/apps/scraper/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@advdoors/tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..4efc8e1 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,10 @@ +DATABASE_URI=postgresql://advdoors:advdoors@localhost:5432/advdoors +PAYLOAD_SECRET=CHANGE_ME_TO_A_RANDOM_SECRET_AT_LEAST_32_CHARS + +S3_ENDPOINT=http://localhost:9000 +S3_BUCKET=advdoors-media +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_REGION=us-east-1 + +NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..0c0b32e --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,3 @@ +import config from "@advdoors/eslint-config/next"; + +export default config; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..14379fe --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,20 @@ +import { withPayload } from "@payloadcms/next/withPayload"; +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + images: { + remotePatterns: [ + { + protocol: "http", + hostname: "localhost", + }, + { + protocol: "http", + hostname: "minio", + }, + ], + }, +}; + +export default withPayload(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..725caca --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@advdoors/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "generate:types": "payload generate:types" + }, + "dependencies": { + "@advdoors/shared": "workspace:*", + "@payloadcms/db-postgres": "^3", + "@payloadcms/next": "^3", + "@payloadcms/richtext-lexical": "^3", + "@payloadcms/storage-s3": "^3", + "graphql": "^16", + "next": "~15.4", + "payload": "^3", + "react": "^19", + "react-dom": "^19", + "sharp": "^0.33" + }, + "devDependencies": { + "@advdoors/eslint-config": "workspace:*", + "@advdoors/tsconfig": "workspace:*", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "@tailwindcss/postcss": "^4", + "postcss": "^8", + "typescript": "^5", + "eslint": "^9" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 0000000..94d8a3f --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / +Disallow: /admin +Disallow: /api diff --git a/apps/web/src/app/(frontend)/[slug]/page.tsx b/apps/web/src/app/(frontend)/[slug]/page.tsx new file mode 100644 index 0000000..5b10973 --- /dev/null +++ b/apps/web/src/app/(frontend)/[slug]/page.tsx @@ -0,0 +1,58 @@ +import { notFound } from "next/navigation"; +import { getPayload } from "payload"; +import config from "@payload-config"; +import type { Metadata } from "next"; +import { RichText } from "@/components/RichText"; + +interface ContentPageProps { + params: Promise<{ slug: string }>; +} + +const RESERVED_SLUGS = ["catalog", "cart", "product"]; + +export async function generateMetadata({ + params, +}: ContentPageProps): Promise { + const { slug } = await params; + if (RESERVED_SLUGS.includes(slug)) return {}; + + const payload = await getPayload({ config }); + const { docs } = await payload.find({ + collection: "pages", + where: { slug: { equals: slug } }, + limit: 1, + }); + const page = docs[0]; + if (!page) return { title: "Не найдено" }; + + return { + title: page.seo?.metaTitle || page.title, + description: page.seo?.metaDescription || undefined, + }; +} + +export default async function ContentPage({ params }: ContentPageProps) { + const { slug } = await params; + if (RESERVED_SLUGS.includes(slug)) notFound(); + + const payload = await getPayload({ config }); + const { docs } = await payload.find({ + collection: "pages", + where: { slug: { equals: slug } }, + limit: 1, + }); + + const page = docs[0]; + if (!page) notFound(); + + return ( +
+

{page.title}

+ {page.content && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(frontend)/api/submit-order/route.ts b/apps/web/src/app/(frontend)/api/submit-order/route.ts new file mode 100644 index 0000000..9de2820 --- /dev/null +++ b/apps/web/src/app/(frontend)/api/submit-order/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { getPayload } from "payload"; +import config from "@payload-config"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { items, customer } = body; + + if (!items?.length || !customer?.name || !customer?.phone) { + return NextResponse.json( + { error: "Не заполнены обязательные поля" }, + { status: 400 }, + ); + } + + const payload = await getPayload({ config }); + + const order = await payload.create({ + collection: "orders", + data: { + items: items.map( + (item: { product: string; quantity: number; priceAtOrder: number }) => ({ + product: item.product, + quantity: item.quantity, + priceAtOrder: item.priceAtOrder, + }), + ), + customer: { + name: customer.name, + phone: customer.phone, + email: customer.email || undefined, + comment: customer.comment || undefined, + }, + status: "new", + }, + }); + + const settings = await payload.findGlobal({ slug: "site-settings" }); + const adminEmail = settings.email; + + if (adminEmail) { + const itemLines = items + .map( + (item: { product: string; quantity: number; priceAtOrder: number }) => + ` - ${item.product} x${item.quantity} = ${item.priceAtOrder * item.quantity} ₽`, + ) + .join("\n"); + + const total = items.reduce( + (sum: number, item: { priceAtOrder: number; quantity: number }) => + sum + item.priceAtOrder * item.quantity, + 0, + ); + + await payload.sendEmail({ + to: adminEmail, + subject: `Новый заказ #${order.orderNumber} на ADVdoors`, + text: [ + `Новый заказ #${order.orderNumber}`, + "", + `Клиент: ${customer.name}`, + `Телефон: ${customer.phone}`, + customer.email ? `Email: ${customer.email}` : null, + customer.comment ? `Комментарий: ${customer.comment}` : null, + "", + "Товары:", + itemLines, + "", + `Итого: ${total.toLocaleString("ru-RU")} ₽`, + "", + `Управление заказом: ${process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"}/admin/collections/orders/${order.id}`, + ] + .filter(Boolean) + .join("\n"), + }); + } + + return NextResponse.json({ + success: true, + orderNumber: order.orderNumber, + }); + } catch (error) { + console.error("Order submission error:", error); + return NextResponse.json( + { error: "Ошибка при создании заказа" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/(frontend)/cart/page.tsx b/apps/web/src/app/(frontend)/cart/page.tsx new file mode 100644 index 0000000..9785820 --- /dev/null +++ b/apps/web/src/app/(frontend)/cart/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { CartView } from "@/components/CartView"; + +export const metadata: Metadata = { + title: "Корзина", +}; + +export default function CartPage() { + return ( +
+

Корзина

+ +
+ ); +} diff --git a/apps/web/src/app/(frontend)/catalog/page.tsx b/apps/web/src/app/(frontend)/catalog/page.tsx new file mode 100644 index 0000000..f81f3b7 --- /dev/null +++ b/apps/web/src/app/(frontend)/catalog/page.tsx @@ -0,0 +1,313 @@ +import Link from "next/link"; +import { getPayload } from "payload"; +import type { Where } from "payload"; +import config from "@payload-config"; +import type { Metadata } from "next"; +import { ProductCard } from "@/components/ProductCard"; +import { BRANDS } from "@advdoors/shared"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Каталог", + description: "Каталог финских входных и межкомнатных дверей", +}; + +interface CatalogPageProps { + searchParams: Promise<{ + page?: string; + brand?: string; + category?: string; + q?: string; + }>; +} + +export default async function CatalogPage({ searchParams }: CatalogPageProps) { + const params = await searchParams; + const page = Number(params.page) || 1; + const payload = await getPayload({ config }); + + const conditions: Where[] = []; + if (params.brand) { + conditions.push({ brand: { equals: params.brand } }); + } + if (params.category) { + conditions.push({ "category.slug": { equals: params.category } }); + } + if (params.q) { + conditions.push({ + or: [ + { name: { contains: params.q } }, + { articleNumber: { contains: params.q } }, + ], + }); + } + + const where: Where = conditions.length > 0 ? { and: conditions } : {}; + + const { docs: products, totalPages, totalDocs } = await payload.find({ + collection: "products", + limit: 24, + page, + where, + sort: "-createdAt", + depth: 1, + }); + + const { docs: categories } = await payload.find({ + collection: "categories", + limit: 100, + sort: "name", + depth: 0, + }); + + function buildUrl(overrides: Record) { + const next: Record = {}; + if (params.brand) next.brand = params.brand; + if (params.category) next.category = params.category; + if (params.q) next.q = params.q; + for (const [k, v] of Object.entries(overrides)) { + if (v) next[k] = v; + else delete next[k]; + } + const qs = new URLSearchParams(next).toString(); + return `/catalog${qs ? `?${qs}` : ""}`; + } + + return ( +
+ {/* Breadcrumb */} + + +

Каталог дверей

+

+ {totalDocs > 0 + ? `${totalDocs} ${totalDocs === 1 ? "товар" : totalDocs < 5 ? "товара" : "товаров"}` + : "Нет товаров"} +

+ +
+ {/* Sidebar */} + + + {/* Product grid */} +
+ {/* Active filters */} + {(params.brand || params.category || params.q) && ( +
+ {params.brand && ( + + {params.brand} × + + )} + {params.category && ( + + {categories.find((c) => c.slug === params.category)?.name || + params.category}{" "} + × + + )} + {params.q && ( + + “{params.q}” × + + )} + + Сбросить все + +
+ )} + + {products.length === 0 ? ( +
+

Товары не найдены

+ + Показать все товары + +
+ ) : ( +
+ {products.map((product) => ( + + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/(frontend)/globals.css b/apps/web/src/app/(frontend)/globals.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/apps/web/src/app/(frontend)/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/apps/web/src/app/(frontend)/layout.tsx b/apps/web/src/app/(frontend)/layout.tsx new file mode 100644 index 0000000..3be9169 --- /dev/null +++ b/apps/web/src/app/(frontend)/layout.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import React from "react"; +import "./globals.css"; +import { Header } from "@/components/Header"; +import { Footer } from "@/components/Footer"; +import { organizationJsonLd } from "@/lib/structured-data"; + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + +export const metadata: Metadata = { + title: { + default: "ADVdoors — Финские двери", + template: "%s | ADVdoors", + }, + description: + "Финские входные и межкомнатные двери KASKI, SWEDOOR by JELD-WEN, ALAVUS. Доставка по Москве и России.", + metadataBase: new URL(SITE_URL), + openGraph: { + type: "website", + locale: "ru_RU", + siteName: "ADVdoors", + }, +}; + +export default function FrontendLayout({ + children, +}: { + children: React.ReactNode; +}) { + const orgData = organizationJsonLd(); + + return ( + + +