feat: добавил веб клиент

This commit is contained in:
Илья Братских 2026-11-01 17:58:46 +03:00
parent 61f70eed71
commit f6a817d7bd
19 changed files with 503 additions and 0 deletions

2
web/.dockerignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
.next

23
web/Dockerfile Normal file
View File

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

View File

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

58
web/app/cart/page.tsx Normal file
View File

@ -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<Cart | null>(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 <p className="error">{error}</p>;
}
if (!cart) {
return <p>Загрузка…</p>;
}
return (
<>
<h1>Корзина</h1>
{cart.items.length === 0 && <p>Пока пусто.</p>}
{cart.items.map((item) => (
<p key={item.product_id}>
{item.title} × {item.qty} = {item.line_total} ₽{" "}
<button type="button" onClick={() => void remove(item.product_id)}>
Убрать
</button>
</p>
))}
<p>Итого: {cart.total} ₽</p>
{cart.items.length > 0 && <Link href="/checkout">Оформить заказ</Link>}
</>
);
}

41
web/app/checkout/page.tsx Normal file
View File

@ -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 (
<>
<h1>Заказ</h1>
<p className="muted">Оплата не списывается. Заказ только записывается в базу.</p>
<form onSubmit={(event) => void submit(event)}>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Имя" required />
<input value={phone} onChange={(event) => setPhone(event.target.value)} placeholder="Телефон" required />
<button type="submit">Подтвердить</button>
{error && <p className="error">{error}</p>}
</form>
</>
);
}

16
web/app/globals.css Normal file
View File

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

3
web/app/health/route.ts Normal file
View File

@ -0,0 +1,3 @@
export function GET() {
return Response.json({ status: "ok" });
}

22
web/app/layout.tsx Normal file
View File

@ -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 (
<html lang="ru">
<body>
<header>
<Link href="/">Каталог</Link>
<Link href="/cart">Корзина</Link>
</header>
<main>{children}</main>
</body>
</html>
);
}

View File

@ -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<Order | null>(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 <p className="error">{error}</p>;
}
if (!order) {
return <p>Загрузка…</p>;
}
return (
<>
<h1>Заказ {order.id}</h1>
<p>Статус: создан, без оплаты.</p>
<p>
{order.customer_name}, {order.customer_phone}
</p>
{order.items.map((item) => (
<p key={item.product_id}>
{item.title} × {item.qty} — {item.price} ₽
</p>
))}
<p>Итого: {order.total} ₽</p>
</>
);
}

58
web/app/page.tsx Normal file
View File

@ -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 (
<>
<h1>Каталог</h1>
<div className="row">
<Link href={`/?sort=${sort}`}>Все</Link>
{catalog.categories.map((category) => (
<Link key={category.id} href={`/?category_id=${category.id}&sort=${sort}`}>
{category.name}
</Link>
))}
</div>
<div className="row">
{SORTS.map(([value, label]) => (
<Link
key={value}
href={params.category_id ? `/?category_id=${params.category_id}&sort=${value}` : `/?sort=${value}`}
>
{label}
</Link>
))}
</div>
<div className="grid">
{catalog.products.map((product) => (
<article className="card" key={product.id}>
<Link href={`/products/${product.id}`}>
<img src={`/backend/products/${product.id}/image`} alt="" />
<h2>{product.title}</h2>
</Link>
<p className="muted">{product.category_name}</p>
<p>{product.price} ₽</p>
</article>
))}
</div>
{catalog.products.length === 0 && <p>В этой категории нет активных товаров.</p>}
</>
);
}

View File

@ -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 (
<article className="product">
<img src={`/backend/products/${product.id}/image`} alt="" />
<p className="muted">{product.category_name}</p>
<h1>{product.title}</h1>
<p>{product.description}</p>
<p>{product.price} ₽</p>
<AddToCart productId={product.id} />
</article>
);
}

View File

@ -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 (
<p>
<button type="button" onClick={add}>
В корзину
</button>{" "}
{message}
</p>
);
}

34
web/lib/api.ts Normal file
View File

@ -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<Catalog> {
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<Product> {
const response = await fetch(`${apiBase()}/products/${id}`, { cache: "no-store" });
if (response.status === 404) {
notFound();
}
if (!response.ok) {
throw new Error("Не удалось загрузить товар");
}
return response.json();
}

48
web/lib/types.ts Normal file
View File

@ -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[];
};

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

@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

7
web/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

20
web/package.json Normal file
View File

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

0
web/public/.gitkeep Normal file
View File

21
web/tsconfig.json Normal file
View File

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