This commit is contained in:
Снесарев Максим 2026-04-01 22:34:50 +03:00
parent e3464ca776
commit a240d523e1
78 changed files with 12743 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -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/

1
.npmrc Normal file
View File

@ -0,0 +1 @@
auto-install-peers=true

View File

@ -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.

21
apps/scraper/package.json Normal file
View File

@ -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"
}
}

View File

@ -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;

133
apps/scraper/src/crawl.ts Normal file
View File

@ -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<cheerio.CheerioAPI> {
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<CatalogListItem[]> {
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;
}

View File

@ -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<DownloadedImage | null> {
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;
}
}

121
apps/scraper/src/extract.ts Normal file
View File

@ -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<ProductDetail> {
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<string>();
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,
};
}

198
apps/scraper/src/import.ts Normal file
View File

@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function login(): Promise<void> {
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<string, string> = {
"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("<!DOCTYPE");
if (isRetryable && looksLikeHtml && attempt < MAX_RETRIES) {
const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
console.warn(
` Payload returned ${statusCode} (HTML error page), retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})...`,
);
await sleep(delay);
continue;
}
throw new Error(`Payload API error ${statusCode}: ${responseText}`);
}
return JSON.parse(responseText);
}
throw new Error(`Payload request exhausted retries for ${method} ${endpoint}`);
}
export async function findOrCreateCategory(
name: string,
slug: string,
): Promise<string> {
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<string | null> {
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<string, string> = {};
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("<!DOCTYPE");
if ((res.status >= 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<string | null> {
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<string, unknown> = {
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;
}
}

118
apps/scraper/src/index.ts Normal file
View File

@ -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);
});

View File

@ -0,0 +1,8 @@
{
"extends": "@advdoors/tsconfig/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

10
apps/web/.env.example Normal file
View File

@ -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

View File

@ -0,0 +1,3 @@
import config from "@advdoors/eslint-config/next";
export default config;

5
apps/web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

20
apps/web/next.config.ts Normal file
View File

@ -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);

39
apps/web/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api

View File

@ -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<Metadata> {
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 (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold">{page.title}</h1>
{page.content && (
<div className="prose mt-8 max-w-none">
<RichText data={page.content} />
</div>
)}
</div>
);
}

View File

@ -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 },
);
}
}

View File

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { CartView } from "@/components/CartView";
export const metadata: Metadata = {
title: "Корзина",
};
export default function CartPage() {
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold">Корзина</h1>
<CartView />
</div>
);
}

View File

@ -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<string, string | undefined>) {
const next: Record<string, string> = {};
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 (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{/* Breadcrumb */}
<nav className="text-sm text-slate-500">
<Link href="/" className="hover:text-amber-600">
Главная
</Link>
<span className="mx-2">/</span>
<span className="text-slate-900">Каталог</span>
</nav>
<h1 className="mt-4 text-3xl font-bold">Каталог дверей</h1>
<p className="mt-1 text-sm text-slate-500">
{totalDocs > 0
? `${totalDocs} ${totalDocs === 1 ? "товар" : totalDocs < 5 ? "товара" : "товаров"}`
: "Нет товаров"}
</p>
<div className="mt-8 grid gap-8 lg:grid-cols-[240px_1fr]">
{/* Sidebar */}
<aside className="space-y-6">
{/* Search */}
<form action="/catalog" method="GET">
<label className="text-sm font-semibold text-slate-900">
Поиск
</label>
<div className="mt-2 flex">
<input
type="text"
name="q"
defaultValue={params.q}
placeholder="Название или артикул"
className="w-full rounded-l-lg border border-r-0 px-3 py-2 text-sm"
/>
<button
type="submit"
className="rounded-r-lg bg-slate-900 px-3 py-2 text-white hover:bg-slate-800"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
</button>
</div>
</form>
{/* Categories */}
<div>
<h3 className="text-sm font-semibold text-slate-900">Категории</h3>
<ul className="mt-2 space-y-1">
<li>
<Link
href={buildUrl({ category: undefined, page: undefined })}
className={`block rounded-md px-2 py-1.5 text-sm transition ${
!params.category
? "bg-amber-50 font-semibold text-amber-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
>
Все категории
</Link>
</li>
{categories.map((cat) => (
<li key={cat.id}>
<Link
href={buildUrl({
category: cat.slug,
page: undefined,
})}
className={`block rounded-md px-2 py-1.5 text-sm transition ${
params.category === cat.slug
? "bg-amber-50 font-semibold text-amber-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
>
{cat.name}
</Link>
</li>
))}
</ul>
</div>
{/* Brands */}
<div>
<h3 className="text-sm font-semibold text-slate-900">Бренд</h3>
<ul className="mt-2 space-y-1">
<li>
<Link
href={buildUrl({ brand: undefined, page: undefined })}
className={`block rounded-md px-2 py-1.5 text-sm transition ${
!params.brand
? "bg-amber-50 font-semibold text-amber-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
>
Все бренды
</Link>
</li>
{BRANDS.map((brand) => (
<li key={brand.value}>
<Link
href={buildUrl({
brand: brand.value,
page: undefined,
})}
className={`block rounded-md px-2 py-1.5 text-sm transition ${
params.brand === brand.value
? "bg-amber-50 font-semibold text-amber-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
>
{brand.label}
</Link>
</li>
))}
</ul>
</div>
</aside>
{/* Product grid */}
<div>
{/* Active filters */}
{(params.brand || params.category || params.q) && (
<div className="mb-6 flex flex-wrap items-center gap-2">
{params.brand && (
<Link
href={buildUrl({ brand: undefined, page: undefined })}
className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700 hover:bg-slate-200"
>
{params.brand} ×
</Link>
)}
{params.category && (
<Link
href={buildUrl({ category: undefined, page: undefined })}
className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700 hover:bg-slate-200"
>
{categories.find((c) => c.slug === params.category)?.name ||
params.category}{" "}
×
</Link>
)}
{params.q && (
<Link
href={buildUrl({ q: undefined, page: undefined })}
className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700 hover:bg-slate-200"
>
&ldquo;{params.q}&rdquo; ×
</Link>
)}
<Link
href="/catalog"
className="text-sm text-amber-600 hover:underline"
>
Сбросить все
</Link>
</div>
)}
{products.length === 0 ? (
<div className="rounded-xl border bg-slate-50 p-12 text-center">
<p className="text-lg text-slate-500">Товары не найдены</p>
<Link
href="/catalog"
className="mt-4 inline-block text-sm text-amber-600 hover:underline"
>
Показать все товары
</Link>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
{products.map((product) => (
<ProductCard
key={product.id}
product={{
id: product.id,
slug: product.slug,
name: product.name,
articleNumber: product.articleNumber,
price: product.price,
discountPrice: product.discountPrice,
availability: product.availability,
images: product.images as unknown[],
}}
/>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<nav className="mt-10 flex justify-center gap-1.5">
{page > 1 && (
<Link
href={buildUrl({ page: String(page - 1) })}
className="rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-700 hover:bg-slate-200"
>
</Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
href={buildUrl({ page: String(p) })}
className={`min-w-[2.5rem] rounded-lg px-3 py-2 text-center text-sm ${
p === page
? "bg-slate-900 font-medium text-white"
: "bg-slate-100 text-slate-700 hover:bg-slate-200"
}`}
>
{p}
</Link>
))}
{page < totalPages && (
<Link
href={buildUrl({ page: String(page + 1) })}
className="rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-700 hover:bg-slate-200"
>
</Link>
)}
</nav>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -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 (
<html lang="ru" suppressHydrationWarning>
<body className="min-h-screen flex flex-col bg-white text-gray-900 antialiased">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgData) }}
/>
<Header />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
);
}

View File

