@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.
- package/README.md +314 -27
- 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
|
-
|
|
793
|
-
|
|
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
|
-
//
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
814
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
-
|
|
826
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
//
|
|
831
|
-
|
|
1049
|
+
if (!promotions.length) return null;
|
|
1050
|
+
|
|
1051
|
+
// Nejvyšší priority akce (BE už 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
|