init
This commit is contained in:
parent
e3464ca776
commit
a240d523e1
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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/
|
||||
64
README.md
64
README.md
@ -0,0 +1,64 @@
|
||||
# ADVdoors
|
||||
|
||||
Modern product catalog for Finnish doors (KASKI, SWEDOOR/JELD-WEN, ALAVUS, ABLOY).
|
||||
|
||||
Built with **Next.js 15**, **Payload CMS 3**, **PostgreSQL 16**, and **MinIO**, managed as a **Turborepo + pnpm** monorepo.
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
```bash
|
||||
just setup # services + deps + .env
|
||||
just dev # start Next.js dev server
|
||||
```
|
||||
|
||||
Or do it all at once:
|
||||
|
||||
```bash
|
||||
just up # services + dev server
|
||||
```
|
||||
|
||||
- **Storefront**: http://localhost:3000
|
||||
- **Admin panel**: http://localhost:3000/admin (create first user on first visit)
|
||||
- **MinIO console**: http://localhost:9001 (minioadmin / minioadmin)
|
||||
- **Payload API**: http://localhost:3000/api
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/web/ — Next.js + Payload CMS (storefront + admin)
|
||||
apps/scraper/ — Migration scraper CLI
|
||||
packages/shared — Shared types and constants
|
||||
docker/ — Docker Compose configs, Caddy, backups
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
```bash
|
||||
cp docker/.env.example docker/.env # edit with real secrets
|
||||
just prod-up
|
||||
```
|
||||
|
||||
Caddy handles SSL automatically.
|
||||
|
||||
## Migration (Scraper)
|
||||
|
||||
```bash
|
||||
just scrape
|
||||
```
|
||||
|
||||
## All Commands
|
||||
|
||||
Run `just` to see the full list. Key ones:
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `just setup` | Full dev setup (services + deps + .env) |
|
||||
| `just up` | Start services + dev server |
|
||||
| `just dev` | Dev server only (services must be running) |
|
||||
| `just build` | Build all packages |
|
||||
| `just scrape` | Run migration scraper |
|
||||
| `just db-reset` | Wipe dev database and MinIO |
|
||||
| `just prod-up` | Production build + start |
|
||||
| `just prod-logs -f` | Tail production logs |
|
||||
|
||||
See [docs/PLAN.md](docs/PLAN.md) for the full architecture plan.
|
||||
21
apps/scraper/package.json
Normal file
21
apps/scraper/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
26
apps/scraper/src/config.ts
Normal file
26
apps/scraper/src/config.ts
Normal 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
133
apps/scraper/src/crawl.ts
Normal 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;
|
||||
}
|
||||
32
apps/scraper/src/download-media.ts
Normal file
32
apps/scraper/src/download-media.ts
Normal 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
121
apps/scraper/src/extract.ts
Normal 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
198
apps/scraper/src/import.ts
Normal 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
118
apps/scraper/src/index.ts
Normal 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);
|
||||
});
|
||||
8
apps/scraper/tsconfig.json
Normal file
8
apps/scraper/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@advdoors/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
apps/web/.env.example
Normal file
10
apps/web/.env.example
Normal 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
|
||||
3
apps/web/eslint.config.js
Normal file
3
apps/web/eslint.config.js
Normal 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
5
apps/web/next-env.d.ts
vendored
Normal 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
20
apps/web/next.config.ts
Normal 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
39
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
apps/web/postcss.config.mjs
Normal file
7
apps/web/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
4
apps/web/public/robots.txt
Normal file
4
apps/web/public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin
|
||||
Disallow: /api
|
||||
58
apps/web/src/app/(frontend)/[slug]/page.tsx
Normal file
58
apps/web/src/app/(frontend)/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
apps/web/src/app/(frontend)/api/submit-order/route.ts
Normal file
90
apps/web/src/app/(frontend)/api/submit-order/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/web/src/app/(frontend)/cart/page.tsx
Normal file
15
apps/web/src/app/(frontend)/cart/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
313
apps/web/src/app/(frontend)/catalog/page.tsx
Normal file
313
apps/web/src/app/(frontend)/catalog/page.tsx
Normal 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"
|
||||
>
|
||||
“{params.q}” ×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
1
apps/web/src/app/(frontend)/globals.css
Normal file
1
apps/web/src/app/(frontend)/globals.css
Normal file
@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
45
apps/web/src/app/(frontend)/layout.tsx
Normal file
45
apps/web/src/app/(frontend)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
apps/web/src/app/(frontend)/page.tsx
Normal file
250
apps/web/src/app/(frontend)/page.tsx
Normal 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,7–1,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
286
apps/web/src/app/(frontend)/product/[slug]/page.tsx
Normal file
286
apps/web/src/app/(frontend)/product/[slug]/page.tsx
Normal 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"
|
||||
? "На заказ (1–6 недель)"
|
||||
: "Скоро в продаже"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/app/(frontend)/sitemap.ts
Normal file
61
apps/web/src/app/(frontend)/sitemap.ts
Normal 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;
|
||||
}
|
||||
23
apps/web/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
23
apps/web/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal 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;
|
||||
53
apps/web/src/app/(payload)/admin/importMap.js
Normal file
53
apps/web/src/app/(payload)/admin/importMap.js
Normal 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
|
||||
}
|
||||
18
apps/web/src/app/(payload)/api/[...slug]/route.ts
Normal file
18
apps/web/src/app/(payload)/api/[...slug]/route.ts
Normal 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);
|
||||
6
apps/web/src/app/(payload)/api/graphql/route.ts
Normal file
6
apps/web/src/app/(payload)/api/graphql/route.ts
Normal 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);
|
||||
31
apps/web/src/app/(payload)/layout.tsx
Normal file
31
apps/web/src/app/(payload)/layout.tsx
Normal 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;
|
||||
42
apps/web/src/collections/Categories.ts
Normal file
42
apps/web/src/collections/Categories.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
39
apps/web/src/collections/Media.ts
Normal file
39
apps/web/src/collections/Media.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
107
apps/web/src/collections/Orders.ts
Normal file
107
apps/web/src/collections/Orders.ts
Normal 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;
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
47
apps/web/src/collections/Pages.ts
Normal file
47
apps/web/src/collections/Pages.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
139
apps/web/src/collections/Products.ts
Normal file
139
apps/web/src/collections/Products.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
15
apps/web/src/collections/Users.ts
Normal file
15
apps/web/src/collections/Users.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
28
apps/web/src/components/AddToCartButton.tsx
Normal file
28
apps/web/src/components/AddToCartButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
apps/web/src/components/CartView.tsx
Normal file
273
apps/web/src/components/CartView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/web/src/components/Footer.tsx
Normal file
120
apps/web/src/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/components/Header.tsx
Normal file
80
apps/web/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/components/ImageGallery.tsx
Normal file
76
apps/web/src/components/ImageGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/components/MobileMenu.tsx
Normal file
70
apps/web/src/components/MobileMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/web/src/components/ProductCard.tsx
Normal file
110
apps/web/src/components/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/components/RichText.tsx
Normal file
22
apps/web/src/components/RichText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/globals/SiteSettings.ts
Normal file
63
apps/web/src/globals/SiteSettings.ts
Normal 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
100
apps/web/src/lib/cart.ts
Normal 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
30
apps/web/src/lib/media.ts
Normal 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 ?? "";
|
||||
}
|
||||
6
apps/web/src/lib/payload.ts
Normal file
6
apps/web/src/lib/payload.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { getPayload as getPayloadInstance } from "payload";
|
||||
import config from "@payload-config";
|
||||
|
||||
export async function getPayloadClient() {
|
||||
return getPayloadInstance({ config });
|
||||
}
|
||||
50
apps/web/src/lib/structured-data.ts
Normal file
50
apps/web/src/lib/structured-data.ts
Normal 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 в Москве и России.",
|
||||
};
|
||||
}
|
||||
703
apps/web/src/payload-types.ts
Normal file
703
apps/web/src/payload-types.ts
Normal 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 {}
|
||||
}
|
||||
67
apps/web/src/payload.config.ts
Normal file
67
apps/web/src/payload.config.ts
Normal 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
24
apps/web/tsconfig.json
Normal 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
6
docker/.env.example
Normal 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
13
docker/Caddyfile
Normal 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
40
docker/Dockerfile
Normal 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
16
docker/backup.sh
Executable 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"
|
||||
52
docker/docker-compose.dev.yml
Normal file
52
docker/docker-compose.dev.yml
Normal 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
108
docker/docker-compose.yml
Normal 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
320
docs/PLAN.md
Normal 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
85
justfile
Normal 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
18
package.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
15
packages/eslint-config/base.js
Normal file
15
packages/eslint-config/base.js
Normal 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: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
3
packages/eslint-config/next.js
Normal file
3
packages/eslint-config/next.js
Normal file
@ -0,0 +1,3 @@
|
||||
import baseConfig from "./base.js";
|
||||
|
||||
export default [...baseConfig];
|
||||
16
packages/eslint-config/package.json
Normal file
16
packages/eslint-config/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
packages/shared/package.json
Normal file
17
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
25
packages/shared/src/constants.ts
Normal file
25
packages/shared/src/constants.ts
Normal 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";
|
||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./constants";
|
||||
58
packages/shared/src/types.ts
Normal file
58
packages/shared/src/types.ts
Normal 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";
|
||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@advdoors/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
20
packages/tsconfig/base.json
Normal file
20
packages/tsconfig/base.json
Normal 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"]
|
||||
}
|
||||
11
packages/tsconfig/nextjs.json
Normal file
11
packages/tsconfig/nextjs.json
Normal 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" }]
|
||||
}
|
||||
}
|
||||
9
packages/tsconfig/node.json
Normal file
9
packages/tsconfig/node.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
}
|
||||
}
|
||||
11
packages/tsconfig/package.json
Normal file
11
packages/tsconfig/package.json
Normal 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
7579
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
22
turbo.json
Normal file
22
turbo.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user