Update project 7 FRONT UI -> API -> DB -> RabbitMQ -> Worker -> MinIO
This commit is contained in:
@@ -2,23 +2,22 @@
|
||||
|
||||
## 1. Базовые принципы
|
||||
- **Стек:** Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS.
|
||||
- **UI/UX:** Темная тема (`#121212`), акцентный цвет `#E32636` (красный неон). Иконки — строго `lucide-react`.
|
||||
- **Компоненты:** Никакого кастомного CSS. Используем только утилитные классы Tailwind. Если нужен сложный компонент (модалка, селект) — используем паттерны `shadcn/ui`.
|
||||
- **UI/UX:** Темная тема (`#121212`), акцентный цвет `#E32636`. Иконки — строго `lucide-react`.
|
||||
- **Компоненты:** Никакого кастомного CSS. Только утилитные классы Tailwind.
|
||||
|
||||
## 2. Управление состоянием (State Management)
|
||||
- **Локальный стейт:** `useState` / `useReducer` для состояния UI (открытые меню, табы).
|
||||
- **Глобальный стейт:** Строго `Zustand` (директория `src/store/`).
|
||||
- `authStore`: Хранит JWT-токен и данные профиля (persist in localStorage).
|
||||
- `cartStore`: Хранит выбранные `seat_id` и считает итоговую сумму (totalPrice).
|
||||
- **Глобальный стейт (Zustand):**
|
||||
- `authStore`: Хранит JWT-токен и данные профиля. **Обязательно** использовать persist middleware (хранить в localStorage).
|
||||
- `cartStore`: Хранит выбранные места (`CartSeat` с `ticketId`). **ЗАПРЕЩЕНО** использовать persist. Корзина живет только в памяти страницы, так как лок места на бэкенде временный (TTL в Redis).
|
||||
|
||||
## 3. Работа с API и Сетью
|
||||
## 3. Работа с API и Сетью (КРИТИЧЕСКИ ВАЖНО)
|
||||
- **Базовый URL:** ЗАПРЕЩЕНО хардкодить `localhost` или прямые IP в исходном коде. Всегда использовать `process.env.NEXT_PUBLIC_API_URL` с фоллбэком.
|
||||
- **Клиент:** Axios инстанс (`src/api/client.ts`).
|
||||
- **Авторизация:** Интерсептор автоматически добавляет `Authorization: Bearer <token>` из `authStore`.
|
||||
- **Роутинг к Бэкенду:** Base URL указывает на API-шлюз бэкенда.
|
||||
- **Обработка ошибок:** Все сетевые запросы должны оборачиваться в `try/catch`. 401 ошибка должна разлогинивать юзера и чистить `authStore`. 409 ошибка (Conflict) обрабатывается локально в компонентах (например, при захвате мест).
|
||||
- **Обработка ошибок:** Все вызовы API оборачивать в `try/catch`. Бизнес-ошибки (например, 409 Conflict) обрабатывать локально с показом Toast-уведомлений.
|
||||
|
||||
## 4. Структура директорий
|
||||
- `src/app/` — Роутинг (Pages, Layouts).
|
||||
- `src/components/` — Переиспользуемые UI компоненты (Кнопки, Карточки, SVG-схемы).
|
||||
- `src/components/` — Переиспользуемые UI компоненты.
|
||||
- `src/api/` — API-клиенты и типизация ответов бэкенда.
|
||||
- `src/store/` — Zustand-хранилища.
|
||||
@@ -2,7 +2,7 @@ import axios from "axios";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: "http://192.168.149.101:8000/api",
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8081/api",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
@@ -14,13 +14,47 @@ apiClient.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// ─── Seat locking ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface LockSeatResponse {
|
||||
message: string;
|
||||
seat_id: number;
|
||||
status: string;
|
||||
/** Returned by backend when the Ticket row is created. Falls back to seat_id. */
|
||||
ticket_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/seats/{seat_id}/lock?user_id={user_id}
|
||||
* Бросает AxiosError со status 409, если место захвачено конкурентом.
|
||||
* Returns the ticketId for the locked seat (falls back to seatId if backend
|
||||
* does not yet expose ticket_id in the response body).
|
||||
* Throws AxiosError with status 409 if the seat is already locked.
|
||||
*/
|
||||
export async function lockSeatApi(seatId: number, userId: number): Promise<void> {
|
||||
await apiClient.post(`/seats/${seatId}/lock`, null, {
|
||||
params: { user_id: userId },
|
||||
export async function lockSeatApi(
|
||||
seatId: number,
|
||||
userId: number
|
||||
): Promise<{ ticketId: number }> {
|
||||
const response = await apiClient.post<LockSeatResponse>(
|
||||
`/seats/${seatId}/lock`,
|
||||
null,
|
||||
{ params: { user_id: userId } }
|
||||
);
|
||||
return { ticketId: response.data.ticket_id ?? seatId };
|
||||
}
|
||||
|
||||
// ─── Payment webhook ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/payment
|
||||
* Simulates a payment gateway callback for the given ticket.
|
||||
* Uses a random idempotency key to satisfy the backend's idempotency check.
|
||||
*/
|
||||
export async function processPaymentWebhook(ticketId: number): Promise<void> {
|
||||
const idempotencyKey = "req-" + Math.random().toString(36).substring(2, 9);
|
||||
await apiClient.post("/webhooks/payment", {
|
||||
ticket_id: ticketId,
|
||||
idempotency_key: idempotencyKey,
|
||||
status: "success",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
346
frontend-client/src/app/checkout/page.tsx
Normal file
346
frontend-client/src/app/checkout/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Shield,
|
||||
Check,
|
||||
Ticket,
|
||||
CreditCard,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useCartStore, type CartSeat } from "@/store/cartStore";
|
||||
import { processPaymentWebhook } from "@/api/client";
|
||||
|
||||
// ─── Mock event data (real data would come from route param / API) ─────────────
|
||||
|
||||
const MOCK_EVENT = {
|
||||
title: "Чемпионат по ММА",
|
||||
subtitle: "15 Марта, 19:00 | ВТБ Арена",
|
||||
imageSrc:
|
||||
"https://images.unsplash.com/photo-1549719386-74dfcbf7dbed?w=400&q=80",
|
||||
};
|
||||
|
||||
// ─── Payment method config ────────────────────────────────────────────────────
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
{ id: "apple-pay", label: "Apple Pay" },
|
||||
{ id: "card", label: "Кредитная карта" },
|
||||
{ id: "sbp", label: "Скидка на кэшбэк" },
|
||||
] as const;
|
||||
|
||||
type PaymentMethodId = (typeof PAYMENT_METHODS)[number]["id"];
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function PaymentMethodIcon({ id }: { id: PaymentMethodId }) {
|
||||
if (id === "apple-pay") {
|
||||
return (
|
||||
<div className="w-10 h-7 bg-white rounded-md flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-black text-[10px] font-extrabold tracking-tight leading-none">
|
||||
Pay
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (id === "card") {
|
||||
return (
|
||||
<div className="w-10 h-7 bg-[#2C2C2E] rounded-md flex items-center justify-center flex-shrink-0">
|
||||
<CreditCard size={15} className="text-[#8E8E93]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Mastercard-style icon
|
||||
return (
|
||||
<div className="w-10 h-7 bg-[#2C2C2E] rounded-md flex items-center justify-center flex-shrink-0">
|
||||
<div className="flex -space-x-2">
|
||||
<div className="w-4 h-4 rounded-full bg-red-500 opacity-90" />
|
||||
<div className="w-4 h-4 rounded-full bg-yellow-400 opacity-90" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group seats by sector+row for the summary block
|
||||
function groupByRow(seats: CartSeat[]): Map<string, CartSeat[]> {
|
||||
const map = new Map<string, CartSeat[]>();
|
||||
for (const seat of seats) {
|
||||
const key = `${seat.sector}__${seat.row}`;
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.push(seat);
|
||||
} else {
|
||||
map.set(key, [seat]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const router = useRouter();
|
||||
const { seats, totalPrice, clearCart } = useCartStore();
|
||||
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodId>("apple-pay");
|
||||
const [useBonuses, setUseBonuses] = useState(false);
|
||||
const [promoCode, setPromoCode] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [toastMsg, setToastMsg] = useState<string | null>(null);
|
||||
const [toastIsError, setToastIsError] = useState(false);
|
||||
|
||||
const BONUS_BALANCE = 120;
|
||||
const bonusDiscount = useBonuses ? BONUS_BALANCE : 0;
|
||||
const rawTotal = totalPrice();
|
||||
const finalTotal = rawTotal - bonusDiscount;
|
||||
const seatGroups = groupByRow(seats);
|
||||
|
||||
function showToast(message: string, isError = false) {
|
||||
setToastMsg(message);
|
||||
setToastIsError(isError);
|
||||
setTimeout(() => setToastMsg(null), 3000);
|
||||
}
|
||||
|
||||
async function handlePay() {
|
||||
if (seats.length === 0 || isLoading) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await Promise.all(seats.map((s: CartSeat) => processPaymentWebhook(s.ticketId)));
|
||||
clearCart();
|
||||
showToast("Оплата прошла успешно!");
|
||||
setTimeout(() => router.push("/tickets"), 1400);
|
||||
} catch {
|
||||
showToast("Ошибка оплаты. Попробуйте снова.", true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty cart guard ──
|
||||
if (seats.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||
<div className="w-full max-w-[390px] flex flex-col items-center justify-center gap-5 px-5">
|
||||
<div className="w-16 h-16 rounded-full bg-[#1C1C1E] flex items-center justify-center">
|
||||
<Ticket size={28} className="text-[#8E8E93]" />
|
||||
</div>
|
||||
<p className="text-[15px] text-[#8E8E93] text-center">
|
||||
Корзина пуста. Выберите места.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-[#E32636] text-[14px] font-semibold"
|
||||
>
|
||||
← Назад к выбору мест
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||
<div className="relative w-full max-w-[390px] flex flex-col bg-[#121212]">
|
||||
|
||||
{/* ── Toast ── */}
|
||||
<div
|
||||
className={`fixed top-14 left-1/2 -translate-x-1/2 z-50 transition-all duration-300 ${
|
||||
toastMsg ? "opacity-100 translate-y-0 pointer-events-auto" : "opacity-0 -translate-y-2 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-full shadow-xl text-white text-[13px] font-medium whitespace-nowrap ${
|
||||
toastIsError ? "bg-[#E32636]" : "bg-[#16A34A]"
|
||||
}`}
|
||||
>
|
||||
{!toastIsError && <Check size={14} strokeWidth={2.5} />}
|
||||
{toastMsg}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3 px-5 pt-12 pb-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
aria-label="Назад"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full bg-[#1C1C1E] text-white flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
<h1 className="text-[18px] font-bold text-white">Оформление заказа</h1>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable content ── */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-36 space-y-3">
|
||||
|
||||
{/* Order summary card */}
|
||||
<div className="bg-[#1C1C1E] rounded-2xl p-4">
|
||||
{/* Event banner */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="relative w-12 h-12 rounded-xl overflow-hidden flex-shrink-0">
|
||||
<Image
|
||||
src={MOCK_EVENT.imageSrc}
|
||||
alt={MOCK_EVENT.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="48px"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[15px] font-bold text-white leading-tight truncate">
|
||||
{MOCK_EVENT.title}
|
||||
</p>
|
||||
<p className="text-[12px] text-[#8E8E93] mt-0.5">{MOCK_EVENT.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-[#2C2C2E] mb-4" />
|
||||
|
||||
{/* Grouped seat rows */}
|
||||
{Array.from(seatGroups.entries()).map(([key, groupSeats]) => (
|
||||
<div key={key} className="flex justify-between items-baseline mb-2 last:mb-0">
|
||||
<p className="text-[13px] text-[#8E8E93]">
|
||||
Сектор:{" "}
|
||||
<span className="text-white font-semibold">{groupSeats[0].sector}</span>
|
||||
, Ряд:{" "}
|
||||
<span className="text-white font-semibold">{groupSeats[0].row}</span>
|
||||
</p>
|
||||
<p className="text-[13px] text-[#8E8E93] text-right">
|
||||
Места:{" "}
|
||||
<span className="text-white font-semibold">
|
||||
{groupSeats.map((s) => s.number).join(", ")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="h-px bg-[#2C2C2E] my-4" />
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-[15px] font-bold text-white">Итого к оплате:</p>
|
||||
<div className="text-right">
|
||||
<p className="text-[20px] font-bold text-white leading-none">
|
||||
{finalTotal.toLocaleString("ru-RU")} ₽
|
||||
</p>
|
||||
<p className="text-[11px] text-[#8E8E93] mt-0.5">Включая сборы</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Promo code — visual stub */}
|
||||
<div className="bg-[#1C1C1E] rounded-2xl flex items-center gap-2 p-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={promoCode}
|
||||
onChange={(e) => setPromoCode(e.target.value)}
|
||||
placeholder="Ввести промокод"
|
||||
className="flex-1 bg-transparent text-[14px] text-white placeholder-[#8E8E93] px-3 py-2 outline-none"
|
||||
/>
|
||||
<button className="bg-[#2C2C2E] text-white text-[13px] font-semibold px-4 py-2.5 rounded-xl flex-shrink-0">
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bonuses — visual stub with functional toggle */}
|
||||
<div className="bg-[#1C1C1E] rounded-2xl p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<p className="text-[15px] font-semibold text-white">Бонусы</p>
|
||||
<p className="text-[13px] text-[#8E8E93]">
|
||||
У вас:{" "}
|
||||
<span className="text-white font-semibold">{BONUS_BALANCE} баллов</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setUseBonuses((v) => !v)}
|
||||
aria-checked={useBonuses}
|
||||
role="switch"
|
||||
className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ${
|
||||
useBonuses ? "bg-[#E32636]" : "bg-[#3A3A3C]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||
useBonuses ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<p className="text-[14px] text-white">Использовать баллы для скидки?</p>
|
||||
<p className="text-[12px] text-[#8E8E93]">
|
||||
Скидка: −{BONUS_BALANCE.toLocaleString("ru-RU")} ₽
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment method selector */}
|
||||
<div className="bg-[#1C1C1E] rounded-2xl px-4 pt-4 pb-1">
|
||||
<p className="text-[15px] font-semibold text-white mb-1">Метод оплаты</p>
|
||||
{PAYMENT_METHODS.map((method, idx) => (
|
||||
<div key={method.id}>
|
||||
{idx > 0 && <div className="h-px bg-[#2C2C2E]" />}
|
||||
<button
|
||||
onClick={() => setSelectedMethod(method.id)}
|
||||
className="flex items-center justify-between w-full py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PaymentMethodIcon id={method.id} />
|
||||
<span className="text-[15px] text-white">{method.label}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||
selectedMethod === method.id
|
||||
? "border-[#E32636] bg-[#E32636]"
|
||||
: "border-[#4B5563] bg-transparent"
|
||||
}`}
|
||||
>
|
||||
{selectedMethod === method.id && (
|
||||
<Check size={11} className="text-white" strokeWidth={3} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Security notice */}
|
||||
<div className="flex items-center gap-1.5 py-3 border-t border-[#2C2C2E]">
|
||||
<Shield size={12} className="text-[#8E8E93] flex-shrink-0" />
|
||||
<p className="text-[12px] text-[#8E8E93]">Все платежи защищены</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Sticky bottom bar ── */}
|
||||
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 w-full max-w-[390px] bg-[#1C1C1E] border-t border-[#2C2C2E] px-5 pt-4 pb-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] text-[#8E8E93]">Итого к оплате:</p>
|
||||
<p className="text-[22px] font-bold text-white leading-tight truncate">
|
||||
{finalTotal.toLocaleString("ru-RU")} ₽
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={isLoading}
|
||||
className="flex-shrink-0 flex items-center gap-2 bg-[#E32636] hover:bg-[#C41E2A] disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all text-white text-[16px] font-bold px-7 py-3.5 rounded-2xl"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={17} className="animate-spin" />
|
||||
Обработка…
|
||||
</>
|
||||
) : (
|
||||
"Оплатить"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ArrowLeft, Share2, MapPin, ShoppingCart, X } from "lucide-react";
|
||||
import OctagonSeatMap from "@/components/OctagonSeatMap";
|
||||
import { useCartStore } from "@/store/cartStore";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// ─── Mocked event data (real API call would go here) ─────────────────────────
|
||||
|
||||
@@ -38,6 +39,8 @@ export default function SeatsPage() {
|
||||
// The first selected seat for the summary line
|
||||
const firstSeat = cartSeats[0];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||
<div className="relative w-full max-w-[390px] flex flex-col min-h-screen bg-[#121212]">
|
||||
@@ -150,7 +153,10 @@ export default function SeatsPage() {
|
||||
{total.toLocaleString("ru-RU")} ₽
|
||||
</p>
|
||||
</div>
|
||||
<button className="bg-[#E32636] hover:bg-[#C41E2A] active:scale-95 transition-all text-white text-[15px] font-semibold px-6 py-3.5 rounded-2xl">
|
||||
<button
|
||||
onClick={() => router.push('/checkout')}
|
||||
className="bg-[#E32636] hover:bg-[#C41E2A] active:scale-95 transition-all text-white text-[15px] font-semibold px-6 py-3.5 rounded-2xl"
|
||||
>
|
||||
Перейти к оплате
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -149,11 +149,12 @@ export default function OctagonSeatMap() {
|
||||
setPendingId(seat.id);
|
||||
|
||||
try {
|
||||
await lockSeatApi(seat.id, userId);
|
||||
const { ticketId } = await lockSeatApi(seat.id, userId);
|
||||
|
||||
// ✅ 200 OK — seat is ours, add to cart
|
||||
// ✅ 200 OK — seat is ours, add to cart with the resolved ticketId
|
||||
addSeat({
|
||||
seatId: seat.id,
|
||||
ticketId,
|
||||
sector: seat.sector,
|
||||
row: seat.row,
|
||||
number: seat.number,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface CartSeat {
|
||||
export interface CartSeat {
|
||||
seatId: number;
|
||||
ticketId: number;
|
||||
sector: string;
|
||||
row: number;
|
||||
number: number;
|
||||
|
||||
@@ -43,6 +43,8 @@ services:
|
||||
container_name: frontend
|
||||
ports:
|
||||
- "3000:3000" # Пробиваем дыру напрямую для дебага
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://192.168.149.101:8000/api
|
||||
volumes:
|
||||
- ../frontend-client:/app
|
||||
- /app/node_modules # Изолируем зависимости контейнера от хоста
|
||||
|
||||
Reference in New Issue
Block a user