mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
hot fix #1
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
# DeepSeek – systémový prompt pro MyClub E‑shop podporu
|
||||
|
||||
Tento prompt je určen jako **system message** pro model `deepseek-chat` používaný v zákaznické podpoře MyClub e‑shopu.
|
||||
|
||||
---
|
||||
|
||||
## 1. Role a identita asistenta
|
||||
|
||||
Jsi **virtuální asistent zákaznické podpory** pro fotbalový klubový e‑shop **MyClub**.
|
||||
|
||||
- Prodáváme zejména **klubový merchandise** (dresy, trička, mikiny, šály, čepice, suvenýry), případně **permanentky, vstupenky a dárkové poukazy**.
|
||||
- E‑shop je úzce napojený na hlavní web klubu (MyClub CMS) a používá stejný uživatelský účet.
|
||||
- Zákazníci jsou převážně **čeští a slovenští fanoušci**, často méně technicky zdatní.
|
||||
|
||||
Mluv **především česky**, pokud není z kontextu zřejmé, že uživatel preferuje jiný jazyk (např. angličtinu). Pokud dotaz přijde slovensky, odpovídej slovensky, ale jednoduše a srozumitelně.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cíle a tón komunikace
|
||||
|
||||
- Buď **přátelský, věcný a stručný**.
|
||||
- Vysvětluj kroky **jednoduchým jazykem** (žádný technický žargon pro běžné uživatele).
|
||||
- Neslibuj nic, co z interního kontextu nevyplývá (termíny doručení, dostupnost, slevy apod.).
|
||||
- Pokud si nejsi jistý, napiš to otevřeně a navrhni kontakt na podporu (email/telefon), který dostaneš v kontextu.
|
||||
|
||||
Příklady vhodného tónu:
|
||||
- „Jasně, poradím. Teď prosím klikněte na…“
|
||||
- „Děkuju za trpělivost, podívám se na to.“
|
||||
- „Tomuhle bohužel přesně nerozumím, ale napíšu, jak to ověřit u podpory.“
|
||||
|
||||
---
|
||||
|
||||
## 3. Kontext, který dostaneš od backendu
|
||||
|
||||
Backend ti bude posílat strukturovaný kontext v JSONu v systémové nebo "tool" části zprávy. Nepředpokládej nic, co v kontextu chybí.
|
||||
|
||||
Příklady kontextu (může se lišit):
|
||||
|
||||
```json
|
||||
{
|
||||
"store": {
|
||||
"store_name": "{{STORE_NAME}}",
|
||||
"club_name": "{{CLUB_NAME}}",
|
||||
"primary_language": "cs",
|
||||
"supported_languages": ["cs", "sk", "en"],
|
||||
"support_email": "{{SUPPORT_EMAIL}}",
|
||||
"support_phone": "{{SUPPORT_PHONE}}",
|
||||
"returns_policy_url": "{{RETURNS_URL}}",
|
||||
"terms_url": "{{TERMS_URL}}"
|
||||
},
|
||||
"user": {
|
||||
"is_logged_in": true,
|
||||
"name": "{{USER_NAME}}",
|
||||
"email": "{{USER_EMAIL}}"
|
||||
},
|
||||
"orders": [
|
||||
{
|
||||
"order_id": "{{ORDER_ID}}",
|
||||
"created_at": "{{ISO_DATETIME}}",
|
||||
"status": "paid | awaiting_payment | shipped | delivered | cancelled | refunded",
|
||||
"total_amount": 1290,
|
||||
"currency": "CZK",
|
||||
"shipping_method": "packeta | courier | pickup",
|
||||
"shipping_status": "label_created | handed_to_carrier | in_transit | ready_for_pickup | delivered",
|
||||
"tracking_url": "{{TRACKING_URL}}"
|
||||
}
|
||||
],
|
||||
"shipping": {
|
||||
"packeta_enabled": true,
|
||||
"estimated_delivery_days": "2-4",
|
||||
"regions": ["CZ", "SK"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
V odpovědích **vždy respektuj tento kontext**. Pokud nějaká informace v kontextu není, otevřeně přiznej, že ji neznáš.
|
||||
|
||||
---
|
||||
|
||||
## 4. Práce s objednávkami a dopravou
|
||||
|
||||
### 4.1 Stav objednávky
|
||||
|
||||
- Stav objednávky vysvětluj lidsky (česky), např.:
|
||||
- `awaiting_payment` → „Čekáme na dokončení platby.“
|
||||
- `paid` → „Objednávka je zaplacená a připravuje se k odeslání.“
|
||||
- `shipped` / `in_transit` → „Balík je na cestě k vám.“
|
||||
- `ready_for_pickup` → „Balík je připravený k vyzvednutí na výdejním místě.“
|
||||
- `delivered` → „Objednávka byla doručena.“
|
||||
- `cancelled` / `refunded` → vysvětlit, že objednávka byla zrušená / peníze vrácené.
|
||||
|
||||
Pokud máš v kontextu `tracking_url`, nabídni uživateli, ať si stav ověří přímo tam.
|
||||
|
||||
### 4.2 Packeta (Zásilkovna)
|
||||
|
||||
- Vysvětluj princip jednoduše:
|
||||
- Zákazník si při objednávce vybral **výdejní místo** nebo **Z‑BOX**.
|
||||
- Po odeslání obdrží SMS/email od Packety s kódem pro vyzvednutí.
|
||||
- Nepopisuj vnitřní technické detaily (API, XML, Packeta.Widget…), pouze uživatelské kroky.
|
||||
- Pokud chybí informace o zásilce, řekni, že nemáš detailní data a ať zákazník použije tracking link nebo kontaktuje podporu.
|
||||
|
||||
---
|
||||
|
||||
## 5. Platby (Stripe)
|
||||
|
||||
- Platební bránu popisuj jako **"bezpečnou online platbu kartou"**.
|
||||
- Nikdy netvrď, že máš přístup k číslům karet – nemáš a mít nebudeš.
|
||||
- Pokud je stav platby `awaiting_payment` nebo `payment_failed`:
|
||||
- Navrhni zkontrolovat údaje karty, případně zkusit jinou kartu nebo prohlížeč.
|
||||
- Pokud problém přetrvá, doporuč kontaktovat podporu (email/telefon z kontextu).
|
||||
- Pokud je platba `paid` a objednávka ještě není odeslaná, uklidni uživatele, že je vše v pořádku a objednávka se připravuje.
|
||||
|
||||
---
|
||||
|
||||
## 6. Typické scénáře a jak odpovídat
|
||||
|
||||
### 6.1 „Kdy mi přijde balík?“
|
||||
|
||||
1. Podívej se, zda máš v kontextu poslední objednávku a její `status` + případně `shipping_status` a `estimated_delivery_days`.
|
||||
2. Odpověz konkrétně:
|
||||
- „Vaše poslední objednávka **#1234** je aktuálně ve stavu **předáno dopravci**. Obvykle dorazí za **2–4 pracovní dny**.“
|
||||
3. Pokud nic z toho nemáš, napiš:
|
||||
- „Bohužel tady nevidím detailní informace k vaší objednávce. Prosím ověřte stav v e‑shopu po přihlášení, nebo napište na {{SUPPORT_EMAIL}}.“
|
||||
|
||||
### 6.2 „Chci změnit výdejní místo / adresu“
|
||||
|
||||
- Vysvětli, že změna po odeslání je omezená a může ji řešit jen lidská podpora.
|
||||
- Navrhni konkrétní postup:
|
||||
- „Přepište prosím číslo objednávky a novou adresu do emailu na {{SUPPORT_EMAIL}}. Kolegové zkusí změnu provést, pokud to dopravce ještě umožňuje.“
|
||||
|
||||
### 6.3 „Nepřišel mi potvrzovací email“
|
||||
|
||||
- Doporuč kroky:
|
||||
- Zkontrolovat spam/promo složky.
|
||||
- Ověřit, že adresa v účtu je správná.
|
||||
- Případně kontaktovat podporu s číslem objednávky.
|
||||
|
||||
---
|
||||
|
||||
## 7. Limity asistenta a eskalace
|
||||
|
||||
- Nikdy **nevymýšlej konkrétní čísla objednávek, částky nebo přesné termíny**, pokud je nemáš v kontextu.
|
||||
- Při nejistotě nebo chybějících datech **vždy nabídni kontakt na podporu**:
|
||||
- „Toto bohužel z chatu nevidím. Prosím napište na {{SUPPORT_EMAIL}} a přidejte číslo objednávky. Kolegové do toho mohou nahlédnout v systému.“
|
||||
|
||||
- Pokud se uživatel ptá na:
|
||||
- právní záležitosti (reklamace, odstoupení od smlouvy),
|
||||
- reklamaci zboží,
|
||||
- vrácení peněz,
|
||||
- nebo cokoliv, co vyžaduje manuální schválení,
|
||||
|
||||
vysvětli obecný princip (např. „máte 14 dní na vrácení při online nákupu“, pokud je to v kontextu nebo běžné v EU), ale nakonec přesměruj na oficiální podmínky (`terms_url`, `returns_policy_url`) a na lidskou podporu.
|
||||
|
||||
---
|
||||
|
||||
## 8. Styl odpovědí
|
||||
|
||||
- Krátké odstavce, maximálně 2–3 věty, pokud uživatel nechce detail.
|
||||
- Používej **tučné zvýraznění** pro důležité informace (číslo objednávky, stav, důležitá akce).
|
||||
- Pokud vysvětluješ postup, napiš ho v očíslovaných krocích.
|
||||
|
||||
Příklad:
|
||||
|
||||
> "Abychom to vyřešili, prosím postupujte takto:\n\n1. Přihlaste se do e‑shopu.\n2. V menu zvolte **Moje objednávky**.\n3. Ověřte stav objednávky **#1234**.\n4. Pokud je tam stále uvedeno `čeká na platbu`, zkuste prosím platbu znovu nebo nás kontaktujte na {{SUPPORT_EMAIL}}."
|
||||
|
||||
---
|
||||
|
||||
## 9. Co dělat při chybějícím nebo nekonzistentním kontextu
|
||||
|
||||
- Pokud backend pošle prázdný nebo neúplný kontext:
|
||||
- Nesnaž se hádat, jaký je stav objednávky nebo zásilky.
|
||||
- Vysvětli, že z chatu nemáš přímý přístup k internímu systému a uživatel musí použít svůj účet nebo kontaktovat podporu.
|
||||
|
||||
Příklad:
|
||||
|
||||
> "Bohužel tady v chatu nevidím detailní data o vaší objednávce. Prosím přihlaste se do e‑shopu a podívejte se do sekce **Moje objednávky**, nebo napište na {{SUPPORT_EMAIL}} s číslem objednávky."
|
||||
|
||||
---
|
||||
|
||||
## 10. Shrnutí
|
||||
|
||||
- Vždy se opírej o data z kontextu.
|
||||
- Odpovídej česky/slovensky, jednoduše a přátelsky.
|
||||
- Neimprovizuj v číslech, částkách ani termínech.
|
||||
- Pokud si nejsi jistý, uveď to a přesměruj uživatele na email/telefon podpory nebo sekci **Moje objednávky** v e‑shopu.
|
||||
@@ -0,0 +1,348 @@
|
||||
# MyClub E‑shop – Implementační plán
|
||||
|
||||
> Cíl: přidat volitelný MyClub e‑shop jako samostatný (ale integrovaný) stack – vlastní backend + frontend, napojený na stávající MyClub uživatele a admin, s podporou Stripe plateb, Packeta (Zásilkovna) dopravy a DeepSeek AI podpory. Vše řízené přes `.env` a Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektura a základní principy
|
||||
|
||||
- **Samostatný e‑shop stack**
|
||||
- `eshop-backend` – Go/Gin služba, vlastní API prefix (např. `/api/v1/eshop`), sdílí DB a auth s hlavním backendem.
|
||||
- `eshop-frontend` – React/TSX (Chakra UI + ClubThemeContext), vlastní doména/subdoména (např. `shop.klub.cz`).
|
||||
- V docker-compose jako dva nové servisy, spouštěné jen pokud `ESHOP_ENABLED=true`.
|
||||
|
||||
- **Sdílené jádro s MyClub**
|
||||
- Sdílená databáze (PostgreSQL) – nové tabulky s prefixem `eshop_`.
|
||||
- Sdílený users / role systém (admin/editor/uživatel) – žádné duplikování účtů.
|
||||
- Admin správa e‑shopu v existujícím `/admin` (nové sekce „E‑shop“ / „Produkty“, „Objednávky“, „Nastavení e‑shopu“).
|
||||
|
||||
- **UI/UX**
|
||||
- Světlý + tmavý režim, jednoduché, mobil‑first.
|
||||
- Barvy z klubového theme (z inicializačního Setupu) – e‑shop z něj dědí, ale může mít drobné odchylky (např. CTA barvy).
|
||||
|
||||
- **Bezpečnost**
|
||||
- Žádné ukládání karetních údajů (vše přes Stripe).
|
||||
- Sdílená JWT/session autentizace mezi doménami (cookies `Domain=.example.cz`).
|
||||
- CORS a CSRF nastavené tak, aby podporovaly hlavní web + e‑shop subdoménu.
|
||||
|
||||
---
|
||||
|
||||
## 2. Konfigurace přes .env
|
||||
|
||||
### 2.1 Klíčové flagy
|
||||
|
||||
- `ESHOP_ENABLED=true|false`
|
||||
- `true` → Docker Compose spustí i e‑shop backend/frontend, setup přidá e‑shop doménu a nastavení.
|
||||
- `false` → běží pouze hlavní MyClub (aktuální chování).
|
||||
|
||||
- Domény & URL
|
||||
- `FRONTEND_URL=https://www.klub.cz` (stávající)
|
||||
- `API_URL=https://api.klub.cz` (stávající)
|
||||
- `ESHOP_FRONTEND_URL=https://shop.klub.cz`
|
||||
- `ESHOP_API_URL=https://eshop-api.klub.cz` *nebo* `https://api.klub.cz/eshop` (dle zvoleného nasazení)
|
||||
|
||||
- Porty pro lokální vývoj
|
||||
- `ESHOP_FRONTEND_PORT=3100`
|
||||
- `ESHOP_BACKEND_PORT=8082`
|
||||
|
||||
### 2.2 Stripe
|
||||
|
||||
- `STRIPE_SECRET_KEY=sk_live_...`
|
||||
- `STRIPE_PUBLISHABLE_KEY=pk_live_...`
|
||||
- `STRIPE_WEBHOOK_SECRET=whsec_...`
|
||||
- `STRIPE_CURRENCY=CZK`
|
||||
- `STRIPE_SUCCESS_URL=${ESHOP_FRONTEND_URL}/objednavka/uspech`
|
||||
- `STRIPE_CANCEL_URL=${ESHOP_FRONTEND_URL}/kosik`
|
||||
|
||||
### 2.3 DeepSeek (AI podpora)
|
||||
|
||||
- `DEEPSEEK_ENABLED=true|false`
|
||||
- `DEEPSEEK_API_KEY=...`
|
||||
- `DEEPSEEK_MODEL=deepseek-chat`
|
||||
- `DEEPSEEK_API_BASE=https://api.deepseek.com`
|
||||
|
||||
### 2.4 Packeta / Zásilkovna
|
||||
|
||||
- `PACKETA_API_PASSWORD=...` (hlavní API heslo pro REST/XML)
|
||||
- `PACKETA_WIDGET_API_KEY=...` (16‑znakový klíč pro widget)
|
||||
- `PACKETA_ESHP_NAME=MyClubEshop`
|
||||
- `PACKETA_ENV=production|test` (logika: v test režimu umožnit vytvářet zásilky jen v interním módu)
|
||||
|
||||
---
|
||||
|
||||
## 3. Docker Compose a Makefile
|
||||
|
||||
### 3.1 Nové servisy v docker-compose
|
||||
|
||||
- `eshop-backend`
|
||||
- build: `./eshop/backend`
|
||||
- env: sdílí DB proměnné s hlavním backendem (`DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`), plus `ESHOP_*`, `STRIPE_*`, `PACKETA_*`, `DEEPSEEK_*`.
|
||||
- port: `${ESHOP_BACKEND_PORT}:8080` (interní port 8080 jako u hlavního backendu).
|
||||
|
||||
- `eshop-frontend`
|
||||
- build: `./eshop/frontend`
|
||||
- env: `ESHOP_API_URL`, `ESHOP_FRONTEND_URL`, `NODE_ENV`, atd.
|
||||
- port: `${ESHOP_FRONTEND_PORT}:3000`.
|
||||
|
||||
### 3.2 Podmíněné spouštění
|
||||
|
||||
- V `Makefile` upravit cíle (např. `docker-up`):
|
||||
- Shell podmínka: pokud `ESHOP_ENABLED=true`, použít `docker compose -f docker-compose.yml -f docker-compose.eshop.yml up`; jinak jen základní `docker-compose.yml`.
|
||||
- Alternativně: jeden compose soubor, ale cíle `docker-up` vs `docker-up-with-eshop` (flag řízený z Makefile).
|
||||
|
||||
---
|
||||
|
||||
## 4. Databázový model e‑shopu
|
||||
|
||||
Použít GORM modely v novém modulu, např. `internal/models/eshop/...`.
|
||||
|
||||
- **Product** (`eshop_products`)
|
||||
- `id`, `slug`, `name`, `description_html`, `short_description`, `price`, `currency`, `vat_rate`, `active`, `stock_mode` (finite/unlimited), `default_image_url`, `gallery_urls`, `tags`, `metadata`.
|
||||
|
||||
- **ProductCategory** (`eshop_product_categories`)
|
||||
- hierarchie kategorií pro filtraci (dresy, fan merchandise, permanentky...).
|
||||
|
||||
- **ProductVariant** (`eshop_product_variants`)
|
||||
- velikosti/barvy, vlastní `sku`, `stock_qty`, `barcode` (volitelně), `image_url`.
|
||||
|
||||
- **Cart / CartItem** (`eshop_carts`, `eshop_cart_items`)
|
||||
- vázané na `user_id` nebo anonymní session (`session_token`).
|
||||
|
||||
- **Order / OrderItem** (`eshop_orders`, `eshop_order_items`)
|
||||
- stav: `new`, `awaiting_payment`, `paid`, `cancelled`, `refunded`, `ready_to_ship`, `shipped`, `delivered`.
|
||||
- vazba na Packeta/Carrier: `shipping_method`, `packeta_point_id`/`address_id`, `tracking_number`.
|
||||
|
||||
- **Payment** (`eshop_payments`)
|
||||
- Stripe `payment_intent_id`, `status`, `amount`, `currency`, `refund_status`, `raw_payload` (JSON pro debug, GDPR‑safe).
|
||||
|
||||
- **ShippingLabel** (`eshop_shipping_labels`)
|
||||
- Packeta `packet_id`, `label_url` / binární uložiště, historie statusů.
|
||||
|
||||
- **EshopSettings** (`eshop_settings`)
|
||||
- `default_currency`, `supported_currencies`, `default_country`, `shipping_options`, `terms_url`, `returns_policy_url`, `support_email`, `support_phone`, atd.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend – API a integrace s MyClub
|
||||
|
||||
### 5.1 Struktura backendu
|
||||
|
||||
- Nový modul/složka: `internal/controllers/eshop`, `internal/services/eshop`, `internal/routes/eshop_routes.go`.
|
||||
- Registrace rout v `routes.go`, ale oddělený API prefix `/api/v1/eshop`.
|
||||
- Middleware:
|
||||
- `JWTOptional` pro veřejné e‑shop stránky (checkout, košík, historie objednávek pro přihlášené).
|
||||
- Role guardy pro admin API (jen admin/editor s právem e‑shop).
|
||||
|
||||
### 5.2 Veřejné API (zkrácený přehled)
|
||||
|
||||
- Produkty
|
||||
- `GET /api/v1/eshop/products` – list s filtrem (kategorie, dostupnost, fulltext).
|
||||
- `GET /api/v1/eshop/products/:slug` – detail.
|
||||
|
||||
- Košík
|
||||
- `GET /api/v1/eshop/cart` – ze session nebo user_id.
|
||||
- `POST /api/v1/eshop/cart/items` – přidání položky.
|
||||
- `PATCH /api/v1/eshop/cart/items/:id` – změna množství/varianty.
|
||||
- `DELETE /api/v1/eshop/cart/items/:id`.
|
||||
|
||||
- Checkout & platby (Stripe)
|
||||
- `POST /api/v1/eshop/checkout` – vytvoření draft objednávky + PaymentIntent (viz kapitola Stripe).
|
||||
- `GET /api/v1/eshop/orders/:id` – detail objednávky pro daného uživatele.
|
||||
|
||||
- Doprava (Packeta)
|
||||
- `GET /api/v1/eshop/shipping/options` – dostupné způsoby dopravy.
|
||||
- `GET /api/v1/eshop/shipping/packeta/widget-config` – vrací `PACKETA_WIDGET_API_KEY` a další volby pro widget.
|
||||
|
||||
- DeepSeek podpora
|
||||
- `POST /api/v1/eshop/support/chat/stream` – SSE/WebSocket proxy na DeepSeek (viz níže).
|
||||
|
||||
### 5.3 Admin API
|
||||
|
||||
- Produkty & kategorie
|
||||
- CRUD endpoints pro produkty, varianty a kategorie.
|
||||
|
||||
- Objednávky
|
||||
- List/filtering podle stavu, data, zákazníka.
|
||||
- Akce: změna stavu, přidání poznámky, rušení/refund.
|
||||
|
||||
- Nastavení e‑shopu
|
||||
- Stripe, Packeta, texty emailů a obchodní podmínky.
|
||||
|
||||
---
|
||||
|
||||
## 6. Autentizace a napojení účtů
|
||||
|
||||
- Použít existující `users` tabulku a JWT/session middleware.
|
||||
- Upravit nastavení cookies tak, aby `Domain=.example.cz` → token platí pro hlavní web i `shop.example.cz`.
|
||||
- CORS policy: přidat e‑shop frontend origin mezi povolené.
|
||||
- V e‑shop frontendu používat stejný mechanismus přihlášení (sdílené služby/axios klient nebo lehké proxy).
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend – veřejný e‑shop (TSX)
|
||||
|
||||
### 7.1 Technologie
|
||||
|
||||
- React 18 + TypeScript + Chakra UI, opět využít `ClubThemeContext` (stejný design systém jako hlavní frontend).
|
||||
- Routing: `react-router` (oddělené entrypointy v rámci `eshop/frontend`).
|
||||
|
||||
### 7.2 Stránky
|
||||
|
||||
- Domovská stránka e‑shopu – výběr kategorií, bestsellery, CTA.
|
||||
- Katalog produktů (grid, filtry, stránkování).
|
||||
- Detail produktu – fotogalerie, varianty (velikost/barva), doporučené produkty.
|
||||
- Košík – přehled položek, úprava množství, mezisoučet.
|
||||
- Checkout – kroky:
|
||||
1. Fakturační údaje
|
||||
2. Doprava (Packeta widget / jiní dopravci)
|
||||
3. Shrnutí + platba (Stripe)
|
||||
- Potvrzení objednávky.
|
||||
- Můj účet → historie objednávek (pro přihlášené MyClub uživatele).
|
||||
|
||||
### 7.3 UX detaily
|
||||
|
||||
- Plná responzivita (mobil, tablet, desktop).
|
||||
- Dark mode připnutý na globální přepínač (stejný jako u hlavního webu).
|
||||
- Konsistentní komponenty (buttony, formuláře, toasty) s adminem.
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend – admin část e‑shopu
|
||||
|
||||
- V existujícím admin SPA přidat navigační kategorii „E‑shop“:
|
||||
- **Produkty** – CRUD, nahrávání obrázků, varianty.
|
||||
- **Objednávky** – tabulka, detail, timeline stavu, link na Packeta štítky.
|
||||
- **Přehledy** – dashboard s grafy (tržby/den, počet objednávek, úspěšnost plateb, rozdělení dopravců).
|
||||
- **Nastavení e‑shopu** – měna, Stripe, Packeta, email šablony.
|
||||
|
||||
- Využít existující komponenty (tabulky, filtry, grafy) používané v jiných admin modulech.
|
||||
|
||||
---
|
||||
|
||||
## 9. Stripe – platební tok
|
||||
|
||||
1. **Checkout** (frontend)
|
||||
- Uživatel vyplní adresu, zvolí dopravu a souhlasí s podmínkami.
|
||||
- Frontend volá `POST /api/v1/eshop/checkout` s obsahem košíku a dopravou.
|
||||
|
||||
2. **Backend**
|
||||
- Zkontroluje ceny/propočítá dopravu.
|
||||
- Vytvoří **Stripe PaymentIntent** (`amount`, `currency`, `metadata` s `order_id`).
|
||||
- V DB uloží draft objednávku se stavem `awaiting_payment`.
|
||||
- Vrátí `client_secret` pro frontend.
|
||||
|
||||
3. **Frontend**
|
||||
- Použije Stripe Elements / `stripe-js` k potvrzení platby.
|
||||
|
||||
4. **Webhook** (backend)
|
||||
- Endpoint např. `/api/v1/eshop/stripe/webhook` (mimo auth, chráněn `STRIPE_WEBHOOK_SECRET`).
|
||||
- Reaguje na eventy `payment_intent.succeeded`, `payment_intent.payment_failed`, `charge.refunded`.
|
||||
- Upraví stav objednávky (`paid`, `cancelled`, `refunded`).
|
||||
|
||||
5. **Email & Packeta**
|
||||
- Po `paid`:
|
||||
- poslat email zákazníkovi + adminovi (použít existující `pkg/email`).
|
||||
- příprava vytvoření Packeta zásilky (viz níže).
|
||||
|
||||
---
|
||||
|
||||
## 10. Packeta / Zásilkovna – doprava (✔ hotovo - MVP)
|
||||
|
||||
Vychází z `eshop/packeta.md`.
|
||||
|
||||
### 10.1 Frontend – widget
|
||||
|
||||
- Na stránce checkoutu:
|
||||
- ✔ načíst `<script src="https://widget.packeta.com/v6/www/js/library.js"></script>` (lazy load přes `useEffect`).
|
||||
- ✔ na výběr způsobu dopravy „Výdejní místo Packeta“ otevřít widget přes `Packeta.Widget.pick(...)`.
|
||||
- ✔ výsledek (objekt `point`) uložit do checkout state a poslat na backend (`addressId`, `name`, `country`, atd.).
|
||||
|
||||
### 10.2 Backend – vytvoření zásilky
|
||||
|
||||
- Po úspěšné platbě (nebo po manuálním potvrzení objednávky):
|
||||
- ✔ Vytvořit XML payload `createPacket` (viz `packeta.md`) – `PacketaService.CreatePacket`.
|
||||
- ✔ Poslat POST na `https://www.zasilkovna.cz/api/rest`.
|
||||
- ✔ Uložit `packetId` a případně generovat PDF štítek přes `packetLabelPdf` – `PacketaService.GetPacketLabel`.
|
||||
- ✔ Endpoint `/api/v1/eshop/shipping/labels/:packet_id` pro stažení PDF.
|
||||
|
||||
- ⬜ Pravidelný cron (např. background job v Go):
|
||||
- `packetStatus` / `packetTracking` → aktualizovat stav objednávky.
|
||||
|
||||
- ⬜ V adminu zobrazit log posledních stavů, link na stažení štítku.
|
||||
|
||||
---
|
||||
|
||||
## 11. DeepSeek AI – zákaznická podpora
|
||||
|
||||
- Backend endpoint: `POST /api/v1/eshop/support/chat/stream`
|
||||
- Auth: volitelně JWT (výhoda – známe uživatele/jazyk, můžeme dotáhnout jeho objednávky).
|
||||
- Vstup: zprávy konverzace + kontext (parametry e‑shopu, aktuální objednávky atd.).
|
||||
- Backend zavolá DeepSeek API s modelem `deepseek-chat` a `stream=true`.
|
||||
- Přeposílá stream (SSE/WebSocket) na frontend.
|
||||
|
||||
- System prompt uložit v repu jako Markdown (soubor vedle tohoto plánu) – **`DeepSeekSupportPrompt.md`** (vytvořený v tomto kroku).
|
||||
- AI odpovídá česky, stručně, s možností přepnout do angličtiny, pokud je dotaz v AJ.
|
||||
|
||||
---
|
||||
|
||||
## 12. Emaily a notifikace
|
||||
|
||||
- Využít existující emailový systém (SMTP konfigurace už je v MyClub).
|
||||
- Nové šablony:
|
||||
- Potvrzení objednávky (CZ, s rekapitulací a odkazem na detail).
|
||||
- Informace o platbě (zaplaceno/neúspěch).
|
||||
- Informace o odeslání (včetně Packeta tracking linku).
|
||||
- Informace o doručení/uzavření.
|
||||
|
||||
---
|
||||
|
||||
## 13. Dvoukrokový Setup
|
||||
|
||||
1. **Hlavní MyClub Setup** (stávající `/setup`)
|
||||
- Klubové údaje, barvy, logo, doména.
|
||||
|
||||
2. **E‑shop Setup** (nový krok/route, aktivní jen pokud `ESHOP_ENABLED=true`)
|
||||
- URL e‑shopu (`ESHOP_FRONTEND_URL`, `ESHOP_API_URL` – předvyplnit z hlavního URL + subdomény).
|
||||
- Default měna, země, jazyk.
|
||||
- Základní texty (obchodní podmínky URL, kontakt na podporu).
|
||||
- Volba povolených dopravců (Packeta on/off, typy).
|
||||
|
||||
- E‑shop Setup přebírá klubové informace (název, logo, barvy) jako výchozí.
|
||||
|
||||
---
|
||||
|
||||
## 14. Bezpečnost a performance
|
||||
|
||||
- Middleware pro rate‑limit u e‑shop API (checkout, platby, podpora chat).
|
||||
- Sanitizace všech vstupů (hlavně HTML popisy produktů a AI odpovědi – DOMPurify na frontendu, validace na backendu).
|
||||
- Logování citlivých dat minimalizovat (žádné celé adresy/karty v debug logu).
|
||||
- Použít existující httpclient/circuitbreaker vrstvy z MyClub pro Packeta a DeepSeek.
|
||||
|
||||
---
|
||||
|
||||
## 15. Fáze implementace
|
||||
|
||||
1. **Infrastruktura**
|
||||
- Nové .env proměnné, docker-compose.eshop.yml, Makefile logika.
|
||||
|
||||
2. **DB a backend jádro**
|
||||
- Migrace tabulek `eshop_*`, základní GORM modely.
|
||||
- Produkty, košík, objednávky (bez plateb/dopravy).
|
||||
|
||||
3. **E‑shop frontend (MVP)**
|
||||
- Katalog, detail, košík, jednoduchý checkout bez Stripe (např. platba převodem jako první krok).
|
||||
|
||||
4. **Stripe integrace**
|
||||
- PaymentIntent, webhooky, stav objednávek.
|
||||
|
||||
5. **Packeta integrace**
|
||||
- Widget na checkoutu, backend `createPacket`, štítky, cron pro statusy.
|
||||
|
||||
6. **Admin e‑shop modul**
|
||||
- Produkty, objednávky, přehledy.
|
||||
|
||||
7. **DeepSeek podpora**
|
||||
- Backend proxy + frontend chat widget, ladění promptu.
|
||||
|
||||
8. **Hardening a testy**
|
||||
- E2E testy checkoutu, zátěžové testy API, bezpečnostní audit.
|
||||
Binary file not shown.
@@ -0,0 +1,375 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
eshop_controllers "fotbal-club/internal/controllers/eshop"
|
||||
"fotbal-club/internal/middleware"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/database"
|
||||
"fotbal-club/pkg/email"
|
||||
"fotbal-club/pkg/logger"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load shared configuration
|
||||
config.LoadConfig()
|
||||
|
||||
// Logger level based on DEBUG flag
|
||||
if config.AppConfig != nil && config.AppConfig.Debug {
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
log.Println("[eshop] Logger level set to DEBUG")
|
||||
} else {
|
||||
logger.SetLevel(logger.LevelInfo)
|
||||
}
|
||||
|
||||
// Gin mode
|
||||
if config.AppConfig != nil && config.AppConfig.AppEnv == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// Initialize shared database connection
|
||||
dbInstance, err := database.InitDB()
|
||||
if err != nil {
|
||||
log.Fatalf("[eshop] Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Optional migrations for eshop-specific tables only (will be added later)
|
||||
runMigrations, _ := strconv.ParseBool(os.Getenv("RUN_MIGRATIONS"))
|
||||
if runMigrations {
|
||||
log.Println("[eshop] RUN_MIGRATIONS is true, but no eshop-specific migrations are defined yet")
|
||||
}
|
||||
|
||||
// Initialize Gin router with a similar hardened stack as the main backend
|
||||
reporter := services.NewErrorReporter(config.AppConfig)
|
||||
r := gin.New()
|
||||
r.Use(middleware.CustomRecoveryWithReporter(reporter))
|
||||
if err := r.SetTrustedProxies(nil); err != nil {
|
||||
log.Printf("[eshop] Trusted proxies setup error: %v", err)
|
||||
}
|
||||
|
||||
r.Use(middleware.RequestID())
|
||||
r.Use(middleware.RequestLogger())
|
||||
r.Use(middleware.ErrorStatusReporter(reporter))
|
||||
r.Use(middleware.SanitizeHeaders())
|
||||
r.Use(middleware.DBContext())
|
||||
r.Use(middleware.RequestSizeLimit(2 * 1024 * 1024))
|
||||
r.Use(middleware.ValidateContentType())
|
||||
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
r.Use(middleware.SecurityHeaders())
|
||||
|
||||
// E-shop CORS – reuse AllowedOrigins from shared config
|
||||
r.Use(func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
allowed := false
|
||||
|
||||
// Explicit exact-origin allow list
|
||||
for _, ao := range config.AppConfig.AllowedOrigins {
|
||||
if ao == origin {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard support
|
||||
if !allowed {
|
||||
for _, ao := range config.AppConfig.AllowedOrigins {
|
||||
if ao == "*" && origin != "" {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Relaxed rule for non-production local dev
|
||||
if !allowed && origin != "" && config.AppConfig.AppEnv != "production" {
|
||||
if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") ||
|
||||
strings.HasPrefix(origin, "https://localhost:") || strings.HasPrefix(origin, "https://127.0.0.1:") {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
|
||||
if allowed && origin != "" {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Vary", "Origin")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Expose-Headers", "X-Request-ID")
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Cache-Control, X-Requested-With, X-Session-Token, X-Admin-Token, X-Dev-Admin, X-CSRF-Token")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Attach DB-backed email service for future order emails (not used yet)
|
||||
_ = email.NewEmailService(config.AppConfig, dbInstance)
|
||||
|
||||
// API group for E-shop
|
||||
api := r.Group("/api/v1/eshop")
|
||||
|
||||
// Healthcheck
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"service": "myclub-eshop-backend",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
api.HEAD("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"service": "myclub-eshop-backend",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
// Admin Setup & Management Endpoints
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.JWTAuth(dbInstance))
|
||||
admin.Use(middleware.RoleAuth("admin"))
|
||||
{
|
||||
// GET /api/v1/eshop/admin/settings
|
||||
admin.GET("/settings", func(c *gin.Context) {
|
||||
var settings models.EshopSettings
|
||||
if err := dbInstance.First(&settings).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Return defaults if not found
|
||||
c.JSON(http.StatusOK, models.EshopSettings{
|
||||
DefaultCurrency: "CZK",
|
||||
DefaultCountry: "CZ",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
})
|
||||
|
||||
// PUT /api/v1/eshop/admin/settings
|
||||
admin.PUT("/settings", func(c *gin.Context) {
|
||||
var body models.EshopSettings
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
var settings models.EshopSettings
|
||||
if err := dbInstance.First(&settings).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new
|
||||
settings = body
|
||||
// Ensure ID is 0 or handled by GORM for creation
|
||||
settings.ID = 0
|
||||
if err := dbInstance.Create(&settings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update existing
|
||||
settings.DefaultCurrency = body.DefaultCurrency
|
||||
settings.SupportedCurrencies = body.SupportedCurrencies
|
||||
settings.DefaultCountry = body.DefaultCountry
|
||||
settings.ShippingOptionsJSON = body.ShippingOptionsJSON
|
||||
settings.TermsURL = body.TermsURL
|
||||
settings.ReturnsPolicyURL = body.ReturnsPolicyURL
|
||||
settings.SupportEmail = body.SupportEmail
|
||||
settings.SupportPhone = body.SupportPhone
|
||||
|
||||
if err := dbInstance.Save(&settings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
})
|
||||
|
||||
// GET /api/v1/eshop/admin/club-info
|
||||
// Returns main Club Settings to pre-fill E-shop Setup
|
||||
admin.GET("/club-info", func(c *gin.Context) {
|
||||
var clubSettings models.Settings
|
||||
if err := dbInstance.First(&clubSettings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load club settings"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"club_name": clubSettings.ClubName,
|
||||
"club_logo_url": clubSettings.ClubLogoURL,
|
||||
"contact_email": clubSettings.ContactEmail,
|
||||
"contact_phone": clubSettings.ContactPhone,
|
||||
"contact_country": clubSettings.ContactCountry,
|
||||
"primary_color": clubSettings.PrimaryColor,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Shipping controller & endpoints
|
||||
shippingCtrl := eshop_controllers.NewShippingController(config.AppConfig)
|
||||
|
||||
// Admin Shipping Actions
|
||||
admin.POST("/orders/:id/create-packet", shippingCtrl.CreatePacket)
|
||||
|
||||
// Public Shipping endpoints
|
||||
shipping := api.Group("/shipping")
|
||||
{
|
||||
shipping.GET("/packeta-widget-config", shippingCtrl.GetPacketaWidgetConfig)
|
||||
shipping.GET("/labels/:packet_id", shippingCtrl.DownloadLabel)
|
||||
}
|
||||
|
||||
// Support chat routes
|
||||
support := api.Group("/support")
|
||||
support.Use(middleware.JWTOptional(dbInstance), middleware.RateLimit(60, time.Minute))
|
||||
RegisterSupportRoutes(support, dbInstance)
|
||||
|
||||
checkoutCtrl := eshop_controllers.NewCheckoutController(dbInstance, config.AppConfig)
|
||||
api.POST("/checkout", middleware.JWTOptional(dbInstance), middleware.RateLimit(10, time.Minute), checkoutCtrl.Checkout)
|
||||
|
||||
// Payment webhooks (Revolut, Stripe)
|
||||
payments := api.Group("/payments")
|
||||
{
|
||||
payments.POST("/revolut/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.RevolutWebhook)
|
||||
payments.POST("/stripe/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.StripeWebhook)
|
||||
}
|
||||
|
||||
// Background jobs
|
||||
go func() {
|
||||
// Update packet statuses every hour
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if config.AppConfig.PacketaAPIPassword != "" {
|
||||
shippingCtrl.UpdatePacketStatuses(dbInstance)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Public catalog endpoints (auth optional)
|
||||
productsPub := api.Group("/products")
|
||||
productsPub.Use(middleware.JWTOptional(dbInstance))
|
||||
{
|
||||
// GET /api/v1/eshop/products
|
||||
productsPub.GET("", func(c *gin.Context) {
|
||||
var items []models.EshopProduct
|
||||
q := dbInstance.
|
||||
Preload("Category").
|
||||
Preload("Variants").
|
||||
Where("active = ?", true).
|
||||
Order("created_at DESC, id DESC")
|
||||
if err := q.Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load products"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
})
|
||||
|
||||
// GET /api/v1/eshop/products/:slug
|
||||
productsPub.GET(":slug", func(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
if strings.TrimSpace(slug) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing product slug"})
|
||||
return
|
||||
}
|
||||
var item models.EshopProduct
|
||||
if err := dbInstance.
|
||||
Preload("Category").
|
||||
Preload("Variants").
|
||||
Where("slug = ? AND active = ?", slug, true).
|
||||
First(&item).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load product"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
// Cart controller
|
||||
cartCtrl := eshop_controllers.NewCartController(dbInstance, config.AppConfig)
|
||||
|
||||
// Public cart endpoints (auth optional, tied to user or session)
|
||||
cart := api.Group("/cart")
|
||||
cart.Use(middleware.JWTOptional(dbInstance))
|
||||
{
|
||||
cart.GET("", cartCtrl.GetCart)
|
||||
cart.POST("/items", cartCtrl.AddItem)
|
||||
cart.PATCH("/items/:id", cartCtrl.UpdateItem)
|
||||
cart.DELETE("/items/:id", cartCtrl.RemoveItem)
|
||||
}
|
||||
|
||||
// GET /api/v1/eshop/orders/:id
|
||||
api.GET("/orders/:id", middleware.JWTOptional(dbInstance), checkoutCtrl.GetOrder)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if strings.TrimSpace(port) == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: r,
|
||||
ReadTimeout: config.AppConfig.ReadTimeout,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: config.AppConfig.WriteTimeout,
|
||||
IdleTimeout: config.AppConfig.IdleTimeout,
|
||||
}
|
||||
|
||||
// Graceful shutdown and DB close
|
||||
sqlDB, err := dbInstance.DB()
|
||||
if err != nil {
|
||||
log.Printf("[eshop] Error getting database connection: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("[eshop] Server starting on port %s\n", port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[eshop] Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("[eshop] Shutdown signal received, shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("[eshop] Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
if sqlDB != nil {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
log.Printf("[eshop] Error closing database connection: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// systemPrompt is hardcoded here to ensure it is available in the binary without complex build steps
|
||||
const systemPrompt = `# DeepSeek – systémový prompt pro MyClub E‑shop podporu
|
||||
|
||||
Tento prompt je určen jako **system message** pro model ` + "`deepseek-chat`" + ` používaný v zákaznické podpoře MyClub e‑shopu.
|
||||
|
||||
---
|
||||
|
||||
## 1. Role a identita asistenta
|
||||
|
||||
Jsi **virtuální asistent zákaznické podpory** pro fotbalový klubový e‑shop **MyClub**.
|
||||
|
||||
- Prodáváme zejména **klubový merchandise** (dresy, trička, mikiny, šály, čepice, suvenýry), případně **permanentky, vstupenky a dárkové poukazy**.
|
||||
- E‑shop je úzce napojený na hlavní web klubu (MyClub CMS) a používá stejný uživatelský účet.
|
||||
- Zákazníci jsou převážně **čeští a slovenští fanoušci**, často méně technicky zdatní.
|
||||
|
||||
Mluv **především česky**, pokud není z kontextu zřejmé, že uživatel preferuje jiný jazyk (např. angličtinu). Pokud dotaz přijde slovensky, odpovídej slovensky, ale jednoduše a srozumitelně.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cíle a tón komunikace
|
||||
|
||||
- Buď **přátelský, věcný a stručný**.
|
||||
- Vysvětluj kroky **jednoduchým jazykem** (žádný technický žargon pro běžné uživatele).
|
||||
- Neslibuj nic, co z interního kontextu nevyplývá (termíny doručení, dostupnost, slevy apod.).
|
||||
- Pokud si nejsi jistý, napiš to otevřeně a navrhni kontakt na podporu (email/telefon), který dostaneš v kontextu.
|
||||
|
||||
Příklady vhodného tónu:
|
||||
- „Jasně, poradím. Teď prosím klikněte na…“
|
||||
- „Děkuju za trpělivost, podívám se na to.“
|
||||
- „Tomuhle bohužel přesně nerozumím, ale napíšu, jak to ověřit u podpory.“
|
||||
|
||||
---
|
||||
|
||||
## 3. Kontext, který dostaneš od backendu
|
||||
|
||||
Backend ti bude posílat strukturovaný kontext v JSONu v systémové nebo "tool" části zprávy. Nepředpokládej nic, co v kontextu chybí.
|
||||
|
||||
Příklady kontextu (může se lišit):
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"store": {
|
||||
"store_name": "{{STORE_NAME}}",
|
||||
"club_name": "{{CLUB_NAME}}",
|
||||
"primary_language": "cs",
|
||||
"supported_languages": ["cs", "sk", "en"],
|
||||
"support_email": "{{SUPPORT_EMAIL}}",
|
||||
"support_phone": "{{SUPPORT_PHONE}}",
|
||||
"returns_policy_url": "{{RETURNS_URL}}",
|
||||
"terms_url": "{{TERMS_URL}}"
|
||||
},
|
||||
"user": {
|
||||
"is_logged_in": true,
|
||||
"name": "{{USER_NAME}}",
|
||||
"email": "{{USER_EMAIL}}"
|
||||
},
|
||||
"orders": [
|
||||
{
|
||||
"order_id": "{{ORDER_ID}}",
|
||||
"created_at": "{{ISO_DATETIME}}",
|
||||
"status": "paid | awaiting_payment | shipped | delivered | cancelled | refunded",
|
||||
"total_amount": 1290,
|
||||
"currency": "CZK",
|
||||
"shipping_method": "packeta | courier | pickup",
|
||||
"shipping_status": "label_created | handed_to_carrier | in_transit | ready_for_pickup | delivered",
|
||||
"tracking_url": "{{TRACKING_URL}}"
|
||||
}
|
||||
],
|
||||
"shipping": {
|
||||
"packeta_enabled": true,
|
||||
"estimated_delivery_days": "2-4",
|
||||
"regions": ["CZ", "SK"]
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
V odpovědích **vždy respektuj tento kontext**. Pokud nějaká informace v kontextu není, otevřeně přiznej, že ji neznáš.
|
||||
|
||||
---
|
||||
|
||||
## 4. Práce s objednávkami a dopravou
|
||||
|
||||
### 4.1 Stav objednávky
|
||||
|
||||
- Stav objednávky vysvětluj lidsky (česky), např.:
|
||||
- ` + "`awaiting_payment`" + ` → „Čekáme na dokončení platby.“
|
||||
- ` + "`paid`" + ` → „Objednávka je zaplacená a připravuje se k odeslání.“
|
||||
- ` + "`shipped` / `in_transit`" + ` → „Balík je na cestě k vám.“
|
||||
- ` + "`ready_for_pickup`" + ` → „Balík je připravený k vyzvednutí na výdejním místě.“
|
||||
- ` + "`delivered`" + ` → „Objednávka byla doručena.“
|
||||
- ` + "`cancelled` / `refunded`" + ` → vysvětlit, že objednávka byla zrušená / peníze vrácené.
|
||||
|
||||
Pokud máš v kontextu ` + "`tracking_url`" + `, nabídni uživateli, ať si stav ověří přímo tam.
|
||||
|
||||
### 4.2 Packeta (Zásilkovna)
|
||||
|
||||
- Vysvětluj princip jednoduše:
|
||||
- Zákazník si při objednávce vybral **výdejní místo** nebo **Z‑BOX**.
|
||||
- Po odeslání obdrží SMS/email od Packety s kódem pro vyzvednutí.
|
||||
- Nepopisuj vnitřní technické detaily (API, XML, Packeta.Widget…), pouze uživatelské kroky.
|
||||
- Pokud chybí informace o zásilce, řekni, že nemáš detailní data a ať zákazník použije tracking link nebo kontaktuje podporu.
|
||||
|
||||
---
|
||||
|
||||
## 5. Platby (Stripe)
|
||||
|
||||
- Platební bránu popisuj jako **"bezpečnou online platbu kartou"**.
|
||||
- Nikdy netvrď, že máš přístup k číslům karet – nemáš a mít nebudeš.
|
||||
- Pokud je stav platby ` + "`awaiting_payment`" + ` nebo ` + "`payment_failed`" + `:
|
||||
- Navrhni zkontrolovat údaje karty, případně zkusit jinou kartu nebo prohlížeč.
|
||||
- Pokud problém přetrvá, doporuč kontaktovat podporu (email/telefon z kontextu).
|
||||
- Pokud je platba ` + "`paid`" + ` a objednávka ještě není odeslaná, uklidni uživatele, že je vše v pořádku a objednávka se připravuje.
|
||||
|
||||
---
|
||||
|
||||
## 6. Typické scénáře a jak odpovídat
|
||||
|
||||
### 6.1 „Kdy mi přijde balík?“
|
||||
|
||||
1. Podívej se, zda máš v kontextu poslední objednávku a její ` + "`status`" + ` + případně ` + "`shipping_status`" + ` a ` + "`estimated_delivery_days`" + `.
|
||||
2. Odpověz konkrétně:
|
||||
- „Vaše poslední objednávka **#1234** je aktuálně ve stavu **předáno dopravci**. Obvykle dorazí za **2–4 pracovní dny**.“
|
||||
3. Pokud nic z toho nemáš, napiš:
|
||||
- „Bohužel tady nevidím detailní informace k vaší objednávce. Prosím ověřte stav v e‑shopu po přihlášení, nebo napište na {{SUPPORT_EMAIL}}.“
|
||||
|
||||
### 6.2 „Chci změnit výdejní místo / adresu“
|
||||
|
||||
- Vysvětli, že změna po odeslání je omezená a může ji řešit jen lidská podpora.
|
||||
- Navrhni konkrétní postup:
|
||||
- „Přepište prosím číslo objednávky a novou adresu do emailu na {{SUPPORT_EMAIL}}. Kolegové zkusí změnu provést, pokud to dopravce ještě umožňuje.“
|
||||
|
||||
### 6.3 „Nepřišel mi potvrzovací email“
|
||||
|
||||
- Doporuč kroky:
|
||||
- Zkontrolovat spam/promo složky.
|
||||
- Ověřit, že adresa v účtu je správná.
|
||||
- Případně kontaktovat podporu s číslem objednávky.
|
||||
|
||||
---
|
||||
|
||||
## 7. Limity asistenta a eskalace
|
||||
|
||||
- Nikdy **nevymýšlej konkrétní čísla objednávek, částky nebo přesné termíny**, pokud je nemáš v kontextu.
|
||||
- Při nejistotě nebo chybějících datech **vždy nabídni kontakt na podporu**:
|
||||
- „Toto bohužel z chatu nevidím. Prosím napište na {{SUPPORT_EMAIL}} a přidejte číslo objednávky. Kolegové do toho mohou nahlédnout v systému.“
|
||||
|
||||
- Pokud se uživatel ptá na:
|
||||
- právní záležitosti (reklamace, odstoupení od smlouvy),
|
||||
- reklamaci zboží,
|
||||
- vrácení peněz,
|
||||
- nebo cokoliv, co vyžaduje manuální schválení,
|
||||
|
||||
vysvětli obecný princip (např. „máte 14 dní na vrácení při online nákupu“, pokud je to v kontextu nebo běžné v EU), ale nakonec přesměruj na oficiální podmínky (` + "`terms_url`" + `, ` + "`returns_policy_url`" + `) a na lidskou podporu.
|
||||
|
||||
---
|
||||
|
||||
## 8. Styl odpovědí
|
||||
|
||||
- Krátké odstavce, maximálně 2–3 věty, pokud uživatel nechce detail.
|
||||
- Používej **tučné zvýraznění** pro důležité informace (číslo objednávky, stav, důležitá akce).
|
||||
- Pokud vysvětluješ postup, napiš ho v očíslovaných krocích.
|
||||
|
||||
Příklad:
|
||||
|
||||
> "Abychom to vyřešili, prosím postupujte takto:\n\n1. Přihlaste se do e‑shopu.\n2. V menu zvolte **Moje objednávky**.\n3. Ověřte stav objednávky **#1234**.\n4. Pokud je tam stále uvedeno ` + "`čeká na platbu`" + `, zkuste prosím platbu znovu nebo nás kontaktujte na {{SUPPORT_EMAIL}}."
|
||||
|
||||
---
|
||||
|
||||
## 9. Co dělat při chybějícím nebo nekonzistentním kontextu
|
||||
|
||||
- Pokud backend pošle prázdný nebo neúplný kontext:
|
||||
- Nesnaž se hádat, jaký je stav objednávky nebo zásilky.
|
||||
- Vysvětli, že z chatu nemáš přímý přístup k internímu systému a uživatel musí použít svůj účet nebo kontaktovat podporu.
|
||||
|
||||
Příklad:
|
||||
|
||||
> "Bohužel tady v chatu nevidím detailní data o vaší objednávce. Prosím přihlaste se do e‑shopu a podívejte se do sekce **Moje objednávky**, nebo napište na {{SUPPORT_EMAIL}} s číslem objednávky."
|
||||
|
||||
---
|
||||
|
||||
## 10. Shrnutí
|
||||
|
||||
- Vždy se opírej o data z kontextu.
|
||||
- Odpovídej česky/slovensky, jednoduše a přátelsky.
|
||||
- Neimprovizuj v číslech, částkách ani termínech.
|
||||
- Pokud si nejsi jistý, uveď to a přesměruj uživatele na email/telefon podpory nebo sekci **Moje objednávky** v e‑shopu.
|
||||
`
|
||||
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message"`
|
||||
History []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"history"`
|
||||
}
|
||||
|
||||
type DeepSeekRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// DeepSeekStreamResponse represents the SSE chunk structure from DeepSeek API
|
||||
type DeepSeekStreamResponse struct {
|
||||
ID string `json:"id"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// RegisterSupportRoutes adds support endpoints to the router
|
||||
func RegisterSupportRoutes(g *gin.RouterGroup, db *gorm.DB) {
|
||||
g.POST("/chat/stream", func(c *gin.Context) {
|
||||
SupportHandler(c, db)
|
||||
})
|
||||
}
|
||||
|
||||
func SupportHandler(c *gin.Context, db *gorm.DB) {
|
||||
// 1. Check if DeepSeek API key is configured
|
||||
if config.AppConfig.DeepSeekAPIKey == "" {
|
||||
log.Println("[eshop] DeepSeek API key not configured")
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Chat support unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var req ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Build Context
|
||||
ctxData := buildContext(c, db)
|
||||
ctxJSON, _ := json.Marshal(ctxData)
|
||||
|
||||
// 3. Prepare messages
|
||||
messages := []Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: systemPrompt + "\n\nCONTEXT:\n" + string(ctxJSON),
|
||||
},
|
||||
}
|
||||
|
||||
// Add history (limit to last 10 messages to save tokens)
|
||||
limit := 10
|
||||
if len(req.History) > limit {
|
||||
req.History = req.History[len(req.History)-limit:]
|
||||
}
|
||||
for _, h := range req.History {
|
||||
messages = append(messages, Message{
|
||||
Role: h.Role,
|
||||
Content: h.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Add current user message
|
||||
messages = append(messages, Message{
|
||||
Role: "user",
|
||||
Content: req.Message,
|
||||
})
|
||||
|
||||
// 4. Call DeepSeek API
|
||||
deepSeekReq := DeepSeekRequest{
|
||||
Model: "deepseek-chat",
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
reqBody, _ := json.Marshal(deepSeekReq)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second, // Long timeout for streaming
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(config.AppConfig.DeepSeekBaseURL, "/") + "/chat/completions"
|
||||
proxyReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
log.Printf("[eshop] Failed to create DeepSeek request: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
proxyReq.Header.Set("Content-Type", "application/json")
|
||||
proxyReq.Header.Set("Authorization", "Bearer "+config.AppConfig.DeepSeekAPIKey)
|
||||
|
||||
resp, err := client.Do(proxyReq)
|
||||
if err != nil {
|
||||
log.Printf("[eshop] DeepSeek API call failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Support service unavailable"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("[eshop] DeepSeek API error %d: %s", resp.StatusCode, string(body))
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Support service error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Stream response
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Transfer-Encoding", "chunked")
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("[eshop] Error reading stream: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Forward the SSE line directly
|
||||
c.Writer.Write([]byte(line + "\n\n"))
|
||||
c.Writer.Flush()
|
||||
|
||||
if line == "data: [DONE]" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildContext(c *gin.Context, db *gorm.DB) map[string]interface{} {
|
||||
// Default store info
|
||||
storeInfo := map[string]interface{}{
|
||||
"store_name": config.AppConfig.PacketaEshopName,
|
||||
"club_name": "MyClub", // Could be dynamic if multi-tenant
|
||||
"primary_language": "cs",
|
||||
"supported_languages": []string{"cs", "sk", "en"},
|
||||
"support_email": config.AppConfig.ContactEmail,
|
||||
"support_phone": "", // Add if available
|
||||
"returns_policy_url": config.AppConfig.FrontendBaseURL + "/obchodni-podminky", // Fallback
|
||||
"terms_url": config.AppConfig.FrontendBaseURL + "/obchodni-podminky",
|
||||
}
|
||||
|
||||
// Try to load settings from DB
|
||||
var settings models.EshopSettings
|
||||
if err := db.First(&settings).Error; err == nil {
|
||||
if settings.SupportEmail != "" {
|
||||
storeInfo["support_email"] = settings.SupportEmail
|
||||
}
|
||||
if settings.SupportPhone != "" {
|
||||
storeInfo["support_phone"] = settings.SupportPhone
|
||||
}
|
||||
if settings.TermsURL != "" {
|
||||
storeInfo["terms_url"] = settings.TermsURL
|
||||
}
|
||||
if settings.ReturnsPolicyURL != "" {
|
||||
storeInfo["returns_policy_url"] = settings.ReturnsPolicyURL
|
||||
}
|
||||
}
|
||||
|
||||
userInfo := map[string]interface{}{
|
||||
"is_logged_in": false,
|
||||
}
|
||||
|
||||
var ordersInfo []map[string]interface{}
|
||||
|
||||
// Check authentication (similar to getCartContext logic)
|
||||
// We check standard context keys populated by auth middleware or manual token check
|
||||
var userID uint
|
||||
loggedIn := false
|
||||
|
||||
if v, ok := c.Get("userID"); ok {
|
||||
switch id := v.(type) {
|
||||
case uint:
|
||||
userID = id
|
||||
loggedIn = true
|
||||
case int:
|
||||
userID = uint(id)
|
||||
loggedIn = true
|
||||
case float64:
|
||||
userID = uint(id)
|
||||
loggedIn = true
|
||||
}
|
||||
}
|
||||
|
||||
if loggedIn {
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err == nil {
|
||||
userInfo["is_logged_in"] = true
|
||||
userInfo["name"] = strings.TrimSpace(user.FirstName + " " + user.LastName)
|
||||
userInfo["email"] = user.Email
|
||||
userInfo["id"] = user.ID
|
||||
}
|
||||
|
||||
// Load last 3 orders
|
||||
var orders []models.EshopOrder
|
||||
if err := db.Where("user_id = ?", userID).
|
||||
Order("created_at DESC").
|
||||
Limit(3).
|
||||
Preload("Labels"). // For tracking info
|
||||
Find(&orders).Error; err == nil {
|
||||
|
||||
for _, o := range orders {
|
||||
trackingURL := ""
|
||||
shippingStatus := "processing"
|
||||
|
||||
if len(o.Labels) > 0 {
|
||||
// Use the latest label
|
||||
lbl := o.Labels[len(o.Labels)-1]
|
||||
trackingURL = lbl.LabelURL // Or specific tracking URL if constructed
|
||||
shippingStatus = lbl.Status
|
||||
}
|
||||
|
||||
ordersInfo = append(ordersInfo, map[string]interface{}{
|
||||
"order_id": o.OrderNumber, // Use user-friendly number
|
||||
"created_at": o.CreatedAt.Format(time.RFC3339),
|
||||
"status": o.Status,
|
||||
"total_amount": float64(o.TotalAmountCents) / 100.0,
|
||||
"currency": o.Currency,
|
||||
"shipping_method": o.ShippingMethod,
|
||||
"shipping_status": shippingStatus,
|
||||
"tracking_url": trackingURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"store": storeInfo,
|
||||
"user": userInfo,
|
||||
"orders": ordersInfo,
|
||||
"shipping": map[string]interface{}{
|
||||
"packeta_enabled": true, // Assume enabled for now
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
REACT_APP_API_URL=/api/v1/eshop
|
||||
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_TYooMQauvdEDq54NiTphI7jx
|
||||
@@ -0,0 +1,2 @@
|
||||
REACT_APP_API_URL=/api/v1/eshop
|
||||
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
@@ -0,0 +1,45 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm i -g npm@10 \
|
||||
&& npm ci --prefer-offline --no-audit --no-fund || npm install --no-audit --no-fund
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build app
|
||||
ENV NODE_ENV=production
|
||||
ENV GENERATE_SOURCEMAP=false
|
||||
ENV CI=true
|
||||
ENV TSC_COMPILE_ON_ERROR=true
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx static assets
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Ensure nginx cache directory is writable for non-root user
|
||||
RUN mkdir -p /var/cache/nginx/client_temp \
|
||||
&& chown -R nginx:nginx /var/cache/nginx
|
||||
|
||||
# Switch to non-root user
|
||||
USER nginx
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"files": {
|
||||
"main.js": "/static/js/main.590e0c36.js",
|
||||
"index.html": "/index.html",
|
||||
"main.590e0c36.js.map": "/static/js/main.590e0c36.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/js/main.590e0c36.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="cs"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#1a365d"/><title>MyClub E-shop</title><script defer="defer" src="/static/js/main.590e0c36.js"></script></head><body><noscript>Pro zobrazení e-shopu je potřeba povolit JavaScript.</noscript><div id="root"></div></body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* use-sync-external-store-shim.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @remix-run/router v1.23.1
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* React Router DOM v6.30.2
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* React Router v6.30.2
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Static assets & SPA routing
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to eshop-backend
|
||||
location /api/ {
|
||||
proxy_pass http://eshop-backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 20m;
|
||||
}
|
||||
}
|
||||
Generated
+16688
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "myclub-eshop-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-router-dom": "^5.3.3"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#1a365d" />
|
||||
<title>MyClub E-shop</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Pro zobrazení e-shopu je potřeba povolit JavaScript.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link as RouterLink } from 'react-router-dom';
|
||||
import { Box, Container, Flex, HStack, Heading, Link, Spacer, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getClubInfo, ClubInfo } from './services/eshopApi';
|
||||
import ShopHomePage from './pages/ShopHomePage';
|
||||
import ProductDetailPage from './pages/ProductDetailPage';
|
||||
import CartPage from './pages/CartPage';
|
||||
import CheckoutPage from './pages/CheckoutPage';
|
||||
import AdminDashboardPage from './pages/AdminDashboardPage';
|
||||
import SetupPage from './pages/SetupPage';
|
||||
import OrderSuccessPage from './pages/OrderSuccessPage';
|
||||
import SupportChatWidget from './components/SupportChatWidget';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { data: clubInfo } = useQuery<ClubInfo>(['eshop-club-info'], getClubInfo);
|
||||
const shopName = clubInfo?.club_name ? `${clubInfo.club_name} E-shop` : 'MyClub E-shop';
|
||||
const headerBg = clubInfo?.primary_color || 'gray.900';
|
||||
|
||||
return (
|
||||
<Flex direction="column" minH="100vh">
|
||||
<Box as="header" borderBottomWidth="1px" py={3} bg={headerBg} color="white">
|
||||
<Container maxW="6xl">
|
||||
<HStack spacing={4} align="center">
|
||||
<Heading size="md">
|
||||
<Link as={RouterLink} to="/" _hover={{ textDecoration: 'none', opacity: 0.9 }}>
|
||||
{shopName}
|
||||
</Link>
|
||||
</Heading>
|
||||
<HStack as="nav" spacing={4} fontSize="sm">
|
||||
<Link as={RouterLink} to="/" _hover={{ opacity: 0.85 }}>Produkty</Link>
|
||||
<Link as={RouterLink} to="/cart" _hover={{ opacity: 0.85 }}>Košík</Link>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<Button as={RouterLink} to="/admin" size="sm" variant="outline" colorScheme="whiteAlpha">
|
||||
E-shop admin
|
||||
</Button>
|
||||
</HStack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Box as="main" flex="1" py={6} bg="gray.50">
|
||||
<Container maxW="6xl">
|
||||
<Routes>
|
||||
<Route path="/" element={<ShopHomePage />} />
|
||||
<Route path="/produkt/:slug" element={<ProductDetailPage />} />
|
||||
<Route path="/cart" element={<CartPage />} />
|
||||
<Route path="/checkout" element={<CheckoutPage />} />
|
||||
<Route path="/objednavka/dekujeme" element={<OrderSuccessPage />} />
|
||||
<Route path="/admin" element={<AdminDashboardPage />} />
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
</Routes>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<SupportChatWidget />
|
||||
|
||||
<Box as="footer" borderTopWidth="1px" py={4} bg="white" fontSize="sm" color="gray.500">
|
||||
<Container maxW="6xl">
|
||||
© {new Date().getFullYear()} MyClub E-shop.
|
||||
</Container>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,272 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Input,
|
||||
Text,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
Flex,
|
||||
Spinner,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaComments, FaPaperPlane, FaTimes, FaRobot, FaUser } from 'react-icons/fa';
|
||||
import { getOrCreateEshopSessionToken } from '../services/eshopApi';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
const RobotIcon: React.ComponentType<any> = FaRobot as any;
|
||||
const TimesIcon: React.ComponentType<any> = FaTimes as any;
|
||||
const PaperPlaneIcon: React.ComponentType<any> = FaPaperPlane as any;
|
||||
const CommentsIcon: React.ComponentType<any> = FaComments as any;
|
||||
|
||||
const SupportChatWidget: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const chatBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isOpen]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMessage = input.trim();
|
||||
setInput('');
|
||||
setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/eshop/support/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-Token': getOrCreateEshopSessionToken(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
history: messages.filter(m => m.role !== 'system').slice(-10),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
if (!response.body) return;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let assistantMessage = '';
|
||||
|
||||
// Add placeholder for assistant message
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
|
||||
try {
|
||||
// DeepSeek streaming format: {"id":..., "choices":[{"delta":{"content":"..."}}]}
|
||||
// BUT our backend just forwards raw content chunks or JSON?
|
||||
// Let's check backend implementation: it forwards JSON chunks from DeepSeek
|
||||
const json = JSON.parse(data);
|
||||
const content = json.choices?.[0]?.delta?.content || '';
|
||||
if (content) {
|
||||
assistantMessage += content;
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev];
|
||||
newMessages[newMessages.length - 1].content = assistantMessage;
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// If backend sends raw text or error
|
||||
console.error('Error parsing stream chunk', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Omlouvám se, ale došlo k chybě při komunikaci s asistentem. Zkuste to prosím později nebo napište na klubový e‑mail.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="fixed" bottom="20px" right="20px" zIndex={1000}>
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<Box
|
||||
bg={bg}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="xl"
|
||||
w="350px"
|
||||
h="500px"
|
||||
mb={4}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<Flex
|
||||
bg="blue.600"
|
||||
color="white"
|
||||
p={3}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<HStack>
|
||||
<RobotIcon />
|
||||
<Text fontWeight="bold">MyClub Asistent</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
aria-label="Close chat"
|
||||
icon={<TimesIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* Messages */}
|
||||
<VStack
|
||||
flex={1}
|
||||
overflowY="auto"
|
||||
p={3}
|
||||
spacing={3}
|
||||
align="stretch"
|
||||
bg={chatBg}
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<Box textAlign="center" color="gray.500" mt={10}>
|
||||
<Box as={RobotIcon} fontSize="40px" mb={2} />
|
||||
<Text fontSize="sm">
|
||||
Ahoj! Jsem virtuální asistent MyClub e‑shopu. <br />
|
||||
Mohu poradit s produkty, dostupností, dopravou nebo stavem objednávky.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{messages.map((msg, idx) => {
|
||||
const isUser = msg.role === 'user';
|
||||
const isSystem = msg.role === 'system';
|
||||
const bubbleBg = isUser ? 'blue.500' : isSystem ? 'red.500' : 'white';
|
||||
const bubbleColor = isUser || isSystem ? 'white' : 'black';
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={idx}
|
||||
justify={isUser ? 'flex-end' : 'flex-start'}
|
||||
>
|
||||
<Box
|
||||
maxW="80%"
|
||||
bg={bubbleBg}
|
||||
color={bubbleColor}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderTopRightRadius={isUser ? 0 : 'lg'}
|
||||
borderTopLeftRadius={!isUser ? 0 : 'lg'}
|
||||
>
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{msg.content}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{isLoading && (
|
||||
<Flex justify="flex-start">
|
||||
<Box bg="white" px={3} py={2} borderRadius="lg" boxShadow="sm">
|
||||
<Spinner size="xs" color="gray.500" />
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</VStack>
|
||||
|
||||
{/* Input */}
|
||||
<Box p={3} borderTop="1px solid" borderColor={borderColor}>
|
||||
<HStack>
|
||||
<Input
|
||||
placeholder="Napište zprávu..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Send"
|
||||
icon={<PaperPlaneIcon />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
borderRadius="full"
|
||||
onClick={handleSend}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{!isOpen && (
|
||||
<Button
|
||||
leftIcon={<CommentsIcon />}
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
boxShadow="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Podpora
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportChatWidget;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { theme } from './theme';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Box, Heading, Text, Alert, AlertIcon, AlertTitle, AlertDescription } from '@chakra-ui/react';
|
||||
|
||||
const AdminDashboardPage: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>E-shop administrace</Heading>
|
||||
<Alert status="info" borderRadius="md" mb={4}>
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>Samostatná administrace e-shopu</AlertTitle>
|
||||
<AlertDescription fontSize="sm">
|
||||
Toto je základní rozhraní pro budoucí správu produktů, objednávek a dopravy.
|
||||
V této fázi doporučujeme produkty zakládat přes hlavní MyClub administraci nebo přímo v databázi.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
<Text color="gray.600">
|
||||
V dalších krocích zde přibudou přehledy objednávek, správa produktů, nastavení Stripe a Packeta integrace
|
||||
a nástroje pro zákaznickou podporu (DeepSeek AI chat).
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardPage;
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCart, updateCartItem, removeCartItem, EshopCart } from '../services/eshopApi';
|
||||
import { Box, Heading, Text, Table, Thead, Tbody, Tr, Th, Td, IconButton, HStack, NumberInput, NumberInputField, Button, useToast } from '@chakra-ui/react';
|
||||
import { FiTrash2 } from 'react-icons/fi';
|
||||
|
||||
const TrashIcon: React.ComponentType<any> = FiTrash2 as any;
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const CartPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError } = useQuery<EshopCart>(['eshop-cart'], getCart);
|
||||
|
||||
const handleQuantityChange = async (id: number, value: number) => {
|
||||
try {
|
||||
await updateCartItem(id, value);
|
||||
queryClient.invalidateQueries(['eshop-cart']);
|
||||
} catch {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se upravit košík.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (id: number) => {
|
||||
try {
|
||||
await removeCartItem(id);
|
||||
queryClient.invalidateQueries(['eshop-cart']);
|
||||
} catch {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se odebrat položku.' });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Text>Načítání košíku…</Text>;
|
||||
if (isError || !data) return <Text>Košík nelze načíst.</Text>;
|
||||
|
||||
const total = data.items?.reduce((sum, it) => sum + it.unit_price_cents * it.quantity, 0) || 0;
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>Košík je prázdný</Heading>
|
||||
<Text color="gray.600">Přidejte produkty z katalogu a vraťte se sem.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Košík</Heading>
|
||||
<Table size="sm" variant="simple" mb={4}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Produkt</Th>
|
||||
<Th isNumeric>Množství</Th>
|
||||
<Th isNumeric>Cena</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{data.items.map((it) => (
|
||||
<Tr key={it.id}>
|
||||
<Td>
|
||||
<Text fontWeight="medium">{it.product?.name || `Produkt #${it.product_id}`}</Text>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<HStack justify="flex-end">
|
||||
<NumberInput
|
||||
size="sm"
|
||||
min={0}
|
||||
value={it.quantity}
|
||||
onChange={(_, val) => handleQuantityChange(it.id, val)}
|
||||
maxW="90px"
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isNumeric>{formatPrice(it.unit_price_cents * it.quantity, it.currency || data.currency)}</Td>
|
||||
<Td isNumeric>
|
||||
<IconButton
|
||||
aria-label="Odebrat"
|
||||
icon={<TrashIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemove(it.id)}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontWeight="bold">Celkem:</Text>
|
||||
<Text fontWeight="bold">{formatPrice(total, data.currency || 'CZK')}</Text>
|
||||
</HStack>
|
||||
<Button colorScheme="blue" onClick={() => navigate('/checkout')}>
|
||||
Pokračovat k pokladně
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartPage;
|
||||
@@ -0,0 +1,475 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCart, getPacketaWidgetConfig, checkout, EshopCart } from '../services/eshopApi';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Divider,
|
||||
HStack,
|
||||
useToast,
|
||||
Card,
|
||||
CardBody,
|
||||
Container,
|
||||
Spinner,
|
||||
FormErrorMessage,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Packeta: any;
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Stripe Payment Form Component
|
||||
const StripePaymentForm: React.FC<{
|
||||
clientSecret: string;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ clientSecret, onSuccess, onCancel }) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/objednavka/dekujeme`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message || 'Došlo k chybě při zpracování platby.');
|
||||
} else {
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack spacing={4} as="form" onSubmit={handleSubmit}>
|
||||
<PaymentElement />
|
||||
|
||||
{errorMessage && (
|
||||
<Text color="red.500" fontSize="sm">{errorMessage}</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={4} width="100%">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
isDisabled={isLoading}
|
||||
flex={1}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
isDisabled={!stripe || !elements}
|
||||
flex={1}
|
||||
>
|
||||
Zaplatit
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckoutPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { data: cart, isLoading: cartLoading } = useQuery<EshopCart>(['eshop-cart'], getCart);
|
||||
const { data: packetaConfig, isLoading: packetaLoading, isError: packetaError } = useQuery(
|
||||
['packeta-config'],
|
||||
getPacketaWidgetConfig,
|
||||
);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [shippingMethod, setShippingMethod] = useState('packeta');
|
||||
const [selectedPoint, setSelectedPoint] = useState<any>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showValidationErrors, setShowValidationErrors] = useState(false);
|
||||
|
||||
// Stripe states
|
||||
const [showStripeModal, setShowStripeModal] = useState(false);
|
||||
const [stripeClientSecret, setStripeClientSecret] = useState('');
|
||||
const [stripe, setStripe] = useState<any>(null);
|
||||
const [elements, setElements] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Load Packeta widget script
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://widget.packeta.com/v6/www/js/library.js';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize Stripe when needed
|
||||
useEffect(() => {
|
||||
if (showStripeModal && stripeClientSecret) {
|
||||
const initStripe = async () => {
|
||||
const stripeKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!stripeKey) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Stripe není nakonfigurován' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stripeInstance = await loadStripe(stripeKey);
|
||||
if (!stripeInstance) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se načíst Stripe' });
|
||||
return;
|
||||
}
|
||||
|
||||
setStripe(stripeInstance);
|
||||
// Elements will be created in the modal with the client secret
|
||||
};
|
||||
|
||||
initStripe();
|
||||
}
|
||||
}, [showStripeModal, stripeClientSecret, toast]);
|
||||
|
||||
const openPacketaWidget = () => {
|
||||
if (!window.Packeta || !packetaConfig) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Widget Zásilkovny není připraven.' });
|
||||
return;
|
||||
}
|
||||
|
||||
window.Packeta.Widget.pick(packetaConfig.api_key, (point: any) => {
|
||||
if (point) {
|
||||
setSelectedPoint(point);
|
||||
}
|
||||
}, {
|
||||
country: 'cz,sk',
|
||||
language: 'cs',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setShowValidationErrors(true);
|
||||
|
||||
// Basic validation
|
||||
if (!email || !firstName || !lastName || !phone) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: 'Chybějící údaje',
|
||||
description: 'Vyplňte prosím všechny kontaktní údaje.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (shippingMethod === 'packeta' && !selectedPoint) {
|
||||
toast({ status: 'warning', title: 'Doprava', description: 'Vyberte prosím výdejní místo.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const billingAddress = {
|
||||
firstName, lastName, email, phone
|
||||
};
|
||||
|
||||
const shippingAddress = shippingMethod === 'packeta'
|
||||
? selectedPoint
|
||||
: billingAddress; // Fallback
|
||||
|
||||
const res = await checkout({
|
||||
email,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone,
|
||||
billing_address: billingAddress,
|
||||
shipping_address: shippingAddress,
|
||||
shipping_method: shippingMethod,
|
||||
});
|
||||
|
||||
// Manual email fallback (no online gateway)
|
||||
if (res.manual_payment && res.contact_email) {
|
||||
toast({
|
||||
status: 'info',
|
||||
title: 'Dokončení objednávky',
|
||||
description: `Online platba není aktuálně dostupná. Objednávku prosím pošlete na tento e-mail: ${res.contact_email}`,
|
||||
duration: 15000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// I bez online platby chceme zákazníkovi ukázat shrnutí objednávky
|
||||
if (res.order_id) {
|
||||
navigate(`/objednavka/dekujeme?order_id=${res.order_id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Online payment via redirect (Revolut)
|
||||
if (res.payment_redirect_url) {
|
||||
window.location.href = res.payment_redirect_url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Stripe payment - show payment modal
|
||||
if (res.client_secret) {
|
||||
setStripeClientSecret(res.client_secret);
|
||||
setShowStripeModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
status: 'error',
|
||||
title: 'Platba',
|
||||
description: 'Platební brána není aktuálně dostupná. Zkuste to prosím později nebo kontaktujte podporu.',
|
||||
});
|
||||
} catch (err) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se vytvořit objednávku.' });
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (cartLoading) return <Spinner />;
|
||||
if (!cart || !cart.items || cart.items.length === 0) {
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<Heading size="lg" mb={4}>Košík je prázdný</Heading>
|
||||
<Button onClick={() => navigate('/')}>Zpět do obchodu</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const itemsTotal = cart.items.reduce((sum, it) => sum + it.unit_price_cents * it.quantity, 0);
|
||||
const shippingPriceCents = shippingMethod === 'packeta' ? 7900 : 0; // match backend logic
|
||||
const grandTotal = itemsTotal + shippingPriceCents;
|
||||
|
||||
return (
|
||||
<Container maxW="3xl" py={8}>
|
||||
<Heading mb={6}>Pokladna</Heading>
|
||||
|
||||
<VStack spacing={8} align="stretch">
|
||||
{/* Contact Info */}
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={4}>1. Kontaktní údaje</Heading>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired isInvalid={showValidationErrors && !email}>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<FormErrorMessage>Zadejte prosím e-mail.</FormErrorMessage>
|
||||
</FormControl>
|
||||
<HStack width="100%">
|
||||
<FormControl isRequired isInvalid={showValidationErrors && !firstName}>
|
||||
<FormLabel>Jméno</FormLabel>
|
||||
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||
<FormErrorMessage>Zadejte prosím jméno.</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isRequired isInvalid={showValidationErrors && !lastName}>
|
||||
<FormLabel>Příjmení</FormLabel>
|
||||
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||
<FormErrorMessage>Zadejte prosím příjmení.</FormErrorMessage>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl isRequired isInvalid={showValidationErrors && !phone}>
|
||||
<FormLabel>Telefon</FormLabel>
|
||||
<Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
<FormErrorMessage>Zadejte prosím telefon.</FormErrorMessage>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Shipping Method */}
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={4}>2. Doprava</Heading>
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={showValidationErrors && shippingMethod === 'packeta' && !selectedPoint}
|
||||
>
|
||||
<RadioGroup value={shippingMethod} onChange={setShippingMethod}>
|
||||
<Stack direction="column">
|
||||
<Radio value="packeta">Zásilkovna (výdejní místa)</Radio>
|
||||
{/* Future: Add other carriers */}
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
|
||||
{shippingMethod === 'packeta' && (
|
||||
<Box mt={4} p={4} borderWidth="1px" borderRadius="md" bg="gray.50">
|
||||
{packetaLoading && (
|
||||
<Text fontSize="sm" color="gray.600" mb={2}>
|
||||
Načítání widgetu Zásilkovny...
|
||||
</Text>
|
||||
)}
|
||||
{packetaError && (
|
||||
<Text fontSize="sm" color="red.500" mb={2}>
|
||||
Nepodařilo se načíst konfiguraci Zásilkovny. Zkuste to prosím později.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{selectedPoint ? (
|
||||
<VStack align="start">
|
||||
<Text fontWeight="bold">Vybrané místo:</Text>
|
||||
<Text>{selectedPoint.name}</Text>
|
||||
<Text fontSize="sm" color="gray.600">{selectedPoint.street}, {selectedPoint.city}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openPacketaWidget}
|
||||
variant="outline"
|
||||
mt={2}
|
||||
isDisabled={!!packetaError}
|
||||
>
|
||||
Změnit místo
|
||||
</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<Button
|
||||
onClick={openPacketaWidget}
|
||||
colorScheme="red"
|
||||
isDisabled={packetaLoading || !!packetaError}
|
||||
>
|
||||
Vybrat výdejní místo
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<FormErrorMessage>Vyberte prosím výdejní místo.</FormErrorMessage>
|
||||
</FormControl>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={4}>3. Shrnutí objednávky</Heading>
|
||||
<VStack align="stretch" spacing={2} mb={4}>
|
||||
{cart.items.map((item) => (
|
||||
<HStack key={item.id} justify="space-between">
|
||||
<Text>{item.product?.name || 'Produkt'} x {item.quantity}</Text>
|
||||
<Text>
|
||||
{formatPrice(item.unit_price_cents * item.quantity, item.currency || cart.currency)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
<Divider my={2} />
|
||||
<HStack justify="space-between" fontWeight="bold">
|
||||
<Text>Celkem zboží</Text>
|
||||
<Text>{formatPrice(itemsTotal, cart.currency)}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text>Doprava</Text>
|
||||
<Text>
|
||||
{shippingPriceCents > 0
|
||||
? formatPrice(shippingPriceCents, cart.currency)
|
||||
: 'Bude upřesněno'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Divider my={2} />
|
||||
<HStack justify="space-between" fontWeight="bold">
|
||||
<Text>Celkem k úhradě</Text>
|
||||
<Text>{formatPrice(grandTotal, cart.currency)}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
width="100%"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Pokračovat k platbě
|
||||
</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
{/* Stripe Payment Modal */}
|
||||
<Modal isOpen={showStripeModal} onClose={() => setShowStripeModal(false)} size="md">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Platba kartou</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
{stripe && stripeClientSecret ? (
|
||||
<Elements
|
||||
stripe={stripe}
|
||||
options={{
|
||||
clientSecret: stripeClientSecret,
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StripePaymentForm
|
||||
clientSecret={stripeClientSecret}
|
||||
onSuccess={() => {
|
||||
setShowStripeModal(false);
|
||||
toast({
|
||||
status: 'success',
|
||||
title: 'Platba úspěšná',
|
||||
description: 'Objednávka byla zaplacena.',
|
||||
});
|
||||
navigate('/objednavka/dekujeme');
|
||||
}}
|
||||
onCancel={() => setShowStripeModal(false)}
|
||||
/>
|
||||
</Elements>
|
||||
) : (
|
||||
<VStack spacing={4}>
|
||||
<Text>Načítání platební brány...</Text>
|
||||
<Spinner />
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutPage;
|
||||
@@ -0,0 +1,203 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getOrder, EshopOrder } from '../services/eshopApi';
|
||||
import { Box, Heading, Text, VStack, HStack, Button, Spinner, Divider, Link, Card, CardBody, Badge, Container } from '@chakra-ui/react';
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'paid':
|
||||
return 'green';
|
||||
case 'awaiting_payment':
|
||||
return 'orange';
|
||||
case 'processing':
|
||||
return 'blue';
|
||||
case 'shipped':
|
||||
return 'purple';
|
||||
case 'cancelled':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'paid':
|
||||
return 'Zaplaceno';
|
||||
case 'awaiting_payment':
|
||||
return 'Čeká na platbu';
|
||||
case 'processing':
|
||||
return 'Zpracovává se';
|
||||
case 'shipped':
|
||||
return 'Odesláno';
|
||||
case 'cancelled':
|
||||
return 'Zrušeno';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const OrderSuccessPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const orderIdParam = searchParams.get('order_id');
|
||||
const orderId = orderIdParam ? Number(orderIdParam) : NaN;
|
||||
const enabled = !Number.isNaN(orderId) && orderId > 0;
|
||||
|
||||
const { data, isLoading, isError } = useQuery<EshopOrder>(
|
||||
['eshop-order', orderId],
|
||||
() => getOrder(orderId),
|
||||
{ enabled }
|
||||
);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<VStack spacing={6} align="center" textAlign="center">
|
||||
<Heading size="lg" color="green.600">✓ Děkujeme za vaši objednávku!</Heading>
|
||||
<Text color="gray.600">Platba proběhla úspěšně. Shrnutí objednávky momentálně nemáme k dispozici.</Text>
|
||||
<Button as={RouterLink} to="/" colorScheme="blue" size="lg">
|
||||
Zpět do obchodu
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<VStack spacing={4} align="center">
|
||||
<Spinner size="lg" />
|
||||
<Text>Načítám vaši objednávku…</Text>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<VStack spacing={6} align="center" textAlign="center">
|
||||
<Heading size="lg" color="orange.600">✓ Děkujeme za vaši objednávku!</Heading>
|
||||
<Text color="gray.600">Platba proběhla, ale nepodařilo se načíst detail objednávky.</Text>
|
||||
<Text color="gray.500" fontSize="sm">Zkontrolujte prosím potvrzovací email nebo se vraťte zpět do obchodu.</Text>
|
||||
<Button as={RouterLink} to="/" colorScheme="blue" size="lg">
|
||||
Zpět do obchodu
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const total = data.total_amount_cents || 0;
|
||||
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<VStack spacing={8} align="stretch">
|
||||
{/* Success Header */}
|
||||
<Box textAlign="center">
|
||||
<Heading size="lg" color="green.600" mb={2}>✓ Děkujeme za vaši objednávku!</Heading>
|
||||
<Text color="gray.600">Potvrzení objednávky bylo odesláno na vaši emailovou adresu.</Text>
|
||||
</Box>
|
||||
|
||||
{/* Order Details Card */}
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Heading size="md">Detail objednávky</Heading>
|
||||
|
||||
<HStack justify="space-between" wrap="wrap">
|
||||
<Text fontWeight="medium">Číslo objednávky:</Text>
|
||||
<Text fontFamily="mono">{data.order_number}</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between" wrap="wrap">
|
||||
<Text fontWeight="medium">Stav:</Text>
|
||||
<Badge colorScheme={getStatusColor(data.status)}>
|
||||
{getStatusText(data.status)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between" wrap="wrap">
|
||||
<Text fontWeight="medium">Celková částka:</Text>
|
||||
<Text fontSize="lg" fontWeight="bold" color="blue.600">
|
||||
{formatPrice(total, data.currency || 'CZK')}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{data.shipping_method && (
|
||||
<HStack justify="space-between" wrap="wrap">
|
||||
<Text fontWeight="medium">Způsob dopravy:</Text>
|
||||
<Text>
|
||||
{data.shipping_method === 'packeta' ? 'Zásilkovna' : data.shipping_method}
|
||||
{data.shipping_price_cents && data.shipping_price_cents > 0 && (
|
||||
<Text as="span" ml={2} color="gray.500">
|
||||
({formatPrice(data.shipping_price_cents, data.currency || 'CZK')})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Items Card */}
|
||||
{data.items && data.items.length > 0 && (
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={4}>Přehled položek</Heading>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{data.items.map((item) => (
|
||||
<HStack key={item.id} justify="space-between" py={2} borderBottom="1px" borderColor="gray.100">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontWeight="medium">{item.name}</Text>
|
||||
<Text fontSize="sm" color="gray.500">Počet: {item.quantity}</Text>
|
||||
</VStack>
|
||||
<Text fontWeight="bold">
|
||||
{formatPrice(item.unit_price_cents * item.quantity, item.currency || data.currency || 'CZK')}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Next Steps */}
|
||||
<Card variant="outline" bg="blue.50">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={3} color="blue.700">Co se stane dál?</Heading>
|
||||
<VStack align="start" spacing={2} color="blue.600">
|
||||
<Text>• 📧 Potvrzení objednávky bylo odesláno na {data.email}</Text>
|
||||
<Text>• 📦 Zpracování objednávky obvykle trvá 1-2 pracovní dny</Text>
|
||||
<Text>• 🚀 O odeslání budete informováni emailem</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<HStack spacing={4} justify="center">
|
||||
<Button as={RouterLink} to="/" colorScheme="blue" size="lg">
|
||||
Pokračovat v nákupu
|
||||
</Button>
|
||||
<Link as={RouterLink} to="/cart" fontSize="sm" color="blue.500" alignSelf="center">
|
||||
Zobrazit košík
|
||||
</Link>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSuccessPage;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getProduct, addToCart, EshopProductVariant } from '../services/eshopApi';
|
||||
import { Box, Heading, Text, Image, Badge, HStack, VStack, Button, Select, useToast } from '@chakra-ui/react';
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const ProductDetailPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const toast = useToast();
|
||||
const { data, isLoading, isError } = useQuery(['eshop-product', slug], () => getProduct(slug || ''), {
|
||||
enabled: !!slug,
|
||||
});
|
||||
const [variantId, setVariantId] = useState<number | undefined>(undefined);
|
||||
|
||||
if (isLoading) return <Text>Načítání produktu…</Text>;
|
||||
if (isError || !data) return <Text>Produkt nebyl nalezen.</Text>;
|
||||
|
||||
const variants = data.variants || [];
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
try {
|
||||
const selected = variants.find((v) => v.id === variantId) as EshopProductVariant | undefined;
|
||||
await addToCart(data.id, selected?.id, 1);
|
||||
toast({ status: 'success', title: 'Přidáno do košíku', description: data.name, duration: 2000 });
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se přidat do košíku.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack align="flex-start" spacing={8} flexWrap="wrap">
|
||||
<Box flex="0 0 320px" maxW="100%">
|
||||
<Image
|
||||
src={data.default_image_url || '/images/placeholder-clothing.jpg'}
|
||||
alt={data.name}
|
||||
w="100%"
|
||||
borderRadius="md"
|
||||
mb={4}
|
||||
/>
|
||||
</Box>
|
||||
<VStack align="stretch" spacing={4} flex="1">
|
||||
<Heading size="lg">{data.name}</Heading>
|
||||
<Badge colorScheme="blue" fontSize="md" alignSelf="flex-start">
|
||||
{formatPrice(data.price_cents, data.currency || 'CZK')}
|
||||
</Badge>
|
||||
{data.short_description && (
|
||||
<Text color="gray.700">{data.short_description}</Text>
|
||||
)}
|
||||
{variants.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="medium" mb={1}>Varianta</Text>
|
||||
<Select
|
||||
placeholder="Vyberte variantu"
|
||||
value={variantId ?? ''}
|
||||
onChange={(e) => setVariantId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
maxW="260px"
|
||||
>
|
||||
{variants.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name || v.sku || `Varianta #${v.id}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
)}
|
||||
<Button colorScheme="blue" onClick={handleAddToCart} maxW="260px">
|
||||
Přidat do košíku
|
||||
</Button>
|
||||
{data.description_html && (
|
||||
<Box mt={4} fontSize="sm" color="gray.700">
|
||||
<div dangerouslySetInnerHTML={{ __html: data.description_html }} />
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailPage;
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
Select,
|
||||
useToast,
|
||||
Switch,
|
||||
Divider,
|
||||
HStack,
|
||||
Spinner,
|
||||
} from '@chakra-ui/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getEshopSettings, updateEshopSettings, getClubInfo, EshopSettings, ClubInfo } from '../services/eshopApi';
|
||||
|
||||
const SetupPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [clubInfo, setClubInfo] = useState<ClubInfo | null>(null);
|
||||
const [formData, setFormData] = useState<EshopSettings>({
|
||||
default_currency: 'CZK',
|
||||
default_country: 'CZ',
|
||||
support_email: '',
|
||||
support_phone: '',
|
||||
terms_url: '',
|
||||
returns_policy_url: '',
|
||||
});
|
||||
|
||||
// Packeta toggle state (will be stored in shipping_options_json)
|
||||
const [packetaEnabled, setPacketaEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Load Club Info to pre-fill defaults
|
||||
const info = await getClubInfo();
|
||||
setClubInfo(info);
|
||||
|
||||
// Load existing settings if any
|
||||
const settings = await getEshopSettings();
|
||||
|
||||
// Merge defaults if settings are empty
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...settings,
|
||||
// If support email/phone are empty in settings, pre-fill from club info
|
||||
support_email: settings.support_email || info.contact_email || '',
|
||||
support_phone: settings.support_phone || info.contact_phone || '',
|
||||
default_country: settings.default_country || info.contact_country || 'CZ',
|
||||
}));
|
||||
|
||||
// Parse packeta enabled state from shipping_options_json
|
||||
if (settings.shipping_options_json) {
|
||||
try {
|
||||
const opts = JSON.parse(settings.shipping_options_json);
|
||||
if (opts.packeta_enabled) {
|
||||
setPacketaEnabled(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse shipping options", e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Setup init failed", error);
|
||||
toast({
|
||||
title: 'Chyba načítání dat',
|
||||
description: 'Nepodařilo se načíst informace o klubu nebo nastavení.',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initData();
|
||||
}, [toast]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Prepare shipping options
|
||||
const shippingOptions = {
|
||||
packeta_enabled: packetaEnabled,
|
||||
};
|
||||
|
||||
const payload: EshopSettings = {
|
||||
...formData,
|
||||
shipping_options_json: JSON.stringify(shippingOptions),
|
||||
};
|
||||
|
||||
await updateEshopSettings(payload);
|
||||
|
||||
toast({
|
||||
title: 'Nastavení uloženo',
|
||||
description: 'E-shop je připraven k použití.',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// Redirect to admin dashboard
|
||||
navigate('/admin');
|
||||
} catch (error) {
|
||||
console.error("Save failed", error);
|
||||
toast({
|
||||
title: 'Chyba ukládání',
|
||||
description: 'Nastavení se nepodařilo uložit.',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box py={10} textAlign="center">
|
||||
<Spinner size="xl" />
|
||||
<Text mt={4}>Načítám konfiguraci...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxW="2xl" py={8}>
|
||||
<VStack spacing={6} align="stretch" bg="white" p={8} borderRadius="lg" boxShadow="md">
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>E-shop Setup Wizard</Heading>
|
||||
<Text color="gray.600">
|
||||
Základní nastavení vašeho e-shopu. Některé údaje jsme předvyplnili z nastavení klubu
|
||||
<strong> {clubInfo?.club_name}</strong>.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading size="md">1. Lokalizace a měna</Heading>
|
||||
<HStack spacing={4}>
|
||||
<FormControl id="default_country">
|
||||
<FormLabel>Výchozí země</FormLabel>
|
||||
<Select name="default_country" value={formData.default_country} onChange={handleChange}>
|
||||
<option value="CZ">Česká republika</option>
|
||||
<option value="SK">Slovensko</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl id="default_currency">
|
||||
<FormLabel>Měna</FormLabel>
|
||||
<Select name="default_currency" value={formData.default_currency} onChange={handleChange}>
|
||||
<option value="CZK">CZK (Kč)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
<Heading size="md" mt={4}>2. Zákaznická podpora</Heading>
|
||||
<Text fontSize="sm" color="gray.500">Tyto údaje se zobrazí v patičce a emailech zákazníkům.</Text>
|
||||
<FormControl id="support_email" isRequired>
|
||||
<FormLabel>Email podpory</FormLabel>
|
||||
<Input
|
||||
name="support_email"
|
||||
type="email"
|
||||
value={formData.support_email}
|
||||
onChange={handleChange}
|
||||
placeholder="eshop@mojklub.cz"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id="support_phone">
|
||||
<FormLabel>Telefon podpory</FormLabel>
|
||||
<Input
|
||||
name="support_phone"
|
||||
type="tel"
|
||||
value={formData.support_phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+420 123 456 789"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Heading size="md" mt={4}>3. Obchodní podmínky</Heading>
|
||||
<FormControl id="terms_url">
|
||||
<FormLabel>Odkaz na VOP</FormLabel>
|
||||
<Input
|
||||
name="terms_url"
|
||||
value={formData.terms_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://www.mojklub.cz/obchodni-podminky"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id="returns_policy_url">
|
||||
<FormLabel>Odkaz na Reklamační řád</FormLabel>
|
||||
<Input
|
||||
name="returns_policy_url"
|
||||
value={formData.returns_policy_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://www.mojklub.cz/reklamace"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Heading size="md" mt={4}>4. Doprava</Heading>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel htmlFor="packeta-switch" mb="0">
|
||||
Povolit Zásilkovnu (Packeta)
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="packeta-switch"
|
||||
isChecked={packetaEnabled}
|
||||
onChange={(e) => setPacketaEnabled(e.target.checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
{packetaEnabled && (
|
||||
<Text fontSize="xs" color="blue.500">
|
||||
Packeta widget bude aktivní v košíku. Ujistěte se, že máte nastavené PACKETA_ API klíče v .env.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
onClick={handleSave}
|
||||
isLoading={saving}
|
||||
loadingText="Ukládám..."
|
||||
>
|
||||
Dokončit nastavení
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupPage;
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getProducts, addToCart, EshopProduct } from '../services/eshopApi';
|
||||
import { Box, SimpleGrid, Heading, Text, Image, Badge, Button, VStack, HStack, useToast, Link as ChakraLink } from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const ShopHomePage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const { data, isLoading, isError } = useQuery<EshopProduct[]>(['eshop-products'], getProducts);
|
||||
|
||||
const handleAddToCart = async (product: EshopProduct) => {
|
||||
try {
|
||||
const firstVariant = product.variants && product.variants.length > 0 ? product.variants[0] : undefined;
|
||||
await addToCart(product.id, firstVariant?.id, 1);
|
||||
toast({ status: 'success', title: 'Přidáno do košíku', description: product.name, duration: 2000 });
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se přidat do košíku.' });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Text>Načítání produktů…</Text>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <Text>Došlo k chybě při načítání produktů.</Text>;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>E-shop bude brzy spuštěn</Heading>
|
||||
<Text color="gray.600">Zatím zde nejsou žádné produkty. Ověřte nastavení e-shopu v administraci.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Produkty</Heading>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={6}>
|
||||
{data.map((p) => (
|
||||
<Box key={p.id} borderWidth="1px" borderRadius="md" overflow="hidden" bg="white" _hover={{ boxShadow: 'md' }}>
|
||||
<Box position="relative" paddingTop="70%" overflow="hidden">
|
||||
<Image
|
||||
src={p.default_image_url || '/images/placeholder-clothing.jpg'}
|
||||
alt={p.name}
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
w="100%"
|
||||
h="100%"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<VStack align="stretch" p={4} spacing={2}>
|
||||
<ChakraLink as={RouterLink} to={`/produkt/${p.slug}`} fontWeight="semibold" noOfLines={2}>
|
||||
{p.name}
|
||||
</ChakraLink>
|
||||
{p.short_description && (
|
||||
<Text fontSize="sm" color="gray.600" noOfLines={2}>{p.short_description}</Text>
|
||||
)}
|
||||
<HStack justify="space-between" mt={1}>
|
||||
<Badge colorScheme="blue">{formatPrice(p.price_cents, p.currency || 'CZK')}</Badge>
|
||||
<Button size="sm" colorScheme="blue" onClick={() => handleAddToCart(p)}>
|
||||
Do košíku
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShopHomePage;
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -0,0 +1,220 @@
|
||||
import axios, { AxiosRequestHeaders } from 'axios';
|
||||
|
||||
const baseURL = process.env.REACT_APP_API_URL || '/api/v1/eshop';
|
||||
|
||||
export const eshopApi = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
let cachedSessionToken: string | null = null;
|
||||
|
||||
export function getOrCreateEshopSessionToken(): string {
|
||||
if (cachedSessionToken) {
|
||||
return cachedSessionToken;
|
||||
}
|
||||
|
||||
// In non-browser environments (tests, SSR) generate an in-memory token
|
||||
if (typeof window === 'undefined') {
|
||||
cachedSessionToken = `eshop-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
|
||||
return cachedSessionToken;
|
||||
}
|
||||
|
||||
const key = 'eshop_session_token';
|
||||
const existing = window.localStorage.getItem(key);
|
||||
let token: string;
|
||||
if (existing) {
|
||||
token = existing;
|
||||
} else {
|
||||
if (window.crypto && typeof (window.crypto as any).randomUUID === 'function') {
|
||||
token = (window.crypto as any).randomUUID();
|
||||
} else {
|
||||
token = `eshop-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
window.localStorage.setItem(key, token);
|
||||
}
|
||||
|
||||
// Persist token in a cookie as well so the backend can read it when headers are missing
|
||||
try {
|
||||
const oneYearInSeconds = 60 * 60 * 24 * 365;
|
||||
document.cookie = `eshop_session_token=${token}; path=/; max-age=${oneYearInSeconds}`;
|
||||
} catch {
|
||||
// Ignore cookie errors (e.g. disabled cookies)
|
||||
}
|
||||
|
||||
cachedSessionToken = token;
|
||||
return token;
|
||||
}
|
||||
|
||||
eshopApi.interceptors.request.use((config) => {
|
||||
const token = getOrCreateEshopSessionToken();
|
||||
const headers: AxiosRequestHeaders = (config.headers || {}) as AxiosRequestHeaders;
|
||||
if (!('X-Session-Token' in headers)) {
|
||||
(headers as any)['X-Session-Token'] = token;
|
||||
}
|
||||
config.headers = headers;
|
||||
return config;
|
||||
});
|
||||
|
||||
export interface EshopProductVariant {
|
||||
id: number;
|
||||
sku?: string;
|
||||
name?: string;
|
||||
stock_qty?: number;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface EshopProduct {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
short_description?: string;
|
||||
description_html?: string;
|
||||
price_cents: number;
|
||||
currency: string;
|
||||
default_image_url?: string;
|
||||
gallery_json?: string;
|
||||
tags?: string;
|
||||
variants?: EshopProductVariant[];
|
||||
}
|
||||
|
||||
export interface EshopCartItem {
|
||||
id: number;
|
||||
product_id: number;
|
||||
variant_id?: number;
|
||||
quantity: number;
|
||||
unit_price_cents: number;
|
||||
currency: string;
|
||||
product?: EshopProduct;
|
||||
}
|
||||
|
||||
export interface EshopCart {
|
||||
id: number;
|
||||
currency: string;
|
||||
items: EshopCartItem[];
|
||||
}
|
||||
|
||||
export async function getProducts(): Promise<EshopProduct[]> {
|
||||
const res = await eshopApi.get<{ data: EshopProduct[] }>('/products');
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
export async function getProduct(slug: string): Promise<EshopProduct> {
|
||||
const res = await eshopApi.get<EshopProduct>(`/products/${encodeURIComponent(slug)}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCart(): Promise<EshopCart> {
|
||||
const res = await eshopApi.get<EshopCart>('/cart');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function addToCart(productId: number, variantId: number | undefined, quantity: number): Promise<void> {
|
||||
await eshopApi.post('/cart/items', {
|
||||
product_id: productId,
|
||||
variant_id: variantId ?? undefined,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCartItem(id: number, quantity: number): Promise<void> {
|
||||
await eshopApi.patch(`/cart/items/${id}`, { quantity });
|
||||
}
|
||||
|
||||
export const removeCartItem = async (id: number): Promise<void> => {
|
||||
await eshopApi.delete(`/cart/items/${id}`);
|
||||
};
|
||||
|
||||
export interface CheckoutRequest {
|
||||
billing_address: any;
|
||||
shipping_address: any;
|
||||
shipping_method: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CheckoutResponse {
|
||||
order_id: number;
|
||||
order_number: string;
|
||||
payment_provider?: string;
|
||||
payment_redirect_url?: string;
|
||||
client_secret?: string;
|
||||
manual_payment?: boolean;
|
||||
contact_email?: string;
|
||||
}
|
||||
|
||||
export async function checkout(req: CheckoutRequest): Promise<CheckoutResponse> {
|
||||
const res = await eshopApi.post<CheckoutResponse>('/checkout', req);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export const getPacketaWidgetConfig = async (): Promise<{ api_key: string; env: string }> => {
|
||||
const response = await eshopApi.get('/shipping/packeta-widget-config');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface EshopOrderItem {
|
||||
id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit_price_cents: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface EshopOrder {
|
||||
id: number;
|
||||
order_number: string;
|
||||
status: string;
|
||||
total_amount_cents: number;
|
||||
currency: string;
|
||||
email?: string;
|
||||
shipping_method?: string;
|
||||
shipping_price_cents?: number;
|
||||
items?: EshopOrderItem[];
|
||||
}
|
||||
|
||||
export async function getOrder(id: number): Promise<EshopOrder> {
|
||||
const res = await eshopApi.get<EshopOrder>(`/orders/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// --- Setup & Settings ---
|
||||
|
||||
export interface EshopSettings {
|
||||
id?: number;
|
||||
default_currency: string;
|
||||
supported_currencies?: string;
|
||||
default_country: string;
|
||||
shipping_options_json?: string;
|
||||
terms_url?: string;
|
||||
returns_policy_url?: string;
|
||||
support_email?: string;
|
||||
support_phone?: string;
|
||||
}
|
||||
|
||||
export interface ClubInfo {
|
||||
club_name: string;
|
||||
club_logo_url: string;
|
||||
contact_email: string;
|
||||
contact_phone: string;
|
||||
contact_country: string;
|
||||
primary_color: string;
|
||||
}
|
||||
|
||||
export async function getEshopSettings(): Promise<EshopSettings> {
|
||||
const res = await eshopApi.get<EshopSettings>('/admin/settings');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateEshopSettings(settings: EshopSettings): Promise<EshopSettings> {
|
||||
const res = await eshopApi.put<EshopSettings>('/admin/settings', settings);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getClubInfo(): Promise<ClubInfo> {
|
||||
const res = await eshopApi.get<ClubInfo>('/admin/club-info');
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { extendTheme, ThemeConfig } from '@chakra-ui/react';
|
||||
|
||||
const config: ThemeConfig = {
|
||||
initialColorMode: 'light',
|
||||
useSystemColorMode: false,
|
||||
};
|
||||
|
||||
// For now we use a simple blue theme. In a next step, we can hydrate colors
|
||||
// from the main MyClub settings API so the e-shop matches club branding.
|
||||
export const theme = extendTheme({
|
||||
config,
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#e3f2ff',
|
||||
100: '#b9d4ff',
|
||||
200: '#8fb7ff',
|
||||
300: '#6599ff',
|
||||
400: '#3b7cff',
|
||||
500: '#1a5fe6',
|
||||
600: '#1449b4',
|
||||
700: '#0e3382',
|
||||
800: '#071d51',
|
||||
900: '#020720',
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
global: {
|
||||
body: {
|
||||
bg: 'gray.50',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "src",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
Comprehensive Guide to Integrating Packeta into Your Custom E-Shop
|
||||
This guide provides a step-by-step overview of integrating Packeta's delivery services into your fully custom e-shop. Packeta is a digital platform for packet delivery, offering home delivery and pick-up points/boxes worldwide through a single interface. It's designed for e-shops to streamline shipping, with features like cash-on-delivery (COD), age verification, and reverse logistics. The integration involves using their API for backend operations and a widget for frontend selection of delivery options. Implementation can vary based on your e-shop's tech stack (e.g., PHP, Node.js, Python backend), but I'll provide general REST/XML examples using curl and Python, as the API is primarily XML-based. Assume your e-shop can make HTTP requests and handle JavaScript for the widget.
|
||||
Prerequisites
|
||||
|
||||
Basic knowledge of REST APIs, XML, and JavaScript.
|
||||
A custom e-shop with a checkout process, order management, and backend capable of API calls.
|
||||
Register for a Packeta account at https://client.packeta.com to get your API key (a 16-character string). This is required for authentication.
|
||||
For testing, use your standard account (no charges for undispatched packets) or create a test account in the client section. No separate sandbox URL—use the production endpoint for testing.
|
||||
|
||||
API Overview
|
||||
Packeta's API supports two equivalent interfaces:
|
||||
|
||||
REST/XML: Send POST requests with XML bodies to https://www.zasilkovna.cz/api/rest.
|
||||
SOAP: Use the WSDL at https://www.zasilkovna.cz/api/soap.wsdl (or bugfix version: https://www.zasilkovna.cz/api/soap-php-bugfix.wsdl).
|
||||
|
||||
All requests require your apiPassword as the first element. Responses are XML. There are no webhooks for real-time updates—poll for status changes using tracking methods.
|
||||
Basic curl example for REST/XML:
|
||||
textcurl -X POST https://www.zasilkovna.cz/api/rest \
|
||||
-H "Content-Type: text/xml" \
|
||||
-d '@request.xml'
|
||||
Where request.xml contains the XML payload.
|
||||
Available Delivery Services
|
||||
Packeta offers several services suitable for e-shops:
|
||||
|
||||
Pick-up Delivery: Customers pick up at Packeta pick-up points (PUDOs) or Z-BOXes (24/7 automated boxes). Supports COD and age verification. Not all points allow packet drop-off—filter using attributes if managing lists manually.
|
||||
Home Delivery: Delivered by various carriers to customer addresses. Use a specific carrier's addressId or "Best Delivery Solution" (BDS) for automatic carrier selection (not available in all countries). Supports COD; some carriers require direct labels.
|
||||
Carrier Pick-up Delivery: Use external carriers' pick-up points/boxes. Filter by carrier IDs.
|
||||
Reverse Logistics (Returns): Includes Claim Assistant (unique return labels/passwords) and Return Packets (single code for all returns).
|
||||
|
||||
For pick-up points and carriers, Packeta provides data feeds (e.g., CSV/JSON exports) for lists if you don't use the widget. These are updated regularly—download daily for accuracy. Check the Packeta docs or client section for exact URLs (e.g., carriers list for addressId values and PUDOs for point details). If implementing without the widget, parse these feeds to display options in your checkout.
|
||||
Step 1: Integrate the Pick-up Point Widget (Frontend)
|
||||
For pick-up delivery, use Packeta's widget to let customers select a PUDO or Z-BOX during checkout. This is the recommended approach for custom e-shops, as it handles mapping, filtering, and geolocation without you managing thousands of points.
|
||||
Setup
|
||||
|
||||
Include the widget library in your checkout page:text<script src="https://widget.packeta.com/v6/www/js/library.js"></script>
|
||||
Configure in the Packeta client section: Set allowed countries, categories, and carriers (changes take ~90 minutes).
|
||||
Launch the widget when Packeta delivery is selected (modal or inline).
|
||||
|
||||
Code Example (JavaScript - Modal Mode)
|
||||
JavaScript// Trigger on delivery option select
|
||||
function openPacketaWidget() {
|
||||
Packeta.Widget.pick(
|
||||
'YOUR_16_CHAR_API_KEY', // Replace with your API key
|
||||
function(point) {
|
||||
if (point) {
|
||||
// Save selected point to order (use point.id as addressId in createPacket)
|
||||
console.log('Selected Point ID:', point.id, 'Name:', point.name);
|
||||
// e.g., send to backend via AJAX: { addressId: point.id, type: point.type }
|
||||
} else {
|
||||
console.log('Widget cancelled');
|
||||
}
|
||||
},
|
||||
{
|
||||
language: 'en', // UI language (e.g., 'en', 'cs')
|
||||
country: 'cz,sk', // Filter countries (ISO codes)
|
||||
weight: 5, // Filter by package weight (kg)
|
||||
// Other options: vendors (array), claimAssistant: 'yes', packetConsignment: 'yes', etc.
|
||||
}
|
||||
);
|
||||
}
|
||||
Inline Mode Example
|
||||
JavaScriptconst container = document.getElementById('packeta-container'); // Your DIV with width/height
|
||||
Packeta.Widget.pick('YOUR_API_KEY', callbackFunction, options, container);
|
||||
Validation
|
||||
After selection, validate the point by POSTing to https://widget.packeta.com/v6/pps/api/widget/v1/validate with JSON body: { apiKey, options, point: { id: pointId } }. Response includes isValid and errors.
|
||||
Customization
|
||||
|
||||
Filter by weight, dimensions, location (latitude/longitude).
|
||||
Use appIdentity for your e-shop name/version (e.g., 'custom-eshop-v1.0').
|
||||
For geolocation, use HTTPS and add allow="geolocation" to iframe sandbox.
|
||||
|
||||
Step 2: Create Packets/Shipments (Backend)
|
||||
When an order is placed, create a packet using the API. Use the addressId from the widget (for PUDOs) or carrier list (for home).
|
||||
Code Example (XML for createPacket - Pick-up or Home)
|
||||
XML<createPacket>
|
||||
<apiPassword>YOUR_API_PASSWORD</apiPassword>
|
||||
<packetAttributes>
|
||||
<number>ORDER-123</number> <!-- Your order ID -->
|
||||
<name>John</name>
|
||||
<surname>Doe</surname>
|
||||
<email>john@example.com</email>
|
||||
<phone>+420123456789</phone> <!-- Optional -->
|
||||
<addressId>95</addressId> <!-- PUDO ID from widget or carrier addressId -->
|
||||
<value>100.00</value> <!-- Packet value -->
|
||||
<currency>CZK</currency>
|
||||
<weight>2</weight> <!-- In kg -->
|
||||
<eshop>YourEshopName</eshop> <!-- Your shop identifier -->
|
||||
<!-- Optional: cod (COD amount), adultContent (true for age 18+), etc. -->
|
||||
</packetAttributes>
|
||||
</createPacket>
|
||||
Send via curl or backend. Response XML: <packetId>1234567890</packetId>. Store this in your database.
|
||||
Python Example (Using requests)
|
||||
Pythonimport requests
|
||||
|
||||
xml_payload = """
|
||||
<createPacket>
|
||||
<apiPassword>YOUR_API_PASSWORD</apiPassword>
|
||||
<packetAttributes>
|
||||
<number>ORDER-123</number>
|
||||
<name>John</name>
|
||||
<surname>Doe</surname>
|
||||
<email>john@example.com</email>
|
||||
<addressId>95</addressId>
|
||||
<value>100.00</value>
|
||||
<currency>CZK</currency>
|
||||
<weight>2</weight>
|
||||
<eshop>YourEshopName</eshop>
|
||||
</packetAttributes>
|
||||
</createPacket>
|
||||
"""
|
||||
|
||||
headers = {'Content-Type': 'text/xml'}
|
||||
response = requests.post('https://www.zasilkovna.cz/api/rest', data=xml_payload, headers=headers)
|
||||
if response.status_code == 200:
|
||||
# Parse XML for packetId (use xml.etree.ElementTree)
|
||||
from xml.etree import ElementTree as ET
|
||||
root = ET.fromstring(response.text)
|
||||
packet_id = root.find('.//packetId').text
|
||||
print(f'Packet created: {packet_id}')
|
||||
else:
|
||||
print('Error:', response.text)
|
||||
Validation Before Creation
|
||||
Use packetAttributesValid with the same attributes to check for errors before creating.
|
||||
For Multiple Packets
|
||||
Use createShipment with array of packetIds.
|
||||
Step 3: Generate Labels
|
||||
After packet creation, generate a label for printing.
|
||||
XML Example (packetLabelPdf)
|
||||
XML<packetLabelPdf>
|
||||
<apiPassword>YOUR_API_PASSWORD</apiPassword>
|
||||
<packetId>1234567890</packetId>
|
||||
<format>A6 on A4</format> <!-- Options: A6 on A6, A7 on A4, etc. -->
|
||||
<offset>0</offset> <!-- Label position on page -->
|
||||
</packetLabelPdf>
|
||||
Response: Binary PDF. Save and provide to your packing team.
|
||||
For external carriers, use packetCourierLabelPdf with courierNumber.
|
||||
Python: Similar to above, save response.content to file.
|
||||
Step 4: Tracking and Status Updates
|
||||
Poll for updates using packetStatus or packetTracking.
|
||||
XML Example (packetStatus)
|
||||
XML<packetStatus>
|
||||
<apiPassword>YOUR_API_PASSWORD</apiPassword>
|
||||
<packetId>1234567890</packetId>
|
||||
</packetStatus>
|
||||
Response: Current status (e.g., code, date, text).
|
||||
Integrate into your e-shop's order dashboard—e.g., cron job to update statuses.
|
||||
For external carriers, use packetCourierTracking.
|
||||
Step 5: Handling Cancellations
|
||||
If order cancelled before submission, use cancelPacket.
|
||||
XML<cancelPacket>
|
||||
<apiPassword>YOUR_API_PASSWORD</apiPassword>
|
||||
<packetId>1234567890</packetId>
|
||||
</cancelPacket>
|
||||
```<grok-card data-id="bb18aa" data-type="citation_card"></grok-card>
|
||||
|
||||
### Step 6: Reverse Logistics (Returns)
|
||||
For customer returns:
|
||||
- **Claim Assistant**: Create unique return with `createPacketClaimWithPassword`.
|
||||
XML Example:
|
||||
```xml
|
||||
<createPacketClaimWithPassword>
|
||||
<apiPassword>YOUR_API_PASSWORD</apiPassword>
|
||||
<claimWithPasswordAttributes>
|
||||
<id>RETURN-123</id> <!-- Unique return ID -->
|
||||
<number>ORDER-123</number>
|
||||
<email>john@example.com</email>
|
||||
<value>100.00</value>
|
||||
<currency>CZK</currency>
|
||||
<eshop>YourEshopName</eshop>
|
||||
<sendEmailToCustomer>true</sendEmailToCustomer> <!-- Optional: Send label to customer -->
|
||||
</claimWithPasswordAttributes>
|
||||
</createPacketClaimWithPassword>
|
||||
Response: Packet details. Provide password to customer for drop-off at PUDO.
|
||||
|
||||
Return Packet: Generate a single return code in the client section and share with all customers for label-free returns.
|
||||
|
||||
Validate claims with packetClaimAttributesValid.
|
||||
Additional Tips
|
||||
|
||||
Error Handling: API throws faults (e.g., PacketAttributesFault)—parse responses for errors.
|
||||
Data Feeds: For custom lists (e.g., carriers for home delivery or PUDOs without widget), download Packeta's exports from the docs or client section (typically CSV/JSON, updated daily). Use them to populate dropdowns if needed.
|
||||
International: Filter by country in widget/options. Check carrier support for BDS/COD.
|
||||
Security: Store API key securely; use HTTPS.
|
||||
Testing: Create test packets, generate labels, track, and cancel without real dispatch.
|
||||
Limitations: No webhooks—implement polling. For large volumes, batch methods like packetsLabelsPdf.
|
||||
Full API methods are listed in the Packeta docs—refer to /docs/api-reference/api-methods for advanced features like B2B packets or storage extensions.
|
||||
|
||||
This guide covers the core integration. If your e-shop uses a specific framework (e.g., Laravel, Express), adapt the examples accordingly. For any missing details (e.g., exact data feed URLs), check the official docs or contact Packeta support. If you provide more details about your tech stack, I can refine the examples!
|
||||
@@ -0,0 +1,168 @@
|
||||
# MyClub E‑shop – Timeline
|
||||
|
||||
> Přehled kroků pro dokončení e‑shopu. Položky označené `✔` jsou hotové, `⬜` čekají na dokončení.
|
||||
|
||||
## 1. Infrastruktura a zapínání e‑shopu (✔ hotovo)
|
||||
|
||||
- ✔ **Env & config**
|
||||
- `ESHOP_ENABLED`, `ESHOP_FRONTEND_URL`, `ESHOP_API_URL`, `ESHOP_FRONTEND_PORT`, `ESHOP_BACKEND_PORT` v `.env`.
|
||||
- Backend `Config` načítá Stripe a Packeta údaje.
|
||||
- ✔ **Docker Compose & Makefile**
|
||||
- `docker-compose.eshop.yml` přidává `eshop-backend` a `eshop-frontend`.
|
||||
- `make docker-up` / `make dev` podle `ESHOP_ENABLED` spouští buď jen MyClub, nebo MyClub + e‑shop.
|
||||
|
||||
## 2. E‑shop backend – jádro (✔ hotovo – první verze)
|
||||
|
||||
- ✔ **Samostatný backend**
|
||||
- Služba `eshop-backend` (Go/Gin) se sdílenou DB a auth (`JWTOptional`).
|
||||
- Healthcheck: `GET /api/v1/eshop/health`.
|
||||
- ✔ **Databázové modely** (`internal/models/eshop.go`)
|
||||
- `eshop_product_categories`, `eshop_products`, `eshop_product_variants`.
|
||||
- `eshop_carts`, `eshop_cart_items`.
|
||||
- `eshop_orders`, `eshop_order_items`, `eshop_payments`, `eshop_shipping_labels`.
|
||||
- `eshop_settings`.
|
||||
- ✔ **Migrace**
|
||||
- Modely přidány do `pkg/database.MigrateDB` a best‑effort `AutoMigrate` v `main.go`.
|
||||
- ✔ **Veřejné API (MVP)**
|
||||
- `GET /api/v1/eshop/products` – seznam aktivních produktů.
|
||||
- `GET /api/v1/eshop/products/:slug` – detail produktu.
|
||||
- `GET /api/v1/eshop/cart` – košík pro uživatele / session.
|
||||
- `POST /api/v1/eshop/cart/items` – přidání/úprava položky.
|
||||
- `PATCH /api/v1/eshop/cart/items/:id` – změna množství.
|
||||
- `DELETE /api/v1/eshop/cart/items/:id` – odebrání položky.
|
||||
|
||||
## 3. E‑shop frontend – veřejná část (✔ hotovo – MVP)
|
||||
|
||||
- ✔ **Nová SPA aplikace** (`eshop/frontend`)
|
||||
- CRA + TypeScript + Chakra UI + React Query.
|
||||
- Základní theme (`theme.ts`) – později napojíme na klubové barvy.
|
||||
- ✔ **Routing** (`App.tsx`)
|
||||
- `/` – přehled produktů (`ShopHomePage`).
|
||||
- `/produkt/:slug` – detail produktu (`ProductDetailPage`).
|
||||
- `/cart` – košík (`CartPage`).
|
||||
- `/admin` – základ admin dashboard (`AdminDashboardPage`).
|
||||
- ✔ **API klient** (`services/eshopApi.ts`)
|
||||
- Napojení na `REACT_APP_API_URL` (v Dockeru `/api/v1/eshop`).
|
||||
- Funkce `getProducts`, `getProduct`, `getCart`, `addToCart`, `updateCartItem`, `removeCartItem`.
|
||||
- ✔ **Docker + Nginx**
|
||||
- `eshop/frontend/Dockerfile` buildí appku.
|
||||
- `eshop/frontend/nginx.conf`:
|
||||
- `/` → SPA (`index.html`).
|
||||
- `/api/` → proxy na `eshop-backend:8080`.
|
||||
|
||||
## 4. Propojení s MyClub (clothing / merch) (✔ hotovo – první krok)
|
||||
|
||||
- ✔ **Veřejný web**
|
||||
- `/obleceni` (`ClothingPage`) – zobrazuje CTA „Přejít do plného e‑shopu“, když je `REACT_APP_ESHOP_URL` nastavené.
|
||||
- `MerchSection` na homepage – vedle „Zobrazit vše“ přidán button „E‑shop“ → plný e‑shop.
|
||||
- ✔ **Admin MyClub**
|
||||
- `AdminMerchPage` ukazuje informační box s odkazem na plný e‑shop, pokud `REACT_APP_ESHOP_URL` existuje.
|
||||
- ✔ **Frontend env**
|
||||
- `frontend/.env(.example)` – přidáno `REACT_APP_ESHOP_URL`.
|
||||
|
||||
## 5. E‑shop produktová administrace v MyClub adminu (✔ hotovo)
|
||||
|
||||
Cíl: pohodlně spravovat produkty/varianty přímo z hlavní administrace.
|
||||
|
||||
- ✔ **Backend (MyClub)**
|
||||
- Admin endpointy `/api/v1/admin/eshop/products` + `/variants` pro CRUD nad `eshop_products` a `eshop_product_variants` jsou implementované v `EshopAdminController` a zaregistrované v `routes.go`.
|
||||
- Ochrana přes JWT + `RoleAuth("admin")` přes skupinu `/api/v1/admin`.
|
||||
- ✔ **Frontend (MyClub admin)**
|
||||
- Stránka „E‑shop → Produkty“ (`AdminEshopProductsPage`) v admin SPA umožňuje CRUD nad produkty (název, slug, cena v haléřích, měna, aktivita, krátký + HTML popis, hlavní obrázek s náhledem, režim skladu).
|
||||
- Pod každým produktem je sekce Varianty s plným CRUD (přidání / úprava / smazání) napojeným na admin API (`eshop_product_variants`).
|
||||
|
||||
## 6. Checkout & objednávky (GoCardless / GoPay – MVP plateb) (✔ hotovo – MVP)
|
||||
|
||||
- ✔ **Backend (eshop-backend)**
|
||||
- `POST /api/v1/eshop/checkout`:
|
||||
- zvaliduje košík, vytvoří draft `EshopOrder` se stavem `awaiting_payment`.
|
||||
- podle konfigurace zvolí poskytovatele plateb:
|
||||
- pokud `GOCARDLESS_ENABLED=true` → použije GoCardless,
|
||||
- jinak pokud `GOPAY_ENABLED=true` → použije GoPay,
|
||||
- jinak vytvoří záznam `EshopPayment` typu `manual_email`.
|
||||
- pro GoPay/GoCardless vrátí `payment_redirect_url` pro frontend.
|
||||
- pro fallback vrátí `manual_payment=true` + `contact_email` (kam objednávku poslat ručně).
|
||||
- ✔ **Frontend (eshop-frontend)**
|
||||
- `/cart` má krok „Pokračovat k platbě“ → jednoduchý checkout wizard.
|
||||
- Po úspěšném `checkout`:
|
||||
- pokud je `payment_redirect_url`, přesměruje uživatele na platební bránu (GoPay / GoCardless),
|
||||
- pokud je `manual_payment=true`, zobrazí hlášku „Online platba není aktuálně dostupná, objednávku prosím pošlete na tento e‑mail…“ a zároveň přesměruje na stránku shrnutí objednávky `/objednavka/dekujeme`.
|
||||
- Checkout stránka má vylepšené UX:
|
||||
- inline validace povinných polí (e‑mail, jméno, příjmení, telefon),
|
||||
- lepší stavy pro Packeta widget (načítání, chyba konfigurace),
|
||||
- v rekapitulaci zobrazuje cenu dopravy a celkovou částku k úhradě podle nastavení backendu.
|
||||
|
||||
## 7. Doprava – Packeta (Zásilkovna) (✔ hotovo)
|
||||
|
||||
- ✔ **Frontend (checkout)**
|
||||
- Na stránce checkoutu načítat Packeta widget (`widget.packeta.com`).
|
||||
- Uložení vybraného výdejního místa / adresy a předání na backend.
|
||||
- ✔ **Backend (eshop-backend)**
|
||||
- Po `paid` objednávce vytvořit Packeta zásilku (`createPacket`) a uložit `packet_id` do `EshopShippingLabel`.
|
||||
- Endpoint pro stažení PDF štítku.
|
||||
- Cron/background job pro pravidelné dotahování stavů (`packetStatus`) a update `EshopShippingLabel.Status`.
|
||||
|
||||
## 8. DeepSeek AI zákaznická podpora (✔ hotovo – MVP)
|
||||
|
||||
- ✔ **Backend (eshop-backend)**
|
||||
- `POST /api/v1/eshop/support/chat/stream` – proxy na DeepSeek (`deepseek-chat`), `stream=true`.
|
||||
- Použití systémového promptu z `eshop/DeepSeekSupportPrompt.md`.
|
||||
- Doplnění kontextu (store info, poslední objednávky uživatele).
|
||||
- ✔ **Frontend (eshop-frontend)**
|
||||
- Malý chat widget v rohu (plovoucí okno) napojený na stream endpoint.
|
||||
- Odpovědi primárně česky, podle promptu.
|
||||
|
||||
## 9. E‑shop Setup (druhý krok po MyClub Setup) (✔ hotovo)
|
||||
|
||||
- ✔ **Backend / nastavení**
|
||||
- Eshop endpointy pro čtení/uložení `EshopSettings` a `club-info`.
|
||||
- ✔ **Frontend**
|
||||
- Nová stránka „E‑shop Setup“ (`/setup`) s kroky:
|
||||
- Výchozí měna, země.
|
||||
- Kontaktní email/telefon pro podporu.
|
||||
- Základní nastavení dopravy (Packeta on/off).
|
||||
- Automatické předvyplnění názvu klubu, loga a barev z MyClub `Settings`.
|
||||
|
||||
## 10. Hardening, monitoring a testy (⬜ TODO – průběžně)
|
||||
|
||||
- ✔ **Bezpečnost & performance**
|
||||
- Rate limiting pro checkout, platby, support chat.
|
||||
- Logging a error reporting pro eshop‑backend (využít stávající `ErrorReporter`).
|
||||
- Stabilní anonymní session token pro košík a AI chat (`X-Session-Token` + cookie `eshop_session_token`).
|
||||
- ✔ **Stripe integrace**
|
||||
- Complete Stripe Payment Element integration with proper Elements wrapper
|
||||
- Enhanced Stripe webhook handler with order status updates
|
||||
- Improved error handling for payment failures
|
||||
- ✔ **Uživatelské rozhraní**
|
||||
- Enhanced order confirmation page with detailed order information
|
||||
- Better status display and next steps information
|
||||
- Improved responsive design and user experience
|
||||
- ✔ **Doprava a logistika**
|
||||
- Enhanced Packeta background job with order status synchronization
|
||||
- Better error handling for shipping status updates
|
||||
- Automatic order status updates based on shipping changes
|
||||
- ✔ **Validace a error handling**
|
||||
- Comprehensive input validation for checkout (email, phone, shipping)
|
||||
- Cart validation with inventory checks and product availability
|
||||
- Better error messages and user feedback
|
||||
- Stock validation for product variants
|
||||
- ⬜ **Testy**
|
||||
- ✔ Základní unit test pro `PacketaService.GetPacketStatus` (mockovaná API odpověď).
|
||||
- ✔ Integrační test: aktualizace stavů Packeta zásilek (`ShippingController.UpdatePacketStatuses`) s mockovaným Packeta API.
|
||||
- ✔ Integrační test: vytvoření objednávky bez online plateb (fallback `manual_email` v `CheckoutController.Checkout`).
|
||||
- ✔ Integrační test: GoPay webhook (`CheckoutController.GoPayWebhook`) pro stav `PAID` → payment `succeeded`, order `paid`, košík `completed`.
|
||||
- ✔ Integrační test: end‑to‑end flow checkout → objednávka (včetně označení košíku jako completed po úspěšné platbě).
|
||||
- ⬜ Zátěžové testy základních endpointů (produkty, košík, checkout).
|
||||
- ✔ Frontend: stránka úspěšné objednávky `/objednavka/dekujeme` se shrnutím objednávky (načtení přes `/api/v1/eshop/orders/:id`).
|
||||
- ✔ Frontend: build (`npm run build` v `eshop/frontend`) prochází včetně checkoutu, Packeta widgetu a AI support chatu.
|
||||
|
||||
---
|
||||
|
||||
## Jak tento soubor používat
|
||||
|
||||
- Pro každý blok výše můžeš:
|
||||
- doplnit konkrétní TODO podkroky,
|
||||
- značit hotové věci jako `✔` (nebo `[x]`) podle stavu projektu,
|
||||
- přidat odkazy na další dokumentaci (např. `ESHOP_IMPLEMENTATION_PLAN.md`, `packeta.md`).
|
||||
|
||||
Aktuálně je hotová infrastruktura, jádro backendu, základní e‑shop frontend, propojení s MyClub clothing, administrace produktů, checkout se Stripe platbami, Packeta integrace a AI podpora. Projekt je ve fázi MVP funkčního celku. Další kroky jsou hardening, monitoring a testování (blok 10).
|
||||
Reference in New Issue
Block a user