# Vlastní frontend balíčku: návod pro agentury

> Doplněk je headless: backend dodá data i závaznou cenu, celý frontend balíčku si postavíte sami. Co musí splnit každá implementace a co je na vás.

- **Zdroj:** https://www.webotvurci.cz/navody/sestav-si-vlastni-balicek/vlastni-frontend-balicku-pro-agentury/
- **Doplněk / kategorie:** Sestav si vlastní balíček (https://www.webotvurci.cz/navody/sestav-si-vlastni-balicek/)
- **Datum:** 2026-08-12
- **Autor:** Webotvůrci (https://www.webotvurci.cz/)

---

Většina doplňků pro Shoptet přichází s hotovým widgetem. Ten widget vypadá, jak vypadá, a když se
klientovi nelíbí, začíná kolotoč: přebíjení stylů `!important`, hledání selektorů v cizím markupu
a doufání, že příští verze doplňku nic nerozbije.

„Sestav si vlastní balíček" jde jinou cestou. Doplněk **žádný widget neservíruje**. Backend
garantuje dvě věci — data, ze kterých zákazník vybírá, a závaznou cenu výsledné sady. Všechno
mezi tím je vaše.

Tenhle text je pro agentury a vývojáře, kteří si nad doplňkem staví **vlastní widget pro Shoptet**
na míru klientově šabloně. Průběžně používáme smyšlený e-shop `www.example.cz`, `projectId`
**123456** a konfiguraci **„Kávový balíček"** — 3 sloty, sleva 15 %.

## Proč headless a co z toho má agentura

**Nikdo cizí nesahá do kódu klienta.** Nasazujete dva vlastní soubory (JS a CSS) a jednu HTML
vložku. Do šablony nevstupuje žádný externí skript, který byste neviděli, a my do vašeho kódu
nevidíme. Když klient řeší bezpečnostní audit nebo má vlastní build pipeline, tohle je rozdíl
mezi „jde to" a „nejde to".

**Design je váš, ne náš.** Konfigurátor můžete postavit jako tři sloty vedle sebe, jako průvodce
po krocích i jako mřížku s počítadlem. Sedne do brand manuálu klienta, ne do našeho.

**Žádný cizí objekt v `window`.** Nemáme globální proměnnou, kterou byste museli respektovat,
ani API, které by vás nutilo do konkrétní struktury DOM. Zbývá jediná závislost — dvě HTTP volání
a jedna nativní funkce Shoptetu.

Cena té svobody je jednoduchá: musíte dodržet kontrakt níž.

## Závazný kontrakt: co musí splnit každá implementace

Osm bodů. Cokoli mimo ně je vaše rozhodnutí.

1. **Počet slotů berte z `product_count`.** Konfigurace může mít 2 až 5 slotů a obchodník ji může
   kdykoli změnit v administraci — natvrdo zadrátovaná trojka vám při změně tiše rozbije widget.
2. **Respektujte `list_mode`.** V režimu `same_lists` existuje jeden společný seznam kategorií
   a produktů, ze kterého zákazník vybírá do všech slotů. V režimu `separate_lists` má každý slot
   vlastní nabídku — do slotu *N* patří jen kategorie, které mají `column_number === N`.
3. **Kategorie si seřaďte sami.** Pořadí, ve kterém dorazí, není součástí kontraktu; řaďte podle
   `column_number` a pak podle `name` s `localeCompare(…, 'cs')`, jinak se sloty mezi načteními
   přehazují.
4. **GUIDy před odesláním seřaďte.** Backend rozpozná už vytvořenou sadu podle spojených GUIDů
   a ten klíč je citlivý na pořadí — stabilní řazení tedy zajistí, že stejná kombinace nezaloží
   v katalogu druhý produkt.
5. **Do košíku vkládejte výhradně přes `shoptet.cartShared.addToCart` s kódem sady.** Nativní
   funkce řeší CSRF token, přepočet košíku i UI feedback šablony; vlastní `fetch` na košíkové
   akce Shoptet blokuje.
6. **Závazná cena je `price` z odpovědi POSTu.** Cokoli spočítáte na frontendu, je orientační
   náhled — po odeslání zobrazenou cenu přepište hodnotou z odpovědi, aby zákazník nikdy neviděl
   jiné číslo, než jaké dostane v košíku.
7. **Data z API escapujte před vložením do DOM.** Názvy produktů a kategorií plní lidé
   v administraci e-shopu a klidně obsahují `&`, `<` nebo uvozovky.
8. **Kód sady neukládejte na později a posílejte jen GUIDy z konfigurace.** Kód platí pro
   aktuální nákup; pro opakovaný nákup si vyžádejte nový POSTem se stejným (seřazeným) výběrem.

Kompletní popis polí, tvarů odpovědí a okrajových případů je v
[dokumentaci na GitHubu](https://github.com/Webotvurci-s-r-o/shoptet-premium-multipack/blob/main/docs/04-vlastni-frontend.md)
— sem ho neopisujeme, protože je to živý dokument.

## Co je naopak čistě na vás

| Oblast | Doporučení z praxe |
|---|---|
| **Layout** | Sloty vedle sebe pro 2–3 krátké seznamy, průvodce po krocích při 20+ produktech na slot, mřížka s počítadlem pro `same_lists`. |
| **Zobrazení úspory** | Nestačí finální cena. Funguje trojice: ~~917 Kč~~ **779,45 Kč**, *ušetříte 137,55 Kč (15 %)*. Slevu dejte i jako badge nad konfigurátor. |
| **Obrázky produktů** | API vrací název souboru, URL skládáte z hostu e-shopu a varianty Shoptet CDN. Vždy nastavte `width`/`height` nebo `aspect-ratio` (CLS) a mějte placeholder pro prázdný slot. |
| **Hodnocení** | Hvězdičky zobrazujte jen když je počet hodnocení větší než nula — „0 hvězdiček" vypadá jako špatná recenze, ne jako chybějící údaj. |
| **Dostupnost** | `> 5` → „Skladem", `1–5` → „Skladem N ks" (vytváří tlak), `≤ 0` → „Vyprodáno" a produkt ve výběru zablokovat. |
| **Stepper množství** | Řeší počet balíčků, ne kusů uvnitř. Není povinný; když ho máte, klampujte hodnotu na `1..N` a ošetřete ruční zápis do inputu. |
| **Animace** | Nejsilnější efekt má vizualizace balíčku, která se plní — tři rámečky, do kterých po výběru naskočí obrázek. Držte se pod 250 ms a respektujte `prefers-reduced-motion`. |
| **Prázdné a chybové stavy** | Skeleton při načítání, prázdný slot jako pozvánka („Vyberte kávu"), chyba se srozumitelnou hláškou a tlačítkem „Zkusit znovu". Nikdy jen do konzole. |
| **Mobil** | Tři sloty se na 360 px nevejdou — stack nebo carousel se snapem, sticky souhrn s tlačítkem u spodní hrany, cíle minimálně 44 × 44 px. |

## Startovací implementace

V repozitáři je funkční starter:
[`examples/starter`](https://github.com/Webotvurci-s-r-o/shoptet-premium-multipack/tree/main/examples/starter).
Vanilla ES6+, žádné závislosti, žádný build step — nahrajete dva soubory a jede to. Zvládá 2 až 5
slotů, oba režimy seznamů, ceny podle měny a ceníku, loading i chybové stavy.

Není to povinný základ. Berte ho jako referenci, ze které si klidně vezmete jen datovou vrstvu.

| Co upravujete | Co se tím mění | Jak často |
|---|---|---|
| Blok `MULTIPACK_CONFIG` | Shortcode, selektor obsahu, varianta obrázků z CDN, práh skladu, maximální množství | Vždy |
| Blok `MULTIPACK_TEXTS` | Všechny texty pro zákazníka včetně pluralizace a popisků pro čtečky | Skoro vždy |
| CSS proměnné `--wtm-*` | Barvy, rádiusy, mezery, stíny. Primární barva se čte jako `var(--color-primary, …)`, takže na většině šablon sedne sama | Skoro vždy |
| Metody `render*()` | Samotný markup — `renderSlot()`, `renderCard()`, `renderSummary()`, `renderRating()`. Vrací HTML řetězce | Když chcete jiný layout |

Konfigurace je celá na jednom místě na začátku souboru:

```js
const MULTIPACK_CONFIG = {
    baseUrl: 'https://shoptet.webotvurci.cz',
    shortcode: 'w-multipack-ABC12',   // váš shortcode, bez hranatých závorek
    contentSelector: '.content-inner',
    imageCdnBase: 'https://cdn.myshoptet.com/usr',
    imageVariant: 'orig',
    stockThreshold: 5,
    disableSoldOut: true,
    useApiPriceInSummary: true,
};
```

Shortcode v konfiguraci je jen výchozí hodnota — když je ve stránce `[w-multipack-ABC12]`, vyhrává
ID ze stránky. Jedním nahraným skriptem tak obsloužíte libovolný počet stránek s různými
konfiguracemi, což je při správě většího e-shopu zásadní úspora.

Markup jednoho slotu vypadá takhle. Všimněte si, že každý řetězec z API prochází helperem `esc()`
a že se kategorie vykreslují jako `<optgroup>` — pokud přepnete roletky za dlaždice, měníte jen
tuhle metodu, zbytek widgetu o tom neví:

```js
renderSlot(slot, index) {
    const selectId = `${this.uid}-slot-${index}`;
    return `
        <div class="wt-multipack__slot">
            <label class="wt-multipack__slot-label" for="${selectId}">${esc(slot.label)}</label>
            <div class="wt-multipack__select-wrap">
                <select class="wt-multipack__select" id="${selectId}" data-slot-select="${index}">
                    <option value="">${esc(this.texts.selectPlaceholder)}</option>
                    ${this.renderOptions(slot.categories)}
                </select>
            </div>
            <div class="wt-multipack__card" data-slot-card="${index}">${this.renderCard(index)}</div>
        </div>`;
}
```

Celý zdroják včetně komentářů je v
[`examples/starter/multipack.js`](https://github.com/Webotvurci-s-r-o/shoptet-premium-multipack/blob/main/examples/starter/multipack.js).

## Doporučené vzory

**Stav držte v objektu, ne v DOM.** Jeden objekt s výběrem, množstvím, stavem a chybou, ze kterého
renderujete. Jakmile začnete stav číst zpátky z `<select>`ů a `textContent`, přijdou nekonzistence —
zákazník klikne během requestu, šablona vám prvek překreslí, jiný doplněk přepíše kontejner.

**Event delegation.** Jeden listener na kořeni konfigurátoru místo N listenerů na dlaždicích.
Přežije překreslení obsahu a nemusíte nic odvěšovat.

**Orientační cena lokálně, závazná z odpovědi.** Během výběru počítejte náhled na frontendu, ať má
zákazník okamžitou zpětnou vazbu, a po úspěšném POSTu ho přepište:

```js
async function submit() {
    setState({ status: 'submitting', error: null });
    const data = await createSet(guidsSorted());
    setState({ status: 'ready', setPrice: data.price });   // závazná cena přepíše odhad
    shoptet.cartShared.addToCart({ productCode: data.code, amount: state.quantity });
}
```

Zobrazujte `state.setPrice ?? estimate()` a při každé změně výběru `setPrice` vynulujte.

**Ochrana proti dvojkliku.** Každé odeslání zakládá v e-shopu reálný produkt, takže single-flight
guard není volitelný — druhý klik má dostat tentýž `Promise`:

```js
let inFlight = null;

async function createSet(guids) {
    if (inFlight) return inFlight;
    inFlight = fetch(URL_PRODUCT_SET, { /* … */ })
        .then((res) => { if (!res.ok) throw new Error('multipack:' + res.status); return res.json(); })
        .finally(() => { inFlight = null; });
    return inFlight;
}
```

Samotné `disabled` na tlačítku nestačí — mezi kliknutím a nastavením atributu je několik
milisekund. Doplňte ho o viditelný spinner.

## UX checklist před spuštěním

Odklikejte na testovací konfiguraci, ideálně přímo na e-shopu klienta:

1. Vyprodaný produkt je v nabídce zablokovaný a nejde ho dostat do sady.
2. Produkt bez ceny v dané cenové hladině se nezobrazuje jako „0 Kč" a nekazí součet.
3. Na pomalé síti (Slow 4G) je vidět skeleton, ne prázdno ani holý text shortcodu.
4. Když API nedopoví při načtení, je vidět srozumitelná chyba a tlačítko „Zkusit znovu".
5. Když selže odeslání, tlačítko je znovu klikatelné a výběr zůstal zachovaný.
6. Rychlý dvojklik na „Koupit" odešle jeden požadavek a do košíku spadne jedna položka.
7. Nekompletní výběr drží tlačítko `disabled` a je vidět, kolik ještě chybí.
8. Změna výběru po zobrazení ceny přepočítá souhrn, nezůstane viset stará hodnota.
9. Přihlášený zákazník s cenovou hladinou vidí ceníkové ceny; ověřte i odhlášení.
10. XSS test: dočasně přejmenujte testovací produkt na `Test <img src=x onerror=alert(1)> "uvozovky" & ampersand` a projděte celý flow.
11. Mobil 360 px — vejde se vše, sticky lišta nepřekrývá poslední slot.
12. Klávesnice a čtečka — projde se Tabem, sloty mají `<label>`, chyba je v `aria-live`.

## FAQ pro agentury

**Můžeme použít React nebo Vue?**
Ano, API je framework-agnostické — dva HTTP endpointy a jedna funkce Shoptetu. Ohlídejte si ale
velikost bundle: konfigurátor běží uvnitř cizí šablony, která už načítá jQuery, slider, analytiku
a další doplňky. React + ReactDOM navíc na landing page, kde jde o konverzi, se na mobilu pozná.
Preact, Svelte, Alpine nebo prostě vanilla dávají v tomhle kontextu větší smysl. Když framework
použijete, mountujte ho do vlastního elementu a nesahejte na okolní DOM.

**Musíme použít váš starter?**
Ne. Je to ukázka, ne závazek — nemusíte z něj přebírat strukturu, názvy CSS tříd ani texty. Když
si napíšete vlastní implementaci a dodržíte kontrakt výš, funguje to úplně stejně. Když si ho
forknete, je váš.

**Jak testovat bez zásahu do produkce?**
Vyžádejte si u nás testovací konfiguraci — vlastní shortcode nad testovacími produkty
s izolovaným obsahem a bez dopadu na ostrou landing page. Frontend si přitom můžete vyvíjet
lokálně na `localhost`, CORS je otevřený a proxy nepotřebujete.

**Co když Shoptet změní šablonu?**
Naše API je nezávislé na šabloně i na verzi Shoptetu, takže se tím nemění. Shoptet dataLayer
a `shoptet.cartShared` jsou navíc oficiální rozhraní Shoptetu pro doplňky — nemění se svévolně
a případné změny Shoptet komunikuje předem. Reálné riziko je jinde: v selektorech, kterými se váš
kód váže na markup šablony. Držte jich co nejmíň, mějte je na jednom místě v konfiguraci
a ošetřete případ, kdy prvek neexistuje.

**Můžeme mít na jedné stránce dvě konfigurace?**
Ano, jsou to dva nezávislé shortcody. Ve svém kódu ale musíte iterovat přes všechny nálezy
a držet stav per instanci, ne v globálních proměnných.

## Kam dál

- [Dokumentace vlastního frontendu na GitHubu](https://github.com/Webotvurci-s-r-o/shoptet-premium-multipack/blob/main/docs/04-vlastni-frontend.md) — kompletní kontrakt, API reference a okrajové případy.
- [Startovací implementace `examples/starter`](https://github.com/Webotvurci-s-r-o/shoptet-premium-multipack/tree/main/examples/starter) — funkční widget k rozebrání.
- [Sestav si vlastní balíček](https://www.webotvurci.cz/sluzby/shoptet/shoptet-premium-e-shop-bez-limitu/dynamicke-sady-balicky-na-miru/) — co doplněk umí, ceník a živá ukázka.

Stavíte balíček pro klienta a chcete si předem projít zadání nebo si vyžádat testovací
konfiguraci? Napište nám na [jsme@webotvurci.cz](mailto:jsme@webotvurci.cz) — rádi vám k tomu
sedneme.

---

*Návod pochází z webu Webotvůrci — Zlatý a Premium partner Shoptetu.*
*Všechny návody: https://www.webotvurci.cz/navody/ · Kontakt: jsme@webotvurci.cz*
