ESWF Shop — Frontend Implementation¶
Дата: 2026-02-17 Сайт: shop.eswf.dev Dev порт: 5175 Статус: ✅ реалізовано
Навіщо¶
Shop — четвертий сайт мульти-сайтової архітектури ESWF. Це Solution Catalog — маркетплейс модулів для DOP-системи. Дозволяє:
- переглядати каталог DOP-модулів (Fleet, Essentials, Logistic та ін.)
- фільтрувати за статусом і типом ціноутворення
- додавати модулі в кошик
- оформлювати замовлення (під авторизованим користувачем)
- переглядати історію замовлень
Продукти у магазині — це платні/безкоштовні DOP-модулі. Покупка → активація ліцензії на тенант.
Tech Stack¶
| Технологія | Версія | Роль |
|---|---|---|
| Next.js | 15.1 | Framework (SSR, App Router) |
| React | 19.0 | UI |
| TypeScript | 5.7 | Типізація |
| Tailwind CSS | 3.4 | Стилі (v3, як у Portal) |
| Zustand | 5.0 | State management (кошик + авторизація) |
| Axios | 1.13 | HTTP клієнт |
| @tabler/icons-react | 3.36 | Іконки (узгоджено з DOP) |
Чому Next.js (не Vite)? Узгодженість з Portal (теж Next.js). Але на відміну від Portal (static export), Shop використовує SSR — продукти можна рендерити на сервері, що дає SEO.
Чому Zustand?
Кошик потрібно зберігати між сторінками та між сесіями (localStorage). Zustand з persist middleware — той самий підхід що і в DOP (eswf-tabs, eswf-auth, eswf-ui).
Чому без React Query?
Shop — простий каталог без складного кешування. Базового useState + useEffect + axios достатньо. React Query додали б у DOP-стилі, якщо каталог виросте.
Структура файлів¶
frontend/shop/
├── app/ # Next.js App Router
│ ├── layout.tsx # Корневий layout: metadata, globals.css, bg-gray-950
│ ├── page.tsx # Головна: Hero + Features + Available Products
│ ├── not-found.tsx # 404 сторінка
│ ├── globals.css # Tailwind + Inter font + анімації
│ ├── catalog/
│ │ └── page.tsx # Каталог: фільтри + grid всіх продуктів
│ ├── products/
│ │ └── [slug]/
│ │ └── page.tsx # Деталь продукту + sticky buy panel
│ ├── cart/
│ │ └── page.tsx # Кошик + order summary
│ ├── checkout/
│ │ └── page.tsx # Оформлення замовлення (вимагає auth)
│ ├── orders/
│ │ └── page.tsx # Мої замовлення (вимагає auth)
│ └── login/
│ └── page.tsx # Форма входу (DOP credentials)
│
├── components/
│ ├── Header.tsx # Sticky header: logo, nav, cart badge, auth
│ ├── Footer.tsx # Footer: links, GitHub
│ ├── ProductCard.tsx # Картка продукту в grid
│ ├── ProductIcon.tsx # Tabler-іконка за кодом модуля (fleet→truck, etc.)
│ ├── StatusBadge.tsx # available / coming_soon / discontinued
│ ├── PriceBadge.tsx # free / paid / subscription + ціна
│ ├── FilterBar.tsx # Search input + status select + pricing select
│ └── CartButton.tsx # Іконка кошика з badge лічильника
│
├── lib/
│ ├── types.ts # TypeScript інтерфейси: Product, CartItem, ShopOrder, User...
│ └── api.ts # Axios клієнт + функції: getProducts, getProduct, createOrder...
│
├── store/
│ ├── cartStore.ts # Zustand: items, addItem, removeItem, clearCart, getTotal
│ └── authStore.ts # Zustand: user, accessToken, refreshToken, isAuthenticated
│
├── .env.development # BACKEND_URL=localhost:8000, DOP/News/Portal URLs
├── .env.production # api.eswf.dev, erp.eswf.dev, etc.
├── next.config.ts # rewrites /api/* → backend, images domains
├── tailwind.config.ts
├── tsconfig.json
├── postcss.config.mjs
└── package.json # "dev": "next dev -p 5175"
Сторінки¶
/ — Home¶
Файл: app/page.tsx (Server Component — SSR)
- Hero — заголовок, підзаголовок, CTA-кнопки "Browse Catalog" і "Try DOP Demo"
- Core Modules — 4 статичні картки: Essentials, Fleet, Logistic, Analytics
- Available Modules — сервер-рендерить перші 6 доступних продуктів з API (
getProducts({ status: "available" })) - Empty state — якщо бекенд недоступний під час білда, відображає заглушку
Фон: градієнт blue-950/30 → gray-950 → green-950/20 + radial-gradient.
/catalog — Каталог¶
Файл: app/catalog/page.tsx (Client Component — "use client")
FilterBar— пошук (debounce 300ms), фільтр заstatus, фільтр заpricing- Запит до
GET /api/v1/shop/products/?status=...&pricing=...&search=... - Grid
1 → 2 → 3 колонкиз анімацією по черзі (animationDelay: i * 40ms) - Loading spinner, empty state, error + retry
/products/[slug] — Деталь продукту¶
Файл: app/products/[slug]/page.tsx (Client Component)
- Лівий стовпець (2/3): назва, badges, short_description, повний description (+ ua), деталі (code, status, pricing, version, updated_at)
- Правий стовпець (1/3, sticky): ціна, кнопка "Add to Cart" / "Added" / "Coming Soon" / "Discontinued"
- При натисканні "Add to Cart" →
useCartStore.addItem()→ кнопка стає "Added to Cart" + з'являється "View Cart"
/cart — Кошик¶
Файл: app/cart/page.tsx (Client Component)
- Список
CartItemз іконкою, назвою, ціною, кнопкою видалення - Order Summary: перелік + total
- Кнопка "Proceed to Checkout" (якщо isAuthenticated) або "Sign In to Checkout"
- "Clear all" видаляє весь кошик
/checkout — Оформлення¶
Файл: app/checkout/page.tsx (Client Component)
- Guard: якщо не авторизований → екран "Sign In Required"
- Guard: якщо кошик порожній → редирект
- Показує order summary + дані акаунту
POST /api/v1/shop/orders/з{ items: [{ product, price }] }- При успіху →
clearCart()→ екран "Order Placed!" з іконкою ✓ - При помилці → inline error message
/orders — Мої замовлення¶
Файл: app/orders/page.tsx (Client Component)
- Guard: якщо не авторизований → екран "Sign In Required"
GET /api/v1/shop/orders/(відфільтровано поuserна бекенді)- Кожне замовлення: id, статус-badge (pending/paid/cancelled), дата, total, список items
- Empty state з посиланням на каталог
/login — Авторизація¶
Файл: app/login/page.tsx (Client Component)
- Форма: username + password (з toggle показу)
POST /api/v1/auth/login/→{ access, refresh }- Потім
GET /api/v1/auth/me/→ user object - Зберігає в
authStore(persisted:eswf-shop-auth) - Після входу — редирект на
?redirect=...або/ - Якщо вже авторизований — одразу редирект
State Management¶
cartStore (Zustand + persist → eswf-shop-cart)¶
items: CartItem[] // { product: Product, quantity: number }[]
addItem(product) // додає, якщо ще немає (дублі не допускаються)
removeItem(productId) // видаляє за id
clearCart() // очищує
getTotal() // сума всіх items як string "123.00"
getCount() // кількість позицій
hasItem(productId) // чи є продукт у кошику
authStore (Zustand + persist → eswf-shop-auth)¶
user: User | null
accessToken: string | null
refreshToken: string | null
isAuthenticated: boolean
login(user, access, refresh)
logout()
API Client¶
Файл: lib/api.ts
Базовий URL: /api/v1 (проксується Next.js rewrites → localhost:8000/api/v1)
Interceptor: зчитує accessToken з localStorage (eswf-shop-auth → state.accessToken) і додає Authorization: Bearer <token>.
| Функція | Endpoint | Auth |
|---|---|---|
getProducts(filters?) |
GET /shop/products/ |
Ні (public) |
getProduct(slug) |
GET /shop/products/{slug}/ |
Ні (public) |
createOrder(items) |
POST /shop/orders/ |
Так |
getOrders() |
GET /shop/orders/ |
Так |
login(username, password) |
POST /auth/login/ |
Ні |
getCurrentUser() |
GET /auth/me/ |
Так |
Компоненти¶
ProductCard¶
Показує:
- ProductIcon — Tabler icon підібрана за product.code (fleet→IconTruck, essentials→IconFileInvoice, etc.)
- Назва + версія
- StatusBadge + PriceBadge
- Кнопка залежно від стану:
- available + не в кошику → "Add to Cart" або "Get Free"
- available + у кошику → "In Cart" (disabled, зелений)
- coming_soon → "Coming Soon" (yellow, disabled)
- discontinued → "Discontinued" (gray, disabled)
FilterBar¶
- Search: debounce 300ms в parent через
useCallback + useEffect + setTimeout - Status select:
""/available/coming_soon/discontinued - Pricing select:
""/free/paid/subscription - Clear button: з'являється тільки коли є активні фільтри
Header¶
- Logo (SVG, той самий що в Portal) + "ESWF Shop"
- Nav links: Home, Catalog, My Orders (підсвічується active route)
CartButton— іконка з синім badge (лічильник items)- Якщо авторизований: ім'я + logout
- Якщо не авторизований: "Sign In" кнопка
Next.js Config¶
// next.config.ts
rewrites: [{ source: "/api/:path*", destination: "http://localhost:8000/api/:path*" }]
images.remotePatterns: [localhost:8000/media/**, https://api.eswf.dev/media/**]
Без output: "export" — SSR/SSG, щоб:
1. Головна сторінка рендерить продукти на сервері (SEO)
2. API rewrites працюють (недоступно в static export)
3. Dynamic routes /products/[slug] без generateStaticParams
Стилізація¶
Повністю узгоджено з Portal:
- bg-gray-950 — основний фон (темніше ніж Portal's bg-gray-900)
- border-white/10, bg-white/5 — скляні картки
- text-blue-400 / bg-blue-600 — акценти
- hover:-translate-y-1 + hover:shadow-[0_16px_48px_rgba(74,158,255,0.12)] — hover ефекти
- Анімації: fadeIn, slideFromTop, slideFromLeft (ті самі keyframes що в Portal)
- Шрифт: Inter (Google Fonts)
- Іконки: @tabler/icons-react (stroke=1.5)
Env Variables¶
.env.development¶
NEXT_PUBLIC_DOP_URL=http://localhost:5173
NEXT_PUBLIC_NEWS_URL=http://localhost:5174
NEXT_PUBLIC_PORTAL_URL=http://localhost:3000
BACKEND_URL=http://localhost:8000
.env.production¶
NEXT_PUBLIC_DOP_URL=https://erp.eswf.dev
NEXT_PUBLIC_NEWS_URL=https://news.eswf.dev
NEXT_PUBLIC_PORTAL_URL=https://eswf.dev
BACKEND_URL=https://api.eswf.dev
BACKEND_URL — серверна (для rewrites), без NEXT_PUBLIC_ префіксу.
NEXT_PUBLIC_* — доступні на клієнті.
Launcher¶
До launcher.js додано:
{
name: "Shop",
cwd: path.join(ROOT, "frontend", "shop"),
command: "npm.cmd",
args: ["run", "dev"],
url: "http://localhost:5175",
color: "\x1b[32m", // green
}
Бекенд (вже існував)¶
Shop використовує наявний Django app backend/shop/:
| Модель | Опис |
|---|---|
Product |
Модуль/продукт: name, slug, code, description, status, pricing, price |
ShopOrder |
Замовлення: user, tenant, status (pending/paid/cancelled), total |
ShopOrderItem |
Позиція замовлення: order, product, price |
License |
Ліцензія: tenant, product, is_active, expires_at |
Відкриті endpoints (AllowAny): GET /api/v1/shop/products/, GET /api/v1/shop/products/{slug}/
Захищені (IsAuthenticated): orders, licenses
Запуск¶
або через launcher:
Що ще можна додати¶
| Функція | Пріоритет |
|---|---|
| Refresh token логіка (auto-refresh при 401) | Середній |
| Пагінація каталогу (next/previous) | Середній |
| Категорії продуктів (потрібна модель на бекенді) | Низький |
| Сторінка "My Licenses" | Низький |
| Stripe/LiqPay інтеграція для оплати | Низький |
| Рейтинги і відгуки | Низький |
| i18n (en/ua) через next-i18next | Низький |
| ISR (Incremental Static Regeneration) для каталогу | Низький |