@behio/storefront-sdk 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +314 -27
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -788,49 +788,336 @@ const { data: pages } = usePages('cs');
788
788
  const { data: page } = usePage('about-us', 'cs');
789
789
  ```
790
790
 
791
- ### useBundles
792
- ```typescript
793
- import { useBundles, useBundle } from '@behio/storefront-sdk/react';
791
+ ### useBundles / useBundle
792
+
793
+ **What it is.** A "bundle" is a merchant-curated set of products sold together
794
+ at a single fixed price that's (usually) lower than buying the components
795
+ individually — think *starter kit*, *holiday gift set*, *3-for-2 deals*, or
796
+ *"breakfast combo"*. The merchant defines what goes in (which products, what
797
+ quantities, what cover image) and sets one total price for the whole thing.
798
+
799
+ **Why it matters.** Bundles are one of the highest-ROI features in e-commerce:
800
+ they raise average order value without having to discount individual products,
801
+ they give customers a clear "good deal" signal (the savings badge), and they
802
+ let you clear slow-moving inventory by pairing it with fast-movers. Most eshop
803
+ platforms treat bundles as paid add-ons or plugins — here it's native.
804
+
805
+ **Where you use it.**
806
+
807
+ - **Homepage / landing pages** — list active bundles as hero cards with cover
808
+ image + `-25%` badge. Drives impulse purchase.
809
+ - **Category pages** — show a relevant bundle ("vše na grilování") next to
810
+ individual products in the same category.
811
+ - **Cart / checkout** — suggest a bundle as upsell ("Přidejte ještě tohle a
812
+ dostanete celý set s 20% slevou").
813
+ - **Dedicated `/bundles` page** — marketing landing for all active sets.
814
+
815
+ **What's in the data:**
816
+
817
+ - `bundlePrice` — what the customer pays for the whole set
818
+ - `itemsSum` — what the components would cost individually (sum of default prices)
819
+ - `savings` — `itemsSum - bundlePrice` (absolute savings in the currency)
820
+ - `savingsPercent` — pre-computed percent so you don't have to do the math in JSX
821
+ - `items[]` — the components with their quantities (for display and stock check)
822
+ - `endsAt` — optional expiry timestamp; if set, the bundle auto-deactivates
823
+
824
+ ```tsx
825
+ import { useBundles, useBundle, useCart, useBehio } from '@behio/storefront-sdk/react';
794
826
 
795
- // List
796
- const { data } = useBundles();
797
- data?.items.forEach((b) => console.log(b.name, b.savingsPercent, '%'));
827
+ // ---- Homepage hero: all active bundles ----
828
+ function BundlesGrid() {
829
+ const { data, isLoading } = useBundles();
830
+ if (isLoading) return <Skeleton />;
831
+ if (!data?.items.length) return null; // no active bundles → hide section
832
+
833
+ return (
834
+ <section className="grid grid-cols-3 gap-4">
835
+ {data.items.map((bundle) => (
836
+ <a key={bundle.id} href={`/bundle/${bundle.slug}`} className="relative">
837
+ {bundle.coverImage && <img src={bundle.coverImage} alt={bundle.name} />}
838
+ <h3>{bundle.name}</h3>
839
+ <div>
840
+ <strong>{bundle.bundlePrice} {bundle.currency}</strong>
841
+ {bundle.savings > 0 && (
842
+ <>
843
+ <s>{bundle.itemsSum} {bundle.currency}</s>
844
+ <span className="badge">-{bundle.savingsPercent}%</span>
845
+ </>
846
+ )}
847
+ </div>
848
+ <p>{bundle.items.length} produktů ušetříte {bundle.savings} {bundle.currency}</p>
849
+ </a>
850
+ ))}
851
+ </section>
852
+ );
853
+ }
798
854
 
799
- // Detail
800
- const { data: bundle } = useBundle('startovaci-balicek');
801
- // bundle: { name, bundlePrice, itemsSum, savings, items: [{ productId, quantity, ... }] }
855
+ // ---- Bundle detail page with "Add to cart" ----
856
+ function BundleDetail({ slug }: { slug: string }) {
857
+ const { data: bundle, isLoading } = useBundle(slug);
858
+ const { client } = useBehio();
859
+ const { refresh } = useCart();
802
860
 
803
- // Add to cart — uses existing useCart hook
804
- const { cart, refresh } = useCart();
805
- await shop.cart.addBundle(bundle.id, 1);
806
- await refresh();
861
+ if (isLoading) return <Skeleton />;
862
+ if (!bundle) return <NotFound />;
863
+
864
+ async function addToCart() {
865
+ await client.cart.addBundle(bundle.id, 1);
866
+ await refresh(); // cart badge updates everywhere
867
+ }
868
+
869
+ return (
870
+ <article>
871
+ <h1>{bundle.name}</h1>
872
+ <p>{bundle.description}</p>
873
+
874
+ <ul>
875
+ {bundle.items.map((item) => (
876
+ <li key={item.productId}>
877
+ {item.quantity}× {item.name}
878
+ {item.defaultPrice && (
879
+ <span className="text-muted">
880
+ ({item.defaultPrice} {bundle.currency} /ks běžně)
881
+ </span>
882
+ )}
883
+ </li>
884
+ ))}
885
+ </ul>
886
+
887
+ <div className="price-box">
888
+ <strong>{bundle.bundlePrice} {bundle.currency}</strong>
889
+ {bundle.savings > 0 && (
890
+ <p>
891
+ Jednotlivě by stálo <s>{bundle.itemsSum} {bundle.currency}</s> —
892
+ ušetříte <strong>{bundle.savings} {bundle.currency}</strong>
893
+ ({bundle.savingsPercent}%)
894
+ </p>
895
+ )}
896
+ <button onClick={addToCart}>Přidat celý balíček do košíku</button>
897
+ </div>
898
+ </article>
899
+ );
900
+ }
807
901
  ```
808
902
 
903
+ **Behind the scenes at checkout.** When the customer buys a bundle, Behio
904
+ rozpadá balíček do order-itemů s proporčně rozpočítanou cenou podle
905
+ defaultních cen komponent (zachováno pro účetnictví a skladové odpisy), ale
906
+ total na faktuře odpovídá `bundlePrice` — zákazník vidí jednu celistvou
907
+ položku, sklady se odepisují správně z komponent.
908
+
909
+ ---
910
+
809
911
  ### useCrossSell
810
- ```typescript
811
- import { useCrossSell } from '@behio/storefront-sdk/react';
812
912
 
813
- const { data } = useCrossSell(product.slug);
814
- // data: { related: CrossSellItem[], upsell: CrossSellItem[], crossSell: CrossSellItem[] }
913
+ **What it is.** Three related lists of product recommendations shown on a
914
+ product detail page:
915
+
916
+ - **Related (Podobné)** — alternativy ke stejnému účelu (jiné vodítko, jiné
917
+ krmivo). Když tě tenhle produkt zaujal, tady jsou jiné stejné kategorie.
918
+ - **Upsell (Lepší varianta)** — dražší / vybavenější verze. „Vidíš levný
919
+ obojek? Tady je verze s koženým prošitím za 2×." Cílem je zvýšit
920
+ average order value.
921
+ - **Cross-sell (Často kupováno s)** — komplementární produkty. K obojku
922
+ vodítko, k misce čistič. Čistý AOV boost na PDP a v košíku.
923
+
924
+ **Why to split them.** Každý z těchto typů má jinou UX roli a měla by být
925
+ různě formulována. Když se to smíchá, ztratí se kontext — zákazník nepozná,
926
+ proč mu to zobrazuješ.
927
+
928
+ **Where to use it.**
929
+
930
+ - **Product detail page** — tři samostatné sekce pod popisem (nebo v sidebar
931
+ sloupci). Nejvyšší konverzní dopad je **Cross-sell "často kupováno s"**
932
+ přímo u tlačítka "Přidat do košíku".
933
+ - **Cart sidebar** — mini-cross-sell widget ("Nezapomeňte ještě na tohle").
934
+ - **Post-purchase page** — "Chcete ještě tohle?" pro druhou objednávku.
935
+
936
+ **Pozor.** Endpoint vrací **jen aktivní produkty ve stejném eshopu**. Když
937
+ jsi v adminu nějaký linknul a pak ho deaktivoval/smazal, zmizí ze seznamu
938
+ automaticky — nemusíš to řešit na FE.
939
+
940
+ ```tsx
941
+ import { useCrossSell, useBehio } from '@behio/storefront-sdk/react';
942
+
943
+ function CrossSellSection({ title, items }: { title: string; items: CrossSellItem[] }) {
944
+ if (!items?.length) return null; // nezobrazuj prázdnou sekci
945
+ return (
946
+ <section>
947
+ <h2>{title}</h2>
948
+ <div className="carousel">
949
+ {items.map((item) => (
950
+ <a key={item.productId} href={`/produkt/${item.slug}`} className="card">
951
+ {item.imageUrl && <img src={item.imageUrl} alt={item.name} />}
952
+ <h4>{item.name}</h4>
953
+ <span>{item.price} Kč</span>
954
+ {item.stockCached === 0 && <span className="text-red">Vyprodáno</span>}
955
+ </a>
956
+ ))}
957
+ </div>
958
+ </section>
959
+ );
960
+ }
961
+
962
+ function ProductDetail({ slug }: { slug: string }) {
963
+ const { data: crossSell } = useCrossSell(slug);
964
+
965
+ return (
966
+ <>
967
+ {/* ... product info ... */}
968
+
969
+ <CrossSellSection
970
+ title="Často kupováno s"
971
+ items={crossSell?.crossSell ?? []}
972
+ />
973
+ <CrossSellSection
974
+ title="Možná vás zaujme i"
975
+ items={crossSell?.related ?? []}
976
+ />
977
+ <CrossSellSection
978
+ title="Chcete raději lepší variantu?"
979
+ items={crossSell?.upsell ?? []}
980
+ />
981
+ </>
982
+ );
983
+ }
984
+ ```
985
+
986
+ **Tip — merge do jedné sekce.** Pokud máš málo content a chceš to zjednodušit:
815
987
 
816
- // Render sections on product detail page
817
- <CrossSellSection title="Často kupováno s" items={data?.crossSell} />
818
- <CrossSellSection title="Podobné produkty" items={data?.related} />
988
+ ```tsx
989
+ const allRecommendations = [
990
+ ...(crossSell?.crossSell ?? []),
991
+ ...(crossSell?.related ?? []),
992
+ ...(crossSell?.upsell ?? []),
993
+ ].slice(0, 6);
819
994
  ```
820
995
 
996
+ ---
997
+
821
998
  ### useProductPromotions
822
- ```typescript
999
+
1000
+ **What it is.** Vrací aktuálně platné akce (`Eshop_Promotion`) týkající se
1001
+ konkrétního produktu — tedy všechny promotions, kde `startsAt <= teď < endsAt`
1002
+ a produkt spadá do scope akce (může to být `ALL_PRODUCTS`, `SPECIFIC_PRODUCTS`,
1003
+ `CATEGORIES` nebo `LABELS`, backend to vyřeší za tebe).
1004
+
1005
+ **Co to není.** Není to výpočet *konečné ceny po slevě* — na to je samostatná
1006
+ vrstva (cart evaluator). Tenhle hook je čistě pro **zobrazovací vrstvu**: badge,
1007
+ countdown, upozornění „akční cena do půlnoci".
1008
+
1009
+ **Proč je to oddělené.** Konverzně nejsilnější marketingový prvek v eshopu je
1010
+ **urgency + scarcity**. „Do konce akce: 2h 14m 37s" přímo na PDP zvyšuje
1011
+ conversion rate měřitelně — je to letitá best practice (Amazon Lightning
1012
+ Deals, Booking "rezervováno 3× za posledních 24h", atd.). Hook ti dodá data,
1013
+ ty uděláš countdown komponentu.
1014
+
1015
+ **Kde to použít.**
1016
+
1017
+ - **Product card v listu** — badge `-20%` nebo vlajka „AKCE".
1018
+ - **Product detail** — velký banner s countdown nad cenou: „Koupíte-li do
1019
+ půlnoci, ušetříte 200 Kč."
1020
+ - **Cart** — varování „Vaše sleva vyprší za 5 minut" aby tlačilo k
1021
+ dokončení objednávky.
1022
+
1023
+ **Pole z API:**
1024
+
1025
+ - `id`, `name`, `slug` — identifikace akce
1026
+ - `type` — `FLASH_SALE` / `SEASONAL` / `CLEARANCE` / `BOGO` / `BUNDLE` / `LOYALTY`
1027
+ - `discountType` — `PERCENTAGE` / `FIXED_AMOUNT` / `FREE_SHIPPING` / `BUY_X_GET_Y`
1028
+ - `discountValue` — hodnota (u PERCENTAGE je to %, u FIXED_AMOUNT Kč, …)
1029
+ - `endsAt` — timestamp konce. Pokud `null`, akce je na neurčito.
1030
+ - `badgeText`, `badgeColor` — merchant ručně nastavil text a barvu badge
1031
+ (např. „-30%" s červenou); pokud oboje `null`, vymysli default podle `discountType`.
1032
+ - `showCountdown` — **respect this!** Když merchant nechce countdown, nerender
1033
+ ho. Ne všechny akce (CLEARANCE, LOYALTY) mají smysl odpočítávat.
1034
+ - `couponRequired` — akce se uplatní jen po zadání kódu na checkoutu.
1035
+ Na PDP ukaž info box („Použijte kód LETO pro uplatnění"), neukazuj jako
1036
+ automatickou slevu.
1037
+
1038
+ ```tsx
823
1039
  import { useProductPromotions } from '@behio/storefront-sdk/react';
1040
+ import { useEffect, useState } from 'react';
824
1041
 
825
- // Auto-refetch every 5 seconds so the countdown stays accurate on long-lived pages
826
- const { data } = useProductPromotions(product.slug, { refetchIntervalMs: 5000 });
1042
+ function ProductPromotionBanner({ slug }: { slug: string }) {
1043
+ // Refetch každé 4 minuty aby se promotion aktualizovala když mezitím
1044
+ // skončila jedna a začala další. Pro pouhý countdown nemusíš refetchovat —
1045
+ // countdown děláš lokálně z endsAt.
1046
+ const { data } = useProductPromotions(slug, { refetchIntervalMs: 4 * 60_000 });
1047
+ const promotions = data?.items ?? [];
827
1048
 
828
- data?.items.forEach((promo) => {
829
- // promo: { name, discountType, discountValue, endsAt, badgeText, badgeColor, ... }
830
- // Use endsAt to render a live countdown: new Date(promo.endsAt) - Date.now()
831
- });
1049
+ if (!promotions.length) return null;
1050
+
1051
+ // Nejvyšší priority akce (BE vrací seřazené priority DESC + createdAt ASC)
1052
+ const promo = promotions[0];
1053
+
1054
+ const badge = promo.badgeText ?? formatDefaultBadge(promo);
1055
+ const badgeColor = promo.badgeColor ?? '#ef4444';
1056
+
1057
+ return (
1058
+ <div className="promo-banner" style={{ backgroundColor: badgeColor + '20', borderColor: badgeColor }}>
1059
+ <div>
1060
+ <span className="badge" style={{ backgroundColor: badgeColor }}>{badge}</span>
1061
+ <strong>{promo.name}</strong>
1062
+ {promo.couponRequired && (
1063
+ <p>Uplatníte zadáním kódu <code>{promo.name}</code> v košíku</p>
1064
+ )}
1065
+ </div>
1066
+ {promo.showCountdown && promo.endsAt && <Countdown endsAt={promo.endsAt} />}
1067
+ </div>
1068
+ );
1069
+ }
1070
+
1071
+ function Countdown({ endsAt }: { endsAt: number }) {
1072
+ const [now, setNow] = useState(Date.now());
1073
+ useEffect(() => {
1074
+ const id = setInterval(() => setNow(Date.now()), 1000);
1075
+ return () => clearInterval(id);
1076
+ }, []);
1077
+
1078
+ const ms = Math.max(0, endsAt - now);
1079
+ if (ms === 0) return <span>Akce skončila</span>;
1080
+
1081
+ const d = Math.floor(ms / 86400_000);
1082
+ const h = Math.floor((ms % 86400_000) / 3600_000);
1083
+ const m = Math.floor((ms % 3600_000) / 60_000);
1084
+ const s = Math.floor((ms % 60_000) / 1000);
1085
+
1086
+ if (d > 0) return <span>Končí za {d}d {h}h {m}m</span>;
1087
+ return <span>Končí za {h}h {m}m {s}s</span>;
1088
+ }
1089
+
1090
+ function formatDefaultBadge(p: { discountType: string; discountValue: number }) {
1091
+ switch (p.discountType) {
1092
+ case 'PERCENTAGE': return `-${p.discountValue}%`;
1093
+ case 'FIXED_AMOUNT': return `-${p.discountValue} Kč`;
1094
+ case 'FREE_SHIPPING': return 'Doprava zdarma';
1095
+ case 'BUY_X_GET_Y': return `${p.discountValue}+1 ZDARMA`;
1096
+ default: return 'AKCE';
1097
+ }
1098
+ }
832
1099
  ```
833
1100
 
1101
+ **Pattern — product card badge.** Když chceš jen malou vlaječku na kartě
1102
+ produktu v listu (bez countdown), zavolej hook per-card a vezmi první promo:
1103
+
1104
+ ```tsx
1105
+ function ProductCardBadge({ slug }: { slug: string }) {
1106
+ const { data } = useProductPromotions(slug);
1107
+ const promo = data?.items[0];
1108
+ if (!promo) return null;
1109
+ return (
1110
+ <span className="badge" style={{ background: promo.badgeColor ?? '#ef4444' }}>
1111
+ {promo.badgeText ?? formatDefaultBadge(promo)}
1112
+ </span>
1113
+ );
1114
+ }
1115
+ ```
1116
+
1117
+ Pozor — v listovém kontextu to dělá N dotazů (jeden per karta). Pokud máš
1118
+ 200 produktů, radši vytvoř vlastní bulk endpoint nebo invaliduj méně často
1119
+ přes React Query `staleTime`.
1120
+
834
1121
  ## All API methods
835
1122
 
836
1123
  ### Catalog
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@behio/storefront-sdk",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "TypeScript SDK for Behio Headless E-Shop — core client + React hooks",
5
5
  "author": "Behio",
6
6
  "license": "MIT",