From f6a817d7bd04d026f4e4c6565e8ef1f71c5e3c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=91=D1=80=D0=B0=D1=82=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D1=85?= Date: Sun, 1 Nov 2026 17:58:46 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B2=D0=B5=D0=B1=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/.dockerignore | 2 ++ web/Dockerfile | 23 ++++++++++++ web/app/backend/[...path]/route.ts | 55 ++++++++++++++++++++++++++++ web/app/cart/page.tsx | 58 ++++++++++++++++++++++++++++++ web/app/checkout/page.tsx | 41 +++++++++++++++++++++ web/app/globals.css | 16 +++++++++ web/app/health/route.ts | 3 ++ web/app/layout.tsx | 22 ++++++++++++ web/app/orders/[id]/page.tsx | 46 ++++++++++++++++++++++++ web/app/page.tsx | 58 ++++++++++++++++++++++++++++++ web/app/products/[id]/page.tsx | 20 +++++++++++ web/components/AddToCart.tsx | 27 ++++++++++++++ web/lib/api.ts | 34 ++++++++++++++++++ web/lib/types.ts | 48 +++++++++++++++++++++++++ web/next-env.d.ts | 2 ++ web/next.config.ts | 7 ++++ web/package.json | 20 +++++++++++ web/public/.gitkeep | 0 web/tsconfig.json | 21 +++++++++++ 19 files changed, 503 insertions(+) create mode 100644 web/.dockerignore create mode 100644 web/Dockerfile create mode 100644 web/app/backend/[...path]/route.ts create mode 100644 web/app/cart/page.tsx create mode 100644 web/app/checkout/page.tsx create mode 100644 web/app/globals.css create mode 100644 web/app/health/route.ts create mode 100644 web/app/layout.tsx create mode 100644 web/app/orders/[id]/page.tsx create mode 100644 web/app/page.tsx create mode 100644 web/app/products/[id]/page.tsx create mode 100644 web/components/AddToCart.tsx create mode 100644 web/lib/api.ts create mode 100644 web/lib/types.ts create mode 100644 web/next-env.d.ts create mode 100644 web/next.config.ts create mode 100644 web/package.json create mode 100644 web/public/.gitkeep create mode 100644 web/tsconfig.json diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..6f5a838 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,2 @@ +node_modules +.next diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..87ff151 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +ARG API_INTERNAL_URL=http://api:8000 +ENV API_INTERNAL_URL=$API_INTERNAL_URL +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3000 \ + API_INTERNAL_URL=http://api:8000 +COPY --from=build --chown=node:node /app/public ./public +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/web/app/backend/[...path]/route.ts b/web/app/backend/[...path]/route.ts new file mode 100644 index 0000000..ccfde22 --- /dev/null +++ b/web/app/backend/[...path]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +const DROP = new Set(["host", "connection", "content-length", "transfer-encoding"]); + +async function proxy(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const base = process.env.API_INTERNAL_URL; + if (!base) { + return NextResponse.json({ detail: "API_INTERNAL_URL is not set" }, { status: 500 }); + } + const { path } = await context.params; + const target = new URL(`${base.replace(/\/$/, "")}/${path.join("/")}`); + target.search = request.nextUrl.search; + + const headers = new Headers(); + request.headers.forEach((value, key) => { + if (!DROP.has(key.toLowerCase())) { + headers.set(key, value); + } + }); + + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + const upstream = await fetch(target, { + method: request.method, + headers, + body: hasBody ? await request.arrayBuffer() : undefined, + cache: "no-store", + redirect: "manual", + }); + + const responseHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + const name = key.toLowerCase(); + if (name === "content-encoding" || name === "content-length" || name === "transfer-encoding" || name === "set-cookie") { + return; + } + responseHeaders.set(key, value); + }); + const cookies = typeof upstream.headers.getSetCookie === "function" ? upstream.headers.getSetCookie() : []; + for (const cookie of cookies) { + responseHeaders.append("set-cookie", cookie); + } + + return new NextResponse(await upstream.arrayBuffer(), { + status: upstream.status, + headers: responseHeaders, + }); +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; diff --git a/web/app/cart/page.tsx b/web/app/cart/page.tsx new file mode 100644 index 0000000..8e06d2d --- /dev/null +++ b/web/app/cart/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import type { Cart } from "@/lib/types"; + +export default function CartPage() { + const [cart, setCart] = useState(null); + const [error, setError] = useState(""); + + async function load() { + const response = await fetch("/backend/cart", { credentials: "include", cache: "no-store" }); + if (!response.ok) { + setError("Не удалось загрузить корзину"); + return; + } + setCart(await response.json()); + } + + useEffect(() => { + void load(); + }, []); + + async function remove(productId: number) { + const response = await fetch(`/backend/cart/items/${productId}`, { + method: "DELETE", + credentials: "include", + }); + if (response.ok) { + setCart(await response.json()); + } + } + + if (error) { + return

