feat: добавил веб клиент
This commit is contained in:
parent
61f70eed71
commit
f6a817d7bd
2
web/.dockerignore
Normal file
2
web/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.next
|
||||
23
web/Dockerfile
Normal file
23
web/Dockerfile
Normal 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"]
|
||||
55
web/app/backend/[...path]/route.ts
Normal file
55
web/app/backend/[...path]/route.ts
Normal 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
58
web/app/cart/page.tsx
Normal 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
41
web/app/checkout/page.tsx
Normal 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
16
web/app/globals.css
Normal 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
3
web/app/health/route.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function GET() {
|
||||
return Response.json({ status: "ok" });
|
||||
}
|
||||
22
web/app/layout.tsx
Normal file
22
web/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
web/app/orders/[id]/page.tsx
Normal file
46
web/app/orders/[id]/page.tsx
Normal 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
58
web/app/page.tsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
web/app/products/[id]/page.tsx
Normal file
20
web/app/products/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
web/components/AddToCart.tsx
Normal file
27
web/components/AddToCart.tsx
Normal 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
34
web/lib/api.ts
Normal 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
48
web/lib/types.ts
Normal 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
2
web/next-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
7
web/next.config.ts
Normal file
7
web/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
20
web/package.json
Normal file
20
web/package.json
Normal 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
0
web/public/.gitkeep
Normal file
21
web/tsconfig.json
Normal file
21
web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user