@ -0,0 +1,250 @@
import Link from "next/link";
import { getPayload } from "payload";
import config from "@payload-config";
import { ProductCard } from "@/components/ProductCard";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const payload = await getPayload({ config });
const { docs: featured } = await payload.find({
collection: "products",
limit: 8,
sort: "-createdAt",
depth: 1,
});
const { docs: categories } = await payload.find({
collection: "categories",
limit: 20,
where: { parent: { exists: false } },
sort: "name",
depth: 0,
});
return (
<>
{/* Hero */}
<section className="relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-700 text-white">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmYiIGZpbGwtb3BhY2l0eT0iMC4wMyI+PHBhdGggZD0iTTM2IDE4YzMuMzEzIDAgNiAyLjY4NyA2IDZzLTIuNjg3IDYtNiA2LTYtMi42ODctNi02IDIuNjg3LTYgNi02eiIvPjwvZz48L2c+PC9zdmc+')] opacity-50" />
<div className="relative mx-auto max-w-7xl px-4 py-20 sm:px-6 sm:py-28 lg:px-8 lg:py-36">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
Финские двери
<span className="block text-amber-400">в Москве и России</span>
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-slate-300">
Входные и межкомнатные двери KASKI, SWEDOOR by JELD-WEN, ALAVUS.
Оригинальная продукция с гарантией от производителя. Работаем с 1994
года.
</p>
<div className="mt-10 flex flex-wrap gap-4">
<Link
href="/catalog"
className="rounded-lg bg-amber-500 px-6 py-3.5 font-semibold text-slate-900 shadow-lg shadow-amber-500/25 transition hover:bg-amber-400"
>
Каталог дверей
</Link>
<Link
href="/contacts"
className="rounded-lg border border-white/20 px-6 py-3.5 font-semibold text-white backdrop-blur transition hover:bg-white/10"
>
Контакты
</Link>
</div>
</div>
</section>
{/* Brands bar */}
<section className="border-b bg-white py-10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-4 text-xl font-bold tracking-wide text-slate-300">
{["KASKI", "SWEDOOR", "JELD-WEN", "ALAVUS", "MATTIOVI", "ABLOY"].map(
(brand) => (
<Link
key={brand}
href={`/catalog?brand=${brand}`}
className="transition hover:text-amber-600"
>
{brand}
</Link>
),
)}
</div>
</div>
</section>
{/* Categories */}
{categories.length > 0 && (
<section className="py-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<h2 className="text-center text-2xl font-bold text-slate-900 sm:text-3xl">
Категории
</h2>
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{categories.map((cat) => (
<Link
key={cat.id}
href={`/catalog?category=${cat.slug}`}
className="group flex items-center gap-4 rounded-xl border bg-white p-5 shadow-sm transition hover:border-amber-200 hover:shadow-md"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-amber-50 text-amber-600 transition group-hover:bg-amber-100">
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3.75 9.75h16.5m-16.5 0A2.25 2.25 0 015.25 7.5h13.5a2.25 2.25 0 012.25 2.25m-16.5 0v7.5A2.25 2.25 0 005.25 19.5h13.5a2.25 2.25 0 002.25-2.25v-7.5"
/>
</svg>
</div>
<span className="font-semibold text-slate-900 group-hover:text-amber-600">
{cat.name}
</span>
</Link>
))}
</div>
</div>
</section>
)}
{/* Why Finnish doors */}
<section className="border-t bg-slate-50 py-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<h2 className="text-center text-2xl font-bold sm:text-3xl">
Почему финские двери?
</h2>
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[
{
icon: "M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.047 8.287 8.287 0 009 9.601a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z",
title: "Теплоизоляция",
desc: "Коэффициент теплопередачи U ≤ 0,71,0 W/m²K — сохраняют тепло в суровом климате.",
},
{
icon: "M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z",
title: "Надёжность",
desc: "Каркас из массива сосны, усиленный брусом LVL и листовым алюминием с двух сторон.",
},
{
icon: "M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z",
title: "Безопасность",
desc: "Замки Abloy, противовзломные петли, силиконовый уплотнитель по периметру.",
},
{
icon: "M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42",
title: "Качество отделки",
desc: "MDF влагостойкий, атмосферная краска. Окраска по каталогам NCS/RAL.",
},
{
icon: "M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z",
title: "Гарантия",
desc: "Оригинальная продукция от производителей с гарантией качества.",
},
{
icon: "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z",
title: "Опыт с 1994",
desc: "Более 30 лет работы с финскими дверями — знаем продукцию досконально.",
},
].map((item) => (
<div
key={item.title}
className="rounded-xl border bg-white p-6 shadow-sm"
>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-amber-50">
<svg
className="h-5 w-5 text-amber-600"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d={item.icon}
/>
</svg>
</div>
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-slate-600">
{item.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* Featured products */}
{featured.length > 0 && (
<section className="py-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<h2 className="text-center text-2xl font-bold sm:text-3xl">
Новые поступления
</h2>
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{featured.map((product) => (
<ProductCard
key={product.id}
product={{
id: product.id,
slug: product.slug,
name: product.name,
articleNumber: product.articleNumber,
price: product.price,
discountPrice: product.discountPrice,
availability: product.availability,
images: product.images as unknown[],
}}
/>
))}
</div>
<div className="mt-10 text-center">
<Link
href="/catalog"
className="inline-flex rounded-lg bg-slate-900 px-8 py-3.5 font-semibold text-white transition hover:bg-slate-800"
>
Весь каталог
</Link>
</div>
</div>
</section>
)}
{/* CTA */}
<section className="border-t bg-amber-50 py-16">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold sm:text-3xl">
Нужна консультация?
</h2>
<p className="mt-4 text-slate-600">
Поможем подобрать дверь под ваш проём, рассчитаем стоимость с
доставкой и установкой.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-4">
<a
href="tel:+74957181212"
className="rounded-lg bg-amber-500 px-6 py-3 font-semibold text-slate-900 transition hover:bg-amber-400"
>
+7 495 718 1212
</a>
<a
href="https://wa.me/79851232828"
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-slate-300 bg-white px-6 py-3 font-semibold text-slate-900 transition hover:bg-slate-50"
>
WhatsApp
</a>
</div>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,286 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPayload } from "payload";
import config from "@payload-config";
import type { Metadata } from "next";
import { AddToCartButton } from "@/components/AddToCartButton";
import { ImageGallery } from "@/components/ImageGallery";
import { ProductCard } from "@/components/ProductCard";
import { getImageUrl, getImageAlt } from "@/lib/media";
import { productJsonLd } from "@/lib/structured-data";
interface ProductPageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({
params,
}: ProductPageProps): Promise<Metadata> {
const { slug } = await params;
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "products",
where: { slug: { equals: slug } },
limit: 1,
});
const product = docs[0];
if (!product) return { title: "Не найдено" };
return {
title: product.seo?.metaTitle || product.name,
description:
product.seo?.metaDescription || product.shortDescription || undefined,
};
}
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "products",
where: { slug: { equals: slug } },
limit: 1,
depth: 2,
});
const product = docs[0];
if (!product) notFound();
const hasDiscount =
product.discountPrice && product.discountPrice < product.price;
const displayPrice = hasDiscount ? product.discountPrice! : product.price;
const galleryImages = (product.images || [])
.map((img) => {
const url = getImageUrl(img as never, "hero");
const alt = getImageAlt(img as never);
return url ? { url, alt: alt || product.name } : null;
})
.filter(Boolean) as { url: string; alt: string }[];
const category =
product.category && typeof product.category === "object"
? product.category
: null;
const relatedProducts = (product.relatedProducts || []).filter(
(r) => typeof r === "object" && r !== null,
) as Array<{
id: string | number;
slug: string;
name: string;
articleNumber: string;
price: number;
discountPrice?: number | null;
availability: string;
images?: unknown[];
}>;
const jsonLd = productJsonLd({
name: product.name,
slug: product.slug,
articleNumber: product.articleNumber,
price: product.price,
discountPrice: product.discountPrice,
availability: product.availability,
shortDescription: product.shortDescription,
imageUrl: galleryImages[0]?.url,
});
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* Breadcrumb */}
<nav className="text-sm text-slate-500">
<Link href="/" className="hover:text-amber-600">
Главная
</Link>
<span className="mx-2">/</span>
<Link href="/catalog" className="hover:text-amber-600">
Каталог
</Link>
{category && (
<>
<span className="mx-2">/</span>
<Link
href={`/catalog?category=${category.slug}`}
className="hover:text-amber-600"
>
{category.name}
</Link>
</>
)}
<span className="mx-2">/</span>
<span className="text-slate-900">{product.name}</span>
</nav>
<div className="mt-6 grid gap-10 lg:grid-cols-2">
{/* Image gallery */}
<ImageGallery images={galleryImages} />
{/* Product info */}
<div>
<h1 className="text-2xl font-bold sm:text-3xl">{product.name}</h1>
<p className="mt-1 text-sm text-slate-500">
Арт. {product.articleNumber}
</p>
{/* Price */}
<div className="mt-6 flex items-baseline gap-3">
{hasDiscount ? (
<>
<span className="text-3xl font-bold text-red-600">
{displayPrice.toLocaleString("ru-RU")}
</span>
<span className="text-lg text-slate-400 line-through">
{product.price.toLocaleString("ru-RU")}
</span>
<span className="rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-semibold text-red-700">
-
{Math.round(
((product.price - displayPrice) / product.price) * 100,
)}
%
</span>
</>
) : (
<span className="text-3xl font-bold">
{product.price.toLocaleString("ru-RU")}
</span>
)}
</div>
{/* Availability */}
<div className="mt-4">
<span
className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${
product.availability === "in-stock"
? "bg-green-50 text-green-700"
: product.availability === "made-to-order"
? "bg-amber-50 text-amber-700"
: "bg-slate-100 text-slate-600"
}`}
>
{product.availability === "in-stock"
? "В наличии"
: product.availability === "made-to-order"
? "На заказ (16 недель)"
: "Скоро в продаже"}
</span>
</div>
{/* Add to cart */}
<AddToCartButton
productId={String(product.id)}
name={product.name}
price={displayPrice}
articleNumber={product.articleNumber}
/>
{/* Description */}
{product.shortDescription && (
<div className="mt-8 rounded-lg bg-slate-50 p-4">
<p className="text-sm leading-relaxed text-slate-700">
{product.shortDescription}
</p>
</div>
)}
{/* Options */}
{product.options && product.options.length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold">Платные опции</h2>
<ul className="mt-3 divide-y rounded-lg border">
{product.options.map(
(
opt: {
name: string;
priceModifier: number;
description?: string | null;
},
i: number,
) => (
<li
key={i}
className="flex items-center justify-between px-4 py-3"
>
<div>
<span className="text-sm font-medium">{opt.name}</span>
{opt.description && (
<p className="text-xs text-slate-500">
{opt.description}
</p>
)}
</div>
<span className="text-sm font-semibold text-amber-600">
{opt.priceModifier > 0 ? "+" : ""}
{opt.priceModifier.toLocaleString("ru-RU")}
</span>
</li>
),
)}
</ul>
</div>
)}
{/* Quick contacts */}
<div className="mt-8 rounded-lg border bg-slate-50 p-4">
<p className="text-sm font-semibold text-slate-900">
Вопросы по товару?
</p>
<div className="mt-2 flex flex-wrap gap-3 text-sm">
<a
href="tel:+74957181212"
className="text-amber-600 hover:underline"
>
+7 495 718 1212
</a>
<a
href="https://wa.me/79851232828"
target="_blank"
rel="noopener noreferrer"
className="text-amber-600 hover:underline"
>
WhatsApp
</a>
<a
href="mailto:adv@advdoors.ru"
className="text-amber-600 hover:underline"
>
adv@advdoors.ru
</a>
</div>
</div>
</div>
</div>
{/* Technical specs */}
{product.technicalSpecs && (
<section className="mt-16 border-t pt-10">
<h2 className="text-2xl font-bold">Техническое описание</h2>
<div className="prose mt-4 max-w-none text-slate-700">
<p className="text-slate-500">
Подробное техническое описание доступно в карточке товара.
</p>
</div>
</section>
)}
{/* Related products */}
{relatedProducts.length > 0 && (
<section className="mt-16 border-t pt-10">
<h2 className="text-2xl font-bold">Похожие товары</h2>
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{relatedProducts.slice(0, 4).map((related) => (
<ProductCard key={related.id} product={related} />
))}
</div>
</section>
)}
</div>
);
}

View File

@ -0,0 +1,61 @@
import type { MetadataRoute } from "next";
import { getPayload } from "payload";
import config from "@payload-config";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const payload = await getPayload({ config });
const entries: MetadataRoute.Sitemap = [
{ url: SITE_URL, lastModified: new Date(), changeFrequency: "weekly", priority: 1.0 },
{ url: `${SITE_URL}/catalog`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 },
];
const { docs: products } = await payload.find({
collection: "products",
limit: 10000,
depth: 0,
});
for (const product of products) {
entries.push({
url: `${SITE_URL}/product/${product.slug}`,
lastModified: new Date(product.updatedAt),
changeFrequency: "weekly",
priority: 0.8,
});
}
const { docs: categories } = await payload.find({
collection: "categories",
limit: 1000,
depth: 0,
});
for (const cat of categories) {
entries.push({
url: `${SITE_URL}/catalog?category=${cat.slug}`,
lastModified: new Date(cat.updatedAt),
changeFrequency: "weekly",
priority: 0.7,
});
}
const { docs: pages } = await payload.find({
collection: "pages",
limit: 100,
depth: 0,
});
for (const page of pages) {
entries.push({
url: `${SITE_URL}/${page.slug}`,
lastModified: new Date(page.updatedAt),
changeFrequency: "monthly",
priority: 0.5,
});
}
return entries;
}

View File

@ -0,0 +1,23 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from "next";
import config from "@payload-config";
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap";
type Args = {
params: Promise<{ segments: string[] }>;
searchParams: Promise<Record<string, string | string[]>>;
};
export const generateMetadata = ({
params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams });
export default Page;

View File

@ -0,0 +1,53 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View File

@ -0,0 +1,18 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from "@payloadcms/next/routes";
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config);
export const PUT = REST_PUT(config);
export const OPTIONS = REST_OPTIONS(config);

View File

@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import { GRAPHQL_POST } from "@payloadcms/next/routes";
export const POST = GRAPHQL_POST(config);

View File

@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { ServerFunctionClient } from "payload";
import config from "@payload-config";
import { handleServerFunctions, RootLayout } from "@payloadcms/next/layouts";
import React from "react";
import { importMap } from "./admin/importMap";
import "@payloadcms/next/css";
type Args = {
children: React.ReactNode;
};
const serverFunctions: ServerFunctionClient = async function (args) {
"use server";
return handleServerFunctions({
...args,
config,
importMap,
});
};
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunctions}>
{children}
</RootLayout>
);
export default Layout;

View File

@ -0,0 +1,42 @@
import type { CollectionConfig } from "payload";
export const Categories: CollectionConfig = {
slug: "categories",
admin: {
useAsTitle: "name",
},
access: {
read: () => true,
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
},
{
name: "parent",
type: "relationship",
relationTo: "categories",
hasMany: false,
admin: {
description: "Родительская категория (для вложенности)",
},
},
{
name: "description",
type: "richText",
},
{
name: "image",
type: "upload",
relationTo: "media",
},
],
};

View File

@ -0,0 +1,39 @@
import type { CollectionConfig } from "payload";
export const Media: CollectionConfig = {
slug: "media",
upload: {
mimeTypes: ["image/*"],
imageSizes: [
{
name: "thumbnail",
width: 300,
height: 300,
position: "centre",
},
{
name: "card",
width: 600,
height: 600,
position: "centre",
},
{
name: "hero",
width: 1200,
height: undefined,
position: "centre",
},
],
adminThumbnail: "thumbnail",
},
access: {
read: () => true,
},
fields: [
{
name: "alt",
type: "text",
required: true,
},
],
};

View File

@ -0,0 +1,107 @@
import type { CollectionConfig } from "payload";
import { ORDER_STATUSES } from "@advdoors/shared";
export const Orders: CollectionConfig = {
slug: "orders",
admin: {
useAsTitle: "orderNumber",
defaultColumns: ["orderNumber", "status", "customer.name", "createdAt"],
},
access: {
read: ({ req: { user } }) => Boolean(user),
create: () => true,
},
fields: [
{
name: "orderNumber",
type: "number",
required: true,
unique: true,
admin: {
readOnly: true,
position: "sidebar",
},
},
{
name: "items",
type: "array",
required: true,
minRows: 1,
labels: {
singular: "Товар",
plural: "Товары",
},
fields: [
{
name: "product",
type: "relationship",
relationTo: "products",
required: true,
},
{
name: "quantity",
type: "number",
required: true,
min: 1,
defaultValue: 1,
},
{
name: "priceAtOrder",
type: "number",
required: true,
min: 0,
},
],
},
{
name: "customer",
type: "group",
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "phone",
type: "text",
required: true,
},
{
name: "email",
type: "email",
},
{
name: "comment",
type: "textarea",
},
],
},
{
name: "status",
type: "select",
required: true,
defaultValue: "new",
options: ORDER_STATUSES.map((s) => ({
label: s.label,
value: s.value,
})),
admin: {
position: "sidebar",
},
},
],
hooks: {
beforeChange: [
async ({ data, operation, req }) => {
if (operation === "create") {
const { totalDocs } = await req.payload.count({
collection: "orders",
});
data.orderNumber = totalDocs + 1;
}
return data;
},
],
},
};

View File

@ -0,0 +1,47 @@
import type { CollectionConfig } from "payload";
export const Pages: CollectionConfig = {
slug: "pages",
admin: {
useAsTitle: "title",
},
access: {
read: () => true,
},
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
},
{
name: "content",
type: "richText",
},
{
name: "seo",
type: "group",
fields: [
{
name: "metaTitle",
type: "text",
},
{
name: "metaDescription",
type: "textarea",
},
{
name: "ogImage",
type: "upload",
relationTo: "media",
},
],
},
],
};

View File

@ -0,0 +1,139 @@
import type { CollectionConfig } from "payload";
import { BRANDS, AVAILABILITY_OPTIONS } from "@advdoors/shared";
export const Products: CollectionConfig = {
slug: "products",
admin: {
useAsTitle: "name",
defaultColumns: ["name", "articleNumber", "brand", "price", "availability"],
},
access: {
read: () => true,
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
},
},
{
name: "articleNumber",
type: "text",
required: true,
admin: {
position: "sidebar",
},
},
{
name: "brand",
type: "select",
required: true,
options: BRANDS.map((b) => ({ label: b.label, value: b.value })),
admin: {
position: "sidebar",
},
},
{
name: "category",
type: "relationship",
relationTo: "categories",
hasMany: false,
},
{
name: "images",
type: "upload",
relationTo: "media",
hasMany: true,
},
{
name: "price",
type: "number",
required: true,
min: 0,
},
{
name: "discountPrice",
type: "number",
min: 0,
admin: {
description: "Оставьте пустым если нет скидки",
},
},
{
name: "availability",
type: "select",
required: true,
defaultValue: "in-stock",
options: AVAILABILITY_OPTIONS.map((a) => ({
label: a.label,
value: a.value,
})),
},
{
name: "shortDescription",
type: "textarea",
},
{
name: "technicalSpecs",
type: "richText",
},
{
name: "options",
type: "array",
labels: {
singular: "Опция",
plural: "Опции",
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "priceModifier",
type: "number",
required: true,
},
{
name: "description",
type: "text",
},
],
},
{
name: "relatedProducts",
type: "relationship",
relationTo: "products",
hasMany: true,
},
{
name: "seo",
type: "group",
fields: [
{
name: "metaTitle",
type: "text",
},
{
name: "metaDescription",
type: "textarea",
},
{
name: "ogImage",
type: "upload",
relationTo: "media",
},
],
},
],
};

View File

@ -0,0 +1,15 @@
import type { CollectionConfig } from "payload";
export const Users: CollectionConfig = {
slug: "users",
auth: true,
admin: {
useAsTitle: "email",
},
fields: [
{
name: "name",
type: "text",
},
],
};

View File

@ -0,0 +1,28 @@
"use client";
import { useCart } from "@/lib/cart";
interface AddToCartButtonProps {
productId: string;
name: string;
price: number;
articleNumber: string;
}
export function AddToCartButton({
productId,
name,
price,
articleNumber,
}: AddToCartButtonProps) {
const { addItem } = useCart();
return (
<button
onClick={() => addItem({ productId, name, price, articleNumber, quantity: 1 })}
className="mt-6 w-full rounded-lg bg-amber-500 px-6 py-3 text-lg font-semibold text-slate-900 transition hover:bg-amber-400 sm:w-auto"
>
В корзину
</button>
);
}

View File

@ -0,0 +1,273 @@
"use client";
import { useCart } from "@/lib/cart";
import { useState } from "react";
import Link from "next/link";
export function CartView() {
const { items, removeItem, updateQuantity, clearCart, total } = useCart();
const [orderNumber, setOrderNumber] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState({
name: "",
phone: "",
email: "",
comment: "",
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (items.length === 0) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch("/api/submit-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: items.map((item) => ({
product: item.productId,
quantity: item.quantity,
priceAtOrder: item.price,
})),
customer: form,
}),
});
const data = await res.json();
if (res.ok && data.success) {
clearCart();
setOrderNumber(data.orderNumber);
} else {
setError(data.error || "Произошла ошибка");
}
} catch {
setError("Не удалось отправить заказ. Попробуйте позже.");
} finally {
setSubmitting(false);
}
};
if (orderNumber) {
return (
<div className="mt-10 rounded-xl border border-green-200 bg-green-50 p-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<svg
className="h-8 w-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-green-800">
Заказ #{orderNumber} оформлен!
</h2>
<p className="mt-2 text-green-700">
Мы свяжемся с вами в ближайшее время для подтверждения заказа.
</p>
<Link
href="/catalog"
className="mt-6 inline-block rounded-lg bg-green-600 px-6 py-3 font-semibold text-white transition hover:bg-green-500"
>
Продолжить покупки
</Link>
</div>
);
}
if (items.length === 0) {
return (
<div className="mt-10 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100">
<svg
className="h-8 w-8 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
</div>
<p className="text-lg text-slate-500">Корзина пуста</p>
<Link
href="/catalog"
className="mt-4 inline-block text-amber-600 hover:underline"
>
Перейти в каталог
</Link>
</div>
);
}
return (
<div className="mt-8 space-y-6">
{/* Cart items */}
<div className="divide-y rounded-xl border">
{items.map((item) => (
<div
key={item.productId}
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-4"
>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-slate-500">
Арт. {item.articleNumber} · {item.price.toLocaleString("ru-RU")}{" "}
/шт
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center rounded-lg border">
<button
onClick={() =>
updateQuantity(
item.productId,
Math.max(1, item.quantity - 1),
)
}
className="px-3 py-1.5 text-slate-500 hover:text-slate-900"
>
</button>
<span className="w-8 text-center text-sm font-medium">
{item.quantity}
</span>
<button
onClick={() =>
updateQuantity(item.productId, item.quantity + 1)
}
className="px-3 py-1.5 text-slate-500 hover:text-slate-900"
>
+
</button>
</div>
<span className="w-28 text-right font-semibold">
{(item.price * item.quantity).toLocaleString("ru-RU")}
</span>
<button
onClick={() => removeItem(item.productId)}
className="rounded p-1.5 text-slate-400 transition hover:bg-red-50 hover:text-red-500"
aria-label="Удалить"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
</div>
))}
</div>
{/* Total */}
<div className="flex items-center justify-between rounded-lg bg-slate-50 px-4 py-3">
<span className="text-lg font-medium text-slate-700">Итого:</span>
<span className="text-2xl font-bold">
{total.toLocaleString("ru-RU")}
</span>
</div>
{/* Order form */}
<form
onSubmit={handleSubmit}
className="space-y-4 rounded-xl border bg-white p-6 shadow-sm"
>
<h2 className="text-xl font-bold">Оформление заказа</h2>
<p className="text-sm text-slate-500">
Заполните форму и мы свяжемся с вами для подтверждения.
</p>
{error && (
<div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-slate-700">
Имя <span className="text-red-500">*</span>
</label>
<input
type="text"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Ваше имя"
className="mt-1 w-full rounded-lg border px-3 py-2.5 text-sm transition focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700">
Телефон <span className="text-red-500">*</span>
</label>
<input
type="tel"
required
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder="+7 (___) ___-__-__"
className="mt-1 w-full rounded-lg border px-3 py-2.5 text-sm transition focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-slate-700">
Email
</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="email@example.com"
className="mt-1 w-full rounded-lg border px-3 py-2.5 text-sm transition focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700">
Комментарий к заказу
</label>
<textarea
value={form.comment}
onChange={(e) => setForm({ ...form, comment: e.target.value })}
rows={3}
placeholder="Размеры проёма, цвет, направление открывания..."
className="mt-1 w-full rounded-lg border px-3 py-2.5 text-sm transition focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full rounded-lg bg-amber-500 px-6 py-3.5 text-lg font-semibold text-slate-900 transition hover:bg-amber-400 disabled:opacity-50 sm:w-auto"
>
{submitting ? "Отправка..." : "Оформить заказ"}
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,120 @@
import Link from "next/link";
import { getPayload } from "payload";
import config from "@payload-config";
export async function Footer() {
const payload = await getPayload({ config });
const settings = await payload.findGlobal({ slug: "site-settings" });
return (
<footer className="border-t bg-slate-900 text-slate-300">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
<div>
<span className="text-lg font-bold text-white">
<span className="text-amber-400">ADV</span>doors
</span>
<p className="mt-2 text-sm">
Финские входные и межкомнатные двери с 1994 года.
</p>
</div>
<div>
<h3 className="font-semibold text-white">Каталог</h3>
<ul className="mt-3 space-y-2 text-sm">
<li>
<Link href="/catalog?brand=KASKI" className="hover:text-white">
Двери KASKI
</Link>
</li>
<li>
<Link
href="/catalog?brand=SWEDOOR"
className="hover:text-white"
>
Двери SWEDOOR
</Link>
</li>
<li>
<Link
href="/catalog?brand=ALAVUS"
className="hover:text-white"
>
Двери ALAVUS
</Link>
</li>
</ul>
</div>
<div>
<h3 className="font-semibold text-white">Информация</h3>
<ul className="mt-3 space-y-2 text-sm">
<li>
<Link href="/about" className="hover:text-white">
О компании
</Link>
</li>
<li>
<Link href="/delivery" className="hover:text-white">
Доставка
</Link>
</li>
<li>
<Link href="/installation" className="hover:text-white">
Установка
</Link>
</li>
<li>
<Link href="/warranty" className="hover:text-white">
Гарантия
</Link>
</li>
</ul>
</div>
<div>
<h3 className="font-semibold text-white">Контакты</h3>
<ul className="mt-3 space-y-2 text-sm">
{settings.phone && (
<li>
<a
href={`tel:${settings.phone.replace(/\s/g, "")}`}
className="hover:text-white"
>
{settings.phone}
</a>
</li>
)}
{settings.email && (
<li>
<a
href={`mailto:${settings.email}`}
className="hover:text-white"
>
{settings.email}
</a>
</li>
)}
{settings.whatsapp && (
<li>
<a
href={`https://wa.me/${settings.whatsapp.replace(/[^0-9]/g, "")}`}
className="hover:text-white"
target="_blank"
rel="noopener noreferrer"
>
WhatsApp
</a>
</li>
)}
</ul>
</div>
</div>
<div className="mt-10 border-t border-slate-700 pt-6 text-center text-sm text-slate-500">
{settings.footerText || "© ADVdoors"}
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,80 @@
import Link from "next/link";
import { getPayload } from "payload";
import config from "@payload-config";
import { MobileMenu } from "./MobileMenu";
export async function Header() {
const payload = await getPayload({ config });
const settings = await payload.findGlobal({ slug: "site-settings" });
return (
<header className="sticky top-0 z-50 border-b bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<Link href="/" className="text-xl font-bold tracking-tight">
<span className="text-amber-600">ADV</span>
<span className="text-slate-900">doors</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
<Link
href="/catalog"
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 hover:text-amber-600"
>
Каталог
</Link>
<Link
href="/about"
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 hover:text-amber-600"
>
О компании
</Link>
<Link
href="/delivery"
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 hover:text-amber-600"
>
Доставка
</Link>
<Link
href="/contacts"
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 hover:text-amber-600"
>
Контакты
</Link>
</nav>
<div className="flex items-center gap-3">
{settings.phone && (
<a
href={`tel:${settings.phone.replace(/\s/g, "")}`}
className="hidden text-sm font-semibold text-slate-900 hover:text-amber-600 lg:block"
>
{settings.phone}
</a>
)}
<Link
href="/cart"
className="hidden rounded-lg bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-200 md:block"
>
<span className="flex items-center gap-1.5">
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
Корзина
</span>
</Link>
<MobileMenu />
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,76 @@
"use client";
import Image from "next/image";
import { useState } from "react";
interface GalleryImage {
url: string;
alt: string;
}
export function ImageGallery({ images }: { images: GalleryImage[] }) {
const [selected, setSelected] = useState(0);
if (images.length === 0) {
return (
<div className="aspect-square overflow-hidden rounded-xl bg-slate-100">
<div className="flex h-full items-center justify-center text-slate-300">
<svg
className="h-16 w-16"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"
/>
</svg>
</div>
</div>
);
}
return (
<div className="space-y-3">
{/* Main image */}
<div className="relative aspect-square overflow-hidden rounded-xl bg-slate-100">
<Image
src={images[selected].url}
alt={images[selected].alt}
fill
className="object-contain"
sizes="(max-width: 1024px) 100vw, 50vw"
priority
/>
</div>
{/* Thumbnails */}
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((img, i) => (
<button
key={i}
onClick={() => setSelected(i)}
className={`relative h-16 w-16 shrink-0 overflow-hidden rounded-lg border-2 transition ${
i === selected
? "border-amber-500"
: "border-transparent opacity-70 hover:opacity-100"
}`}
>
<Image
src={img.url}
alt={img.alt}
fill
className="object-cover"
sizes="64px"
/>
</button>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,70 @@
"use client";
import Link from "next/link";
import { useState } from "react";
export function MobileMenu() {
const [open, setOpen] = useState(false);
return (
<div className="md:hidden">
<button
onClick={() => setOpen(!open)}
className="rounded-lg p-2 text-slate-700 hover:bg-slate-100"
aria-label="Меню"
>
{open ? (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
)}
</button>
{open && (
<div className="absolute left-0 right-0 top-full z-50 border-b bg-white shadow-lg">
<nav className="flex flex-col px-4 py-4">
<Link
href="/catalog"
onClick={() => setOpen(false)}
className="rounded-lg px-3 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Каталог
</Link>
<Link
href="/about"
onClick={() => setOpen(false)}
className="rounded-lg px-3 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
О компании
</Link>
<Link
href="/delivery"
onClick={() => setOpen(false)}
className="rounded-lg px-3 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Доставка
</Link>
<Link
href="/contacts"
onClick={() => setOpen(false)}
className="rounded-lg px-3 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Контакты
</Link>
<Link
href="/cart"
onClick={() => setOpen(false)}
className="mt-2 rounded-lg bg-amber-50 px-3 py-2.5 text-sm font-semibold text-amber-700"
>
Корзина
</Link>
</nav>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,110 @@
import Link from "next/link";
import Image from "next/image";
import { getImageUrl, getImageAlt } from "@/lib/media";
interface ProductCardProps {
product: {
id: string | number;
slug: string;
name: string;
articleNumber: string;
price: number;
discountPrice?: number | null;
availability: string;
images?: unknown[];
};
}
export function ProductCard({ product }: ProductCardProps) {
const firstImage = product.images?.[0];
const imageUrl = getImageUrl(firstImage as never, "card");
const imageAlt = getImageAlt(firstImage as never);
const hasDiscount =
product.discountPrice && product.discountPrice < product.price;
const discountPct = hasDiscount
? Math.round(
((product.price - product.discountPrice!) / product.price) * 100,
)
: 0;
return (
<Link
href={`/product/${product.slug}`}
className="group relative flex flex-col rounded-xl border bg-white shadow-sm transition hover:shadow-md"
>
{hasDiscount && (
<span className="absolute right-3 top-3 z-10 rounded-full bg-red-500 px-2 py-0.5 text-xs font-bold text-white">
-{discountPct}%
</span>
)}
<div className="relative aspect-square overflow-hidden rounded-t-xl bg-slate-100">
{imageUrl ? (
<Image
src={imageUrl}
alt={imageAlt || product.name}
fill
className="object-cover transition group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
/>
) : (
<div className="flex h-full items-center justify-center text-slate-300">
<svg
className="h-12 w-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"
/>
</svg>
</div>
)}
</div>
<div className="flex flex-1 flex-col p-4">
<h3 className="line-clamp-2 text-sm font-semibold text-slate-900 group-hover:text-amber-600">
{product.name}
</h3>
<p className="mt-1 text-xs text-slate-400">Арт. {product.articleNumber}</p>
<div className="mt-auto flex items-baseline gap-2 pt-3">
{hasDiscount ? (
<>
<span className="text-lg font-bold text-red-600">
{product.discountPrice!.toLocaleString("ru-RU")}
</span>
<span className="text-sm text-slate-400 line-through">
{product.price.toLocaleString("ru-RU")}
</span>
</>
) : (
<span className="text-lg font-bold text-slate-900">
{product.price.toLocaleString("ru-RU")}
</span>
)}
</div>
<span
className={`mt-2 inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${
product.availability === "in-stock"
? "bg-green-50 text-green-700"
: product.availability === "made-to-order"
? "bg-amber-50 text-amber-700"
: "bg-slate-50 text-slate-500"
}`}
>
{product.availability === "in-stock"
? "В наличии"
: product.availability === "made-to-order"
? "На заказ"
: "Скоро"}
</span>
</div>
</Link>
);
}

View File

@ -0,0 +1,22 @@
"use client";
interface RichTextProps {
data: unknown;
}
export function RichText({ data }: RichTextProps) {
if (!data) return null;
// Payload Lexical rich text is stored as a serialized editor state.
// For now, render a simple fallback. This will be enhanced with
// @payloadcms/richtext-lexical's serializer once types are generated.
if (typeof data === "string") {
return <div dangerouslySetInnerHTML={{ __html: data }} />;
}
return (
<div className="text-slate-600">
<p>Содержимое страницы.</p>
</div>
);
}

View File

@ -0,0 +1,63 @@
import type { GlobalConfig } from "payload";
export const SiteSettings: GlobalConfig = {
slug: "site-settings",
label: "Настройки сайта",
access: {
read: () => true,
},
fields: [
{
name: "phone",
type: "text",
defaultValue: "+7 495 718 1212",
},
{
name: "whatsapp",
type: "text",
defaultValue: "+7 985 1232828",
},
{
name: "telegram",
type: "text",
},
{
name: "email",
type: "email",
defaultValue: "adv@advdoors.ru",
},
{
name: "address",
type: "textarea",
},
{
name: "workingHours",
type: "text",
},
{
name: "footerText",
type: "textarea",
defaultValue: "© 1994-2026 АДВ Двери: Финские двери.",
},
{
name: "socialLinks",
type: "array",
labels: {
singular: "Ссылка",
plural: "Социальные сети",
},
fields: [
{
name: "platform",
type: "text",
required: true,
},
{
name: "url",
type: "text",
required: true,
},
],
},
],
};

100
apps/web/src/lib/cart.ts Normal file
View File

@ -0,0 +1,100 @@
"use client";
import { useSyncExternalStore, useCallback } from "react";
export interface CartItem {
productId: string;
name: string;
price: number;
articleNumber: string;
quantity: number;
}
const STORAGE_KEY = "advdoors-cart";
let listeners: Array<() => void> = [];
let cachedRaw: string | null = null;
let cachedItems: CartItem[] = [];
const SERVER_SNAPSHOT: CartItem[] = [];
function emitChange() {
for (const listener of listeners) {
listener();
}
}
function getSnapshot(): CartItem[] {
if (typeof window === "undefined") return SERVER_SNAPSHOT;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw !== cachedRaw) {
cachedRaw = raw;
cachedItems = raw ? JSON.parse(raw) : [];
}
return cachedItems;
} catch {
return cachedItems;
}
}
function getServerSnapshot(): CartItem[] {
return SERVER_SNAPSHOT;
}
function setItems(items: CartItem[]) {
const json = JSON.stringify(items);
localStorage.setItem(STORAGE_KEY, json);
cachedRaw = json;
cachedItems = items;
emitChange();
}
function subscribe(listener: () => void) {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}
export function useCart() {
const items = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const addItem = useCallback((item: CartItem) => {
const current = getSnapshot();
const existing = current.find((i) => i.productId === item.productId);
if (existing) {
setItems(
current.map((i) =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i,
),
);
} else {
setItems([...current, item]);
}
}, []);
const removeItem = useCallback((productId: string) => {
setItems(getSnapshot().filter((i) => i.productId !== productId));
}, []);
const updateQuantity = useCallback(
(productId: string, quantity: number) => {
setItems(
getSnapshot().map((i) =>
i.productId === productId ? { ...i, quantity } : i,
),
);
},
[],
);
const clearCart = useCallback(() => {
setItems([]);
}, []);
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
return { items, addItem, removeItem, updateQuantity, clearCart, total };
}

30
apps/web/src/lib/media.ts Normal file
View File

@ -0,0 +1,30 @@
type PayloadMedia = {
url?: string | null;
sizes?: Record<
string,
{ url?: string | null; width?: number | null; height?: number | null } | undefined
>;
alt?: string;
};
export function getImageUrl(
media: PayloadMedia | string | number | null | undefined,
size?: "thumbnail" | "card" | "hero",
): string | null {
if (!media || typeof media === "string" || typeof media === "number")
return null;
if (size && media.sizes?.[size]?.url) {
return media.sizes[size]!.url!;
}
return media.url ?? null;
}
export function getImageAlt(
media: PayloadMedia | string | number | null | undefined,
): string {
if (!media || typeof media === "string" || typeof media === "number")
return "";
return media.alt ?? "";
}

View File

@ -0,0 +1,6 @@
import { getPayload as getPayloadInstance } from "payload";
import config from "@payload-config";
export async function getPayloadClient() {
return getPayloadInstance({ config });
}

View File

@ -0,0 +1,50 @@
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
export function productJsonLd(product: {
name: string;
slug: string;
articleNumber: string;
price: number;
discountPrice?: number | null;
availability: string;
shortDescription?: string | null;
imageUrl?: string | null;
}) {
const displayPrice = product.discountPrice || product.price;
return {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
sku: product.articleNumber,
url: `${SITE_URL}/product/${product.slug}`,
description: product.shortDescription || undefined,
image: product.imageUrl || undefined,
offers: {
"@type": "Offer",
priceCurrency: "RUB",
price: displayPrice,
availability:
product.availability === "in-stock"
? "https://schema.org/InStock"
: product.availability === "made-to-order"
? "https://schema.org/PreOrder"
: "https://schema.org/OutOfStock",
url: `${SITE_URL}/product/${product.slug}`,
},
};
}
export function organizationJsonLd() {
return {
"@context": "https://schema.org",
"@type": "Organization",
name: "ADVdoors",
url: SITE_URL,
telephone: "+74957181212",
email: "adv@advdoors.ru",
foundingDate: "1994",
description:
"Финские входные и межкомнатные двери KASKI, SWEDOOR by JELD-WEN, ALAVUS в Москве и России.",
};
}

View File

@ -0,0 +1,703 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
products: Product;
categories: Category;
orders: Order;
pages: Page;
media: Media;
users: User;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
products: ProductsSelect<false> | ProductsSelect<true>;
categories: CategoriesSelect<false> | CategoriesSelect<true>;
orders: OrdersSelect<false> | OrdersSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {
'site-settings': SiteSetting;
};
globalsSelect: {
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
};
locale: null;
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products".
*/
export interface Product {
id: number;
name: string;
slug: string;
articleNumber: string;
brand: 'KASKI' | 'ALAVUS' | 'SWEDOOR' | 'JELD-WEN' | 'MATTIOVI' | 'ABLOY';
category?: (number | null) | Category;
images?: (number | Media)[] | null;
price: number;
/**
* Оставьте пустым если нет скидки
*/
discountPrice?: number | null;
availability: 'in-stock' | 'made-to-order' | 'coming-soon';
shortDescription?: string | null;
technicalSpecs?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
options?:
| {
name: string;
priceModifier: number;
description?: string | null;
id?: string | null;
}[]
| null;
relatedProducts?: (number | Product)[] | null;
seo?: {
metaTitle?: string | null;
metaDescription?: string | null;
ogImage?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories".
*/
export interface Category {
id: number;
name: string;
slug: string;
/**
* Родительская категория (для вложенности)
*/
parent?: (number | null) | Category;
description?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
image?: (number | null) | Media;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
thumbnail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
card?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
hero?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "orders".
*/
export interface Order {
id: number;
orderNumber: number;
items: {
product: number | Product;
quantity: number;
priceAtOrder: number;
id?: string | null;
}[];
customer: {
name: string;
phone: string;
email?: string | null;
comment?: string | null;
};
status: 'new' | 'in-progress' | 'completed' | 'cancelled';
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages".
*/
export interface Page {
id: number;
title: string;
slug: string;
content?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
seo?: {
metaTitle?: string | null;
metaDescription?: string | null;
ogImage?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
name?: string | null;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'products';
value: number | Product;
} | null)
| ({
relationTo: 'categories';
value: number | Category;
} | null)
| ({
relationTo: 'orders';
value: number | Order;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'users';
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products_select".
*/
export interface ProductsSelect<T extends boolean = true> {
name?: T;
slug?: T;
articleNumber?: T;
brand?: T;
category?: T;
images?: T;
price?: T;
discountPrice?: T;
availability?: T;
shortDescription?: T;
technicalSpecs?: T;
options?:
| T
| {
name?: T;
priceModifier?: T;
description?: T;
id?: T;
};
relatedProducts?: T;
seo?:
| T
| {
metaTitle?: T;
metaDescription?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories_select".
*/
export interface CategoriesSelect<T extends boolean = true> {
name?: T;
slug?: T;
parent?: T;
description?: T;
image?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "orders_select".
*/
export interface OrdersSelect<T extends boolean = true> {
orderNumber?: T;
items?:
| T
| {
product?: T;
quantity?: T;
priceAtOrder?: T;
id?: T;
};
customer?:
| T
| {
name?: T;
phone?: T;
email?: T;
comment?: T;
};
status?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages_select".
*/
export interface PagesSelect<T extends boolean = true> {
title?: T;
slug?: T;
content?: T;
seo?:
| T
| {
metaTitle?: T;
metaDescription?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
sizes?:
| T
| {
thumbnail?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
card?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
hero?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings".
*/
export interface SiteSetting {
id: number;
phone?: string | null;
whatsapp?: string | null;
telegram?: string | null;
email?: string | null;
address?: string | null;
workingHours?: string | null;
footerText?: string | null;
socialLinks?:
| {
platform: string;
url: string;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings_select".
*/
export interface SiteSettingsSelect<T extends boolean = true> {
phone?: T;
whatsapp?: T;
telegram?: T;
email?: T;
address?: T;
workingHours?: T;
footerText?: T;
socialLinks?:
| T
| {
platform?: T;
url?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

View File

@ -0,0 +1,67 @@
import path from "path";
import { fileURLToPath } from "url";
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import { s3Storage } from "@payloadcms/storage-s3";
import sharp from "sharp";
import { Products } from "./collections/Products";
import { Categories } from "./collections/Categories";
import { Orders } from "./collections/Orders";
import { Pages } from "./collections/Pages";
import { Media } from "./collections/Media";
import { Users } from "./collections/Users";
import { SiteSettings } from "./globals/SiteSettings";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
admin: {
user: Users.slug,
meta: {
titleSuffix: " — ADVdoors",
},
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Products, Categories, Orders, Pages, Media, Users],
globals: [SiteSettings],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || "UNSAFE-DEFAULT-SECRET",
typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || "",
},
}),
sharp,
plugins: [
s3Storage({
collections: {
media: true,
},
bucket: process.env.S3_BUCKET || "advdoors-media",
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || "minioadmin",
secretAccessKey: process.env.S3_SECRET_KEY || "minioadmin",
},
region: process.env.S3_REGION || "us-east-1",
endpoint: process.env.S3_ENDPOINT || "http://localhost:9000",
forcePathStyle: true,
},
}),
],
});

24
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"extends": "@advdoors/tsconfig/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
]
},
"allowJs": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

6
docker/.env.example Normal file
View File

@ -0,0 +1,6 @@
POSTGRES_PASSWORD=change-me
PAYLOAD_SECRET=change-me-to-a-random-secret-at-least-32-characters
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=change-me
SITE_URL=https://advdoors.ru
SITE_DOMAIN=advdoors.ru

13
docker/Caddyfile Normal file
View File

@ -0,0 +1,13 @@
{$SITE_DOMAIN:advdoors.ru} {
reverse_proxy app:3000
handle_path /media/* {
reverse_proxy minio:9000 {
header_up Host {upstream_hostport}
}
rewrite * /advdoors-media{uri}
header Cache-Control "public, max-age=2592000, immutable"
}
encode gzip zstd
}

40
docker/Dockerfile Normal file
View File

@ -0,0 +1,40 @@
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@10 --activate
WORKDIR /app
# Install dependencies
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
COPY packages/tsconfig/package.json packages/tsconfig/package.json
COPY packages/eslint-config/package.json packages/eslint-config/package.json
RUN pnpm install --frozen-lockfile
# Build
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages ./packages
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @advdoors/web build
# Production
FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "apps/web/server.js"]

16
docker/backup.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/sh
set -e
BACKUP_DIR="/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
KEEP_DAYS=30
mkdir -p "$BACKUP_DIR"
echo "[$(date)] Starting PostgreSQL backup..."
pg_dump -h postgres -U advdoors advdoors | gzip > "$BACKUP_DIR/advdoors_${TIMESTAMP}.sql.gz"
echo "[$(date)] Backup created: advdoors_${TIMESTAMP}.sql.gz"
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +${KEEP_DAYS} -delete
echo "[$(date)] Cleaned backups older than ${KEEP_DAYS} days"

View File

@ -0,0 +1,52 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: advdoors
POSTGRES_USER: advdoors
POSTGRES_PASSWORD: advdoors
ports:
- "5435:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U advdoors"]
interval: 5s
timeout: 3s
retries: 5
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 3s
retries: 5
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin;
mc mb --ignore-existing local/advdoors-media;
mc anonymous set download local/advdoors-media;
exit 0;
"
volumes:
pgdata:
minio-data:

108
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,108 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: advdoors
POSTGRES_USER: advdoors
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-advdoors}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U advdoors"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio-data:/data
ports:
- "127.0.0.1:9001:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin};
mc mb --ignore-existing local/advdoors-media;
mc anonymous set download local/advdoors-media;
exit 0;
"
app:
build:
context: ..
dockerfile: docker/Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
environment:
DATABASE_URI: postgresql://advdoors:${POSTGRES_PASSWORD:-advdoors}@postgres:5432/advdoors
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
S3_ENDPOINT: http://minio:9000
S3_BUCKET: advdoors-media
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_REGION: us-east-1
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-https://advdoors.ru}
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
- app
- minio
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
SITE_DOMAIN: ${SITE_DOMAIN:-advdoors.ru}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
backup:
image: postgres:16-alpine
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:-advdoors}
volumes:
- ./backup.sh:/backup.sh:ro
- backups:/backups
entrypoint: >
/bin/sh -c "
while true; do
/backup.sh
sleep 86400
done
"
volumes:
pgdata:
minio-data:
caddy-data:
caddy-config:
backups:

320
docs/PLAN.md Normal file
View File

@ -0,0 +1,320 @@
# ADVdoors.ru Modern Site Rebuild
> Rebuild advdoors.ru as a modern, self-hosted product catalog with cart/order functionality and an intuitive admin panel, using a Turborepo + pnpm monorepo with Next.js + Payload CMS + PostgreSQL + MinIO, deployed with Docker Compose. Includes a scraper app for migrating data from the old site.
## Current State
The existing advdoors.ru is a dated product catalog for Finnish doors (KASKI, SWEDOOR/JELD-WEN, ALAVUS, Abloy). It has ~200-500 products across categories (exterior doors, interior doors, accessories), with product pages containing images, prices, discounts, technical specs, and availability. The repo is a fresh start.
## Stack
### Core: Turborepo + pnpm monorepo
The project uses a **Turborepo** monorepo managed by **pnpm** workspaces. This keeps the main web app, the migration scraper, and shared packages in one repo with fast, cached builds.
- **Turborepo** -- orchestrates builds, dev, lint, typecheck across apps/packages with caching
- **pnpm** -- fast, disk-efficient package manager with strict dependency isolation
### Apps and Packages
- **`apps/web`** -- Next.js 15 + Payload CMS 3 (storefront + admin in one app)
- **`apps/scraper`** -- Node.js CLI tool to crawl advdoors.ru and import data into the new site
- **`packages/shared`** -- Shared TypeScript types, constants (brands, availability statuses, etc.)
- **`packages/tsconfig`** -- Shared TypeScript configurations
- **`packages/eslint-config`** -- Shared ESLint configurations
### Web App: Next.js 15 + Payload CMS 3
Payload CMS v3 embeds directly inside a Next.js application -- one codebase, one deployment:
- **Payload CMS 3** provides the admin panel (`/admin`), database ORM, authentication, media management, and API layer -- all as a Next.js plugin, not a separate service
- **Next.js 15 (App Router)** provides SSR/SSG for SEO, React Server Components for performance, and the storefront UI
- **PostgreSQL 16** as the database (Payload's Drizzle adapter)
- **MinIO** as S3-compatible object storage for product images and media from day one
- **Tailwind CSS v4** for styling the storefront
- **TypeScript** throughout
### Scraper App
A standalone Node.js CLI tool in `apps/scraper` for one-time data migration:
- Crawls advdoors.ru catalog pages, following pagination
- Extracts product data: name, article number, price, discount, images, specs, category, availability
- Downloads product images and uploads them to MinIO
- Creates products/categories in Payload CMS via its REST or Local API
- Imports shared types from `packages/shared` to ensure data consistency
- Uses Cheerio for HTML parsing, got/undici for HTTP
### Why this stack over alternatives
- **Strapi + Next.js** -- Two separate apps to deploy/maintain; Payload v3 integrates into Next.js directly
- **Medusa.js** -- Full e-commerce engine -- overkill when no payment processing is needed
- **WordPress + WooCommerce** -- Not modern, poor DX, PHP stack
- **Directus + Nuxt** -- Good option, but two apps; Payload's admin UI is more polished for this use case
### Deployment: Docker Compose (self-hosted)
```mermaid
graph LR
subgraph docker [Docker Compose]
Nginx["Nginx (reverse proxy + SSL)"]
App["Next.js + Payload CMS"]
DB["PostgreSQL 16"]
MinIO["MinIO (S3 storage)"]
end
Browser --> Nginx
Nginx --> App
Nginx -->|"static media"| MinIO
App --> DB
App -->|"upload/fetch media"| MinIO
```
- **Nginx** -- reverse proxy, SSL termination (Let's Encrypt), static asset caching, proxies `/media` to MinIO
- **App** -- single Next.js container serving both storefront and `/admin` panel
- **PostgreSQL 16** -- product data, orders, users, media metadata
- **MinIO** -- S3-compatible object storage for all product images and media uploads; Payload's S3 storage adapter connects directly to it; Nginx serves media publicly with caching headers
---
## Data Model (Payload Collections)
### Products
- `name` (text, localized if needed later)
- `slug` (auto-generated from name)
- `sku` / `articleNumber` (text, e.g., "73146")
- `category` (relationship to Categories)
- `brand` (select: KASKI, ALAVUS, SWEDOOR/JELD-WEN, etc.)
- `images` (array of uploads, with gallery support)
- `price` (number, rubles)
- `discountPrice` (number, optional)
- `availability` (select: in-stock / made-to-order / coming-soon)
- `shortDescription` (text)
- `technicalSpecs` (rich text -- for detailed specs like on the current site)
- `options` (array of {name, priceModifier, description} -- for paid customizations)
- `relatedProducts` (relationship, self-referencing)
- `seoMeta` (meta title, description, OG image)
### Categories
- `name` (text)
- `slug` (auto)
- `parent` (self-relationship for hierarchy: "Exterior Doors" > "With Glass")
- `description` (rich text)
- `image` (upload)
### Orders
- `orderNumber` (auto-incrementing)
- `items` (array of {product, quantity, priceAtOrder})
- `customer` (group: name, phone, email, comment)
- `status` (select: new / in-progress / completed / cancelled)
- `createdAt` (auto)
- Triggers email notification to admin on new order
### Pages (for static content)
- `title`, `slug`, `content` (rich text), `seoMeta`
- Used for: About, Delivery, Installation, Warranty, Contacts
### SiteSettings (global)
- `phone`, `whatsapp`, `telegram`, `email`
- `address`
- `workingHours`
- `footerText`
- `socialLinks`
### Media (Payload uploads with S3/MinIO)
- Payload's `@payloadcms/storage-s3` adapter stores all uploads in MinIO
- Auto image resizing/optimization via Sharp
- WebP generation for thumbnails and display sizes
- MinIO bucket: `advdoors-media`, public-read policy for product images
---
## Storefront Pages
### Public pages (Next.js App Router)
- **`/`** -- Hero section, featured products, brand highlights, "why choose Finnish doors" section
- **`/catalog`** -- All products with sidebar filters (category, brand, price range, availability), search, pagination
- **`/catalog/[categorySlug]`** -- Category-filtered view
- **`/product/[slug]`** -- Product detail: image gallery (lightbox), specs, price, options, "add to cart", related products
- **`/cart`** -- Cart contents, quantity adjustment, order form (name, phone, email, comment), submit order
- **`/[pageSlug]`** -- Dynamic content pages (about, delivery, installation, warranty, contacts)
### Admin panel (Payload CMS, `/admin`)
Out-of-the-box from Payload:
- Dashboard with order count, recent orders
- Product CRUD with image upload, drag-and-drop reordering
- Category management with tree view
- Order management with status updates
- Page content editor (rich text with embedded images)
- Site settings editor
- User management (admin accounts)
The admin UI is modern, responsive, and intuitive enough for a non-technical user -- it resembles familiar CMS interfaces.
---
## Key Features
### For customers
- Fast, responsive, mobile-first design
- Product search (full-text via PostgreSQL)
- Category filtering + price range + brand filters
- Image galleries with zoom
- Shopping cart (persisted in localStorage, synced on order submit)
- Order form with phone/email (no payment -- order goes to admin)
- WhatsApp / Telegram click-to-chat buttons
- SEO: server-rendered pages, meta tags, structured data (JSON-LD Product schema), sitemap.xml
### For the admin (father)
- Simple login at `/admin`
- Add/edit products with drag-and-drop image upload
- Set prices and discounts
- Manage categories
- View and manage incoming orders (with email notifications)
- Edit content pages (about, delivery, etc.)
- Update contact info and site settings
### Technical
- Turborepo cached builds (`pnpm turbo build` -- only rebuilds what changed)
- Docker Compose one-command deployment (`docker compose up -d`)
- Automatic SSL via Let's Encrypt (Nginx + certbot)
- MinIO for all media storage (S3-compatible, self-hosted, with web console at `:9001`)
- Database backups (pg_dump cron job in Docker)
- Image optimization (Sharp, WebP auto-conversion)
- Rate limiting on order submission
- CSRF protection
---
## Project Structure (Turborepo + pnpm)
```
advdoors/
apps/
web/ # Next.js 15 + Payload CMS 3
src/
app/ # Next.js App Router
(frontend)/ # Route group for public pages
page.tsx # Home
catalog/
product/
cart/
[slug]/ # Dynamic content pages
(payload)/ # Payload admin routes (auto-generated)
collections/ # Payload collection definitions
Products.ts
Categories.ts
Orders.ts
Pages.ts
Media.ts
globals/ # Payload globals
SiteSettings.ts
components/ # React components (storefront UI)
lib/ # Utilities, cart logic, API helpers
payload.config.ts # Payload CMS configuration
public/ # Static assets (favicon, robots.txt)
Dockerfile
next.config.ts
tailwind.config.ts
package.json
scraper/ # Migration scraper CLI
src/
index.ts # Entry point
crawl.ts # Page crawler (pagination, link following)
extract.ts # HTML parser (Cheerio) for product data
import.ts # Payload API client for creating records
download-media.ts # Image downloader + MinIO uploader
package.json
packages/
shared/ # Shared types, constants, brand lists
src/
types.ts # Product, Category, Order types
constants.ts # Brands, availability statuses
package.json
tsconfig/ # Shared tsconfig bases
base.json
nextjs.json
node.json
package.json
eslint-config/ # Shared ESLint configs
base.js
next.js
package.json
docker/
docker-compose.yml # PostgreSQL + MinIO + App + Nginx
docker-compose.dev.yml # Dev overrides (hot reload, exposed ports)
nginx.conf
.env.example
turbo.json # Turborepo pipeline config
pnpm-workspace.yaml # pnpm workspace definition
package.json # Root package.json (scripts: dev, build, lint)
.gitignore
```
---
## Implementation Phases
### Phase 1: Foundation (monorepo + infrastructure)
- Initialize Turborepo + pnpm workspace with `apps/web`, `apps/scraper`, `packages/*`
- Scaffold Next.js 15 + Payload CMS 3 in `apps/web`
- Define data model: collections (Products, Categories, Orders, Pages, Media) and globals (SiteSettings)
- Docker Compose: PostgreSQL 16 + MinIO + App + Nginx
- Configure Payload S3 storage adapter pointing to MinIO
- Shared types in `packages/shared`
- Basic admin panel working, can create products and upload images to MinIO
### Phase 2: Storefront
- Home page design and implementation
- Catalog page with sidebar filters (category, brand, price range) and pagination
- Product detail page with image gallery, specs, pricing, options
- Content pages (about, delivery, installation, warranty, contacts)
- Responsive mobile-first design with Tailwind CSS v4
### Phase 3: Cart and Orders
- Shopping cart (client-side state + UI)
- Order form and submission
- Order management in admin panel
- Email notifications on new orders
### Phase 4: Scraper / Data Migration
- Build scraper in `apps/scraper` using Cheerio + undici
- Crawl advdoors.ru: all catalog pages, follow pagination, extract category structure
- Extract per-product: name, SKU, prices, images, specs, availability, options
- Download images and upload to MinIO
- Import all data into Payload CMS via Local API
- Validate imported data against the live site
### Phase 5: Polish and Deploy
- SEO (meta tags, structured data, sitemap)
- Image optimization pipeline
- Performance optimization (caching, ISR)
- Nginx config with SSL
- Database backup strategy
- Production deployment
---
## Open Questions / Future Considerations
- **Multi-language**: Currently Russian only. Payload supports localization if needed later.
- **Analytics**: Yandex.Metrika integration (simple script tag).
- **CDN**: Not needed initially for a Russian-focused site, but can add later.
- **MinIO replication**: Single-node MinIO is fine to start; can add erasure coding or replication later.
- **CI/CD**: Turborepo's remote caching could be added with a self-hosted cache server if build times grow.

85
justfile Normal file
View File

@ -0,0 +1,85 @@
set dotenv-load := false
dev_compose := "docker/docker-compose.dev.yml"
prod_compose := "docker/docker-compose.yml"
# List available recipes
default:
@just --list
# Full dev setup: services + deps + env + dev server
setup: services install env
@echo "Ready. Run 'just dev' to start."
# Start dev PostgreSQL + MinIO
services:
docker compose -f {{dev_compose}} up -d
@echo "Waiting for services..."
@sleep 3
@echo "PostgreSQL: localhost:5435"
@echo "MinIO API: localhost:9000"
@echo "MinIO UI: localhost:9001 (minioadmin/minioadmin)"
# Stop dev services
services-down:
docker compose -f {{dev_compose}} down
# Install pnpm dependencies
install:
pnpm install
# Create .env from example if it doesn't exist
env:
@[ -f apps/web/.env ] || (cp apps/web/.env.example apps/web/.env && sed -i 's/:5432/:5435/' apps/web/.env && echo "Created apps/web/.env (port adjusted to 5435)")
@[ -f apps/web/.env ] && echo "apps/web/.env exists"
# Start Next.js dev server
dev:
pnpm --filter @advdoors/web dev
# Start everything: services + dev server
up: services
pnpm --filter @advdoors/web dev
# Run scraper
scrape:
pnpm --filter @advdoors/scraper scrape
# Build all packages
build:
pnpm turbo build
# Typecheck all packages
typecheck:
pnpm turbo typecheck
# Lint all packages
lint:
pnpm turbo lint
# Clean build artifacts
clean:
pnpm turbo clean
rm -rf node_modules apps/*/node_modules packages/*/node_modules
# Reset dev database (destructive)
db-reset:
docker compose -f {{dev_compose}} down -v
docker compose -f {{dev_compose}} up -d
@echo "Database and MinIO volumes wiped. Restart dev server to re-create schema."
# Production: build and start all containers
prod-up:
docker compose -f {{prod_compose}} up -d --build
# Production: stop
prod-down:
docker compose -f {{prod_compose}} down
# Production: view logs
prod-logs *args:
docker compose -f {{prod_compose}} logs {{args}}
# Show status of dev services
status:
docker compose -f {{dev_compose}} ps

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "advdoors",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"typecheck": "turbo typecheck",
"clean": "turbo clean"
},
"packageManager": "pnpm@10.20.0",
"engines": {
"node": ">=22"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "sharp"]
}
}

View File

@ -0,0 +1,15 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
);

View File

@ -0,0 +1,3 @@
import baseConfig from "./base.js";
export default [...baseConfig];

View File

@ -0,0 +1,16 @@
{
"name": "@advdoors/eslint-config",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
"./base": "./base.js",
"./next": "./next.js"
},
"dependencies": {
"eslint": "^9",
"typescript-eslint": "^8",
"@eslint/js": "^9"
}
}

View File

@ -0,0 +1,17 @@
{
"name": "@advdoors/shared",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"devDependencies": {
"@advdoors/tsconfig": "workspace:*",
"typescript": "^5"
}
}

View File

@ -0,0 +1,25 @@
import type { Brand, Availability, OrderStatus } from "./types";
export const BRANDS: { value: Brand; label: string }[] = [
{ value: "KASKI", label: "KASKI" },
{ value: "ALAVUS", label: "ALAVUS" },
{ value: "SWEDOOR", label: "SWEDOOR by JELD-WEN" },
{ value: "JELD-WEN", label: "JELD-WEN" },
{ value: "MATTIOVI", label: "MATTIOVI" },
{ value: "ABLOY", label: "ABLOY" },
];
export const AVAILABILITY_OPTIONS: { value: Availability; label: string }[] = [
{ value: "in-stock", label: "В наличии" },
{ value: "made-to-order", label: "На заказ" },
{ value: "coming-soon", label: "Скоро в продаже" },
];
export const ORDER_STATUSES: { value: OrderStatus; label: string }[] = [
{ value: "new", label: "Новый" },
{ value: "in-progress", label: "В работе" },
{ value: "completed", label: "Выполнен" },
{ value: "cancelled", label: "Отменён" },
];
export const MINIO_BUCKET = "advdoors-media";

View File

@ -0,0 +1,2 @@
export * from "./types";
export * from "./constants";

View File

@ -0,0 +1,58 @@
export interface ProductData {
name: string;
slug: string;
articleNumber: string;
brand: Brand;
price: number;
discountPrice?: number;
availability: Availability;
shortDescription?: string;
technicalSpecs?: string;
options?: ProductOption[];
categorySlug?: string;
}
export interface ProductOption {
name: string;
priceModifier: number;
description?: string;
}
export interface CategoryData {
name: string;
slug: string;
parentSlug?: string;
description?: string;
}
export interface OrderItem {
productId: string;
quantity: number;
priceAtOrder: number;
}
export interface CustomerInfo {
name: string;
phone: string;
email?: string;
comment?: string;
}
export interface OrderData {
orderNumber: number;
items: OrderItem[];
customer: CustomerInfo;
status: OrderStatus;
}
export type Brand =
| "KASKI"
| "ALAVUS"
| "SWEDOOR"
| "JELD-WEN"
| "MATTIOVI"
| "ABLOY";
export type Availability = "in-stock" | "made-to-order" | "coming-soon";
export type OrderStatus = "new" | "in-progress" | "completed" | "cancelled";

View File

@ -0,0 +1,8 @@
{
"extends": "@advdoors/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2022",
"lib": ["ES2022"],
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"noEmit": true,
"plugins": [{ "name": "next" }]
}
}

View File

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"module": "ESNext",
"outDir": "dist",
"rootDir": "src"
}
}

View File

@ -0,0 +1,11 @@
{
"name": "@advdoors/tsconfig",
"version": "0.0.0",
"private": true,
"license": "MIT",
"files": [
"base.json",
"nextjs.json",
"node.json"
]
}

7579
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

22
turbo.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"clean": {
"cache": false
}
}
}