This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
+185
View File
@@ -0,0 +1,185 @@
# DeepSeek systémový prompt pro MyClub Eshop podporu
Tento prompt je určen jako **system message** pro model `deepseek-chat` používaný v zákaznické podpoře MyClub eshopu.
---
## 1. Role a identita asistenta
Jsi **virtuální asistent zákaznické podpory** pro fotbalový klubový eshop **MyClub**.
- Prodáváme zejména **klubový merchandise** (dresy, trička, mikiny, šály, čepice, suvenýry), případně **permanentky, vstupenky a dárkové poukazy**.
- Eshop 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 **ZBOX**.
- 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 **24 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 eshopu 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ě 23 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 eshopu.\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 eshopu 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 eshopu.
+348
View File
@@ -0,0 +1,348 @@
# MyClub Eshop Implementační plán
> Cíl: přidat volitelný MyClub eshop 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ý eshop 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 eshopu v existujícím `/admin` (nové sekce „Eshop“ / „Produkty“, „Objednávky“, „Nastavení eshopu“).
- **UI/UX**
- Světlý + tmavý režim, jednoduché, mobilfirst.
- Barvy z klubového theme (z inicializačního Setupu) eshop 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 + eshop subdoménu.
---
## 2. Konfigurace přes .env
### 2.1 Klíčové flagy
- `ESHOP_ENABLED=true|false`
- `true` → Docker Compose spustí i eshop backend/frontend, setup přidá eshop 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=...` (16znakový 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 eshopu
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, GDPRsafe).
- **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é eshop stránky (checkout, košík, historie objednávek pro přihlášené).
- Role guardy pro admin API (jen admin/editor s právem eshop).
### 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í eshopu
- 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 eshop frontend origin mezi povolené.
- V eshop frontendu používat stejný mechanismus přihlášení (sdílené služby/axios klient nebo lehké proxy).
---
## 7. Frontend veřejný eshop (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 eshopu 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 eshopu
- V existujícím admin SPA přidat navigační kategorii „Eshop“:
- **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í eshopu** 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 eshopu, 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. **Eshop Setup** (nový krok/route, aktivní jen pokud `ESHOP_ENABLED=true`)
- URL eshopu (`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).
- Eshop Setup přebírá klubové informace (název, logo, barvy) jako výchozí.
---
## 14. Bezpečnost a performance
- Middleware pro ratelimit u eshop 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. **Eshop 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 eshop 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.
+375
View File
@@ -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)
}
}
}
+463
View File
@@ -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 Eshop podporu
Tento prompt je určen jako **system message** pro model ` + "`deepseek-chat`" + ` používaný v zákaznické podpoře MyClub eshopu.
---
## 1. Role a identita asistenta
Jsi **virtuální asistent zákaznické podpory** pro fotbalový klubový eshop **MyClub**.
- Prodáváme zejména **klubový merchandise** (dresy, trička, mikiny, šály, čepice, suvenýry), případně **permanentky, vstupenky a dárkové poukazy**.
- Eshop 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 **ZBOX**.
- 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 **24 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 eshopu 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ě 23 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 eshopu.\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 eshopu 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 eshopu.
`
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
},
}
}
+2
View File
@@ -0,0 +1,2 @@
REACT_APP_API_URL=/api/v1/eshop
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_TYooMQauvdEDq54NiTphI7jx
+2
View File
@@ -0,0 +1,2 @@
REACT_APP_API_URL=/api/v1/eshop
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_...
+45
View File
@@ -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;"]
+10
View File
@@ -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"
]
}
+1
View File
@@ -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
+25
View File
@@ -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;
}
}
+16688
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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"
]
}
}
+13
View File
@@ -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>
+67
View File
@@ -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">
&copy; {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ý email.',
},
]);
} 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 eshopu. <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;
+33
View File
@@ -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;
+113
View File
@@ -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;
+475
View File
@@ -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;
+251
View File
@@ -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 ()</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;
+86
View File
@@ -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
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+220
View File
@@ -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;
}
+33
View File
@@ -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',
},
},
},
});
+21
View File
@@ -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"]
}
+188
View File
@@ -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!
+168
View File
@@ -0,0 +1,168 @@
# MyClub Eshop Timeline
> Přehled kroků pro dokončení eshopu. Položky označené `✔` jsou hotové, `⬜` čekají na dokončení.
## 1. Infrastruktura a zapínání eshopu (✔ 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 + eshop.
## 2. Eshop 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 besteffort `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. Eshop 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 eshopu“, když je `REACT_APP_ESHOP_URL` nastavené.
- `MerchSection` na homepage vedle „Zobrazit vše“ přidán button „Eshop“ → plný eshop.
-**Admin MyClub**
- `AdminMerchPage` ukazuje informační box s odkazem na plný eshop, pokud `REACT_APP_ESHOP_URL` existuje.
-**Frontend env**
- `frontend/.env(.example)` přidáno `REACT_APP_ESHOP_URL`.
## 5. Eshop 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 „Eshop → 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 email…“ 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í (email, 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. Eshop Setup (druhý krok po MyClub Setup) (✔ hotovo)
-**Backend / nastavení**
- Eshop endpointy pro čtení/uložení `EshopSettings` a `club-info`.
-**Frontend**
- Nová stránka „Eshop 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 eshopbackend (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: endtoend 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í eshop 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).