{error}

; + } + if (!cart) { + return

Загрузка…

; + } + + return ( + <> +

Корзина

+ {cart.items.length === 0 &&

Пока пусто.

} + {cart.items.map((item) => ( +

+ {item.title} × {item.qty} = {item.line_total} ₽{" "} + +

+ ))} +

Итого: {cart.total} ₽

+ {cart.items.length > 0 && Оформить заказ} + + ); +} diff --git a/web/app/checkout/page.tsx b/web/app/checkout/page.tsx new file mode 100644 index 0000000..4a15cf5 --- /dev/null +++ b/web/app/checkout/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, type FormEvent } from "react"; + +export default function CheckoutPage() { + const router = useRouter(); + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [error, setError] = useState(""); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(""); + const response = await fetch("/backend/orders", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ customer_name: name, customer_phone: phone }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + setError(typeof data.detail === "string" ? data.detail : "Проверьте имя и телефон"); + return; + } + router.push(`/orders/${data.id}`); + } + + return ( + <> +

Заказ

+

Оплата не списывается. Заказ только записывается в базу.

+
void submit(event)}> + setName(event.target.value)} placeholder="Имя" required /> + setPhone(event.target.value)} placeholder="Телефон" required /> + + {error &&

{error}

} +
+ + ); +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..c652087 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,16 @@ +* { box-sizing: border-box; } +body { margin: 0; font-family: sans-serif; background: #f4f4f4; color: #171717; } +a { color: inherit; } +header { display: flex; gap: 16px; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ddd; } +header a { font-weight: 600; text-decoration: none; } +main { max-width: 840px; margin: 0 auto; padding: 20px 16px 48px; } +.row { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 16px; } +.row a { background: #fff; border: 1px solid #ccc; padding: 6px 10px; text-decoration: none; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.card { background: #fff; border: 1px solid #ddd; padding: 12px; } +.card img, .product img { width: 100%; height: auto; display: block; background: #eee; } +.card h2, .product h1 { font-size: 1.1rem; margin: 10px 0 6px; } +.muted { color: #555; } +button, input { font: inherit; padding: 8px 10px; } +form { display: grid; gap: 8px; max-width: 360px; } +.error { color: #9f1239; } diff --git a/web/app/health/route.ts b/web/app/health/route.ts new file mode 100644 index 0000000..613ab1b --- /dev/null +++ b/web/app/health/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ status: "ok" }); +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..4b16605 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; + +import "./globals.css"; + +export const metadata = { + title: "Магазин", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +
+ Каталог + Корзина +
+
{children}
+ + + ); +} diff --git a/web/app/orders/[id]/page.tsx b/web/app/orders/[id]/page.tsx new file mode 100644 index 0000000..481bf36 --- /dev/null +++ b/web/app/orders/[id]/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +import type { Order } from "@/lib/types"; + +export default function OrderPage() { + const params = useParams<{ id: string }>(); + const [order, setOrder] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + void (async () => { + const response = await fetch(`/backend/orders/${params.id}`, { credentials: "include", cache: "no-store" }); + if (!response.ok) { + setError("Заказ не найден"); + return; + } + setOrder(await response.json()); + })(); + }, [params.id]); + + if (error) { + return

{error}

; + } + if (!order) { + return

Загрузка…

; + } + + return ( + <> +

Заказ {order.id}

+

Статус: создан, без оплаты.

+

+ {order.customer_name}, {order.customer_phone} +

+ {order.items.map((item) => ( +

+ {item.title} × {item.qty} — {item.price} ₽ +

+ ))} +

Итого: {order.total} ₽

+ + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..23e88d2 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; + +import { getCatalog } from "@/lib/api"; + +export const dynamic = "force-dynamic"; + +const SORTS = [ + ["price_asc", "Сначала дешевле"], + ["price_desc", "Сначала дороже"], + ["new", "Новые"], +] as const; + +export default async function CatalogPage({ + searchParams, +}: { + searchParams: Promise<{ category_id?: string; sort?: string }>; +}) { + const params = await searchParams; + const sort = SORTS.some(([value]) => value === params.sort) ? params.sort! : "price_asc"; + const catalog = await getCatalog(params.category_id, sort); + + return ( + <> +

Каталог

+
+ Все + {catalog.categories.map((category) => ( + + {category.name} + + ))} +
+
+ {SORTS.map(([value, label]) => ( + + {label} + + ))} +
+
+ {catalog.products.map((product) => ( +
+ + +

{product.title}

+ +

{product.category_name}

+

{product.price} ₽

+
+ ))} +
+ {catalog.products.length === 0 &&

В этой категории нет активных товаров.

} + + ); +} diff --git a/web/app/products/[id]/page.tsx b/web/app/products/[id]/page.tsx new file mode 100644 index 0000000..dc39209 --- /dev/null +++ b/web/app/products/[id]/page.tsx @@ -0,0 +1,20 @@ +import { AddToCart } from "@/components/AddToCart"; +import { getProduct } from "@/lib/api"; + +export const dynamic = "force-dynamic"; + +export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const product = await getProduct(id); + + return ( +
+ +

{product.category_name}

+

{product.title}

+

{product.description}

+

{product.price} ₽

+ +
+ ); +} diff --git a/web/components/AddToCart.tsx b/web/components/AddToCart.tsx new file mode 100644 index 0000000..a0c02ec --- /dev/null +++ b/web/components/AddToCart.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useState } from "react"; + +export function AddToCart({ productId }: { productId: number }) { + const [message, setMessage] = useState(""); + + async function add() { + setMessage("Добавляю…"); + const response = await fetch("/backend/cart/items", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ product_id: productId, qty: 1 }), + }); + setMessage(response.ok ? "В корзине" : "Не удалось добавить"); + } + + return ( +

+ {" "} + {message} +

+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts new file mode 100644 index 0000000..ada5568 --- /dev/null +++ b/web/lib/api.ts @@ -0,0 +1,34 @@ +import { notFound } from "next/navigation"; + +import type { Catalog, Product } from "./types"; + +function apiBase(): string { + const url = process.env.API_INTERNAL_URL; + if (!url) { + throw new Error("API_INTERNAL_URL is not set"); + } + return url.replace(/\/$/, ""); +} + +export async function getCatalog(categoryId?: string, sort = "price_asc"): Promise { + const params = new URLSearchParams({ sort }); + if (categoryId) { + params.set("category_id", categoryId); + } + const response = await fetch(`${apiBase()}/products?${params}`, { cache: "no-store" }); + if (!response.ok) { + throw new Error("Не удалось загрузить каталог"); + } + return response.json(); +} + +export async function getProduct(id: string): Promise { + const response = await fetch(`${apiBase()}/products/${id}`, { cache: "no-store" }); + if (response.status === 404) { + notFound(); + } + if (!response.ok) { + throw new Error("Не удалось загрузить товар"); + } + return response.json(); +} diff --git a/web/lib/types.ts b/web/lib/types.ts new file mode 100644 index 0000000..6fac85f --- /dev/null +++ b/web/lib/types.ts @@ -0,0 +1,48 @@ +export type Category = { + id: number; + name: string; +}; + +export type Product = { + id: number; + category_id: number; + category_name: string; + title: string; + description: string; + price: string; +}; + +export type Catalog = { + categories: Category[]; + products: Product[]; +}; + +export type CartItem = { + product_id: number; + title: string; + price: string; + qty: number; + line_total: string; +}; + +export type Cart = { + items: CartItem[]; + total: string; +}; + +export type OrderItem = { + product_id: number; + title: string; + price: string; + qty: number; +}; + +export type Order = { + id: number; + status: string; + customer_name: string; + customer_phone: string; + total: string; + created_at: string; + items: OrderItem[]; +}; diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 0000000..546e587 --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 0000000..864f2f8 --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b0855e1 --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "shop-web", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^15.5.4", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "typescript": "^5.9.0" + } +} diff --git a/web/public/.gitkeep b/web/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..dacb033 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}