@behio/storefront-sdk 0.1.8 → 0.1.10
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 +320 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -788,49 +788,342 @@ 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 ("Everything for grilling") next
|
|
810
|
+
to individual products in the same category.
|
|
811
|
+
- **Cart / checkout** — suggest a bundle as upsell ("Add these two more items
|
|
812
|
+
and get the whole set with a 20% discount").
|
|
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} products · save {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} /pc regular price)
|
|
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
|
+
Individually it would cost <s>{bundle.itemsSum} {bundle.currency}</s> —
|
|
892
|
+
you save <strong>{bundle.savings} {bundle.currency}</strong>
|
|
893
|
+
({bundle.savingsPercent}%)
|
|
894
|
+
</p>
|
|
895
|
+
)}
|
|
896
|
+
<button onClick={addToCart}>Add the whole bundle to cart</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
|
+
splits it into individual order items with prices distributed proportionally
|
|
905
|
+
based on each component's default price (this is kept so accounting and
|
|
906
|
+
stock decrements work correctly). The invoice total matches `bundlePrice` —
|
|
907
|
+
the customer sees one line item, inventory is decremented component-by-component.
|
|
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** — alternatives for the same need (another leash, another dog
|
|
917
|
+
food). "If this one caught your eye, here are similar options in the same
|
|
918
|
+
category."
|
|
919
|
+
- **Upsell** — a better / more premium version. "Looking at the basic collar?
|
|
920
|
+
Here's the leather-stitched premium version for 2× the price." Goal: raise
|
|
921
|
+
average order value by steering to a better margin.
|
|
922
|
+
- **Cross-sell** — complementary products. Leash for the collar, cleaner for
|
|
923
|
+
the bowl. Pure AOV boost on PDP and in cart.
|
|
924
|
+
|
|
925
|
+
**Why split them.** Each type has a different UX role and should be phrased
|
|
926
|
+
differently. Mixed together they lose context — the customer can't tell why
|
|
927
|
+
they're being shown.
|
|
928
|
+
|
|
929
|
+
**Where to use it.**
|
|
930
|
+
|
|
931
|
+
- **Product detail page** — three separate sections below the description
|
|
932
|
+
(or in a sidebar column). Highest conversion impact is **Cross-sell
|
|
933
|
+
"Frequently bought together"** right next to the "Add to cart" button.
|
|
934
|
+
- **Cart sidebar** — mini cross-sell widget ("Don't forget these too").
|
|
935
|
+
- **Post-purchase page** — "Want to add these?" for a follow-up order.
|
|
936
|
+
|
|
937
|
+
**Note.** The endpoint returns **only active products from the same eshop**.
|
|
938
|
+
If you linked a product in admin and later disabled or deleted it, it drops
|
|
939
|
+
from the list automatically — no need to handle that on the frontend.
|
|
940
|
+
|
|
941
|
+
```tsx
|
|
942
|
+
import { useCrossSell, useBehio } from '@behio/storefront-sdk/react';
|
|
943
|
+
|
|
944
|
+
function CrossSellSection({ title, items }: { title: string; items: CrossSellItem[] }) {
|
|
945
|
+
if (!items?.length) return null; // hide empty section
|
|
946
|
+
return (
|
|
947
|
+
<section>
|
|
948
|
+
<h2>{title}</h2>
|
|
949
|
+
<div className="carousel">
|
|
950
|
+
{items.map((item) => (
|
|
951
|
+
<a key={item.productId} href={`/product/${item.slug}`} className="card">
|
|
952
|
+
{item.imageUrl && <img src={item.imageUrl} alt={item.name} />}
|
|
953
|
+
<h4>{item.name}</h4>
|
|
954
|
+
<span>{item.price}</span>
|
|
955
|
+
{item.stockCached === 0 && <span className="text-red">Sold out</span>}
|
|
956
|
+
</a>
|
|
957
|
+
))}
|
|
958
|
+
</div>
|
|
959
|
+
</section>
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function ProductDetail({ slug }: { slug: string }) {
|
|
964
|
+
const { data: crossSell } = useCrossSell(slug);
|
|
965
|
+
|
|
966
|
+
return (
|
|
967
|
+
<>
|
|
968
|
+
{/* ... product info ... */}
|
|
969
|
+
|
|
970
|
+
<CrossSellSection
|
|
971
|
+
title="Frequently bought together"
|
|
972
|
+
items={crossSell?.crossSell ?? []}
|
|
973
|
+
/>
|
|
974
|
+
<CrossSellSection
|
|
975
|
+
title="You might also like"
|
|
976
|
+
items={crossSell?.related ?? []}
|
|
977
|
+
/>
|
|
978
|
+
<CrossSellSection
|
|
979
|
+
title="Want a premium version?"
|
|
980
|
+
items={crossSell?.upsell ?? []}
|
|
981
|
+
/>
|
|
982
|
+
</>
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
**Tip — merge into a single section.** If you have sparse data and want
|
|
988
|
+
to keep it simple, combine all three into one list:
|
|
815
989
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
990
|
+
```tsx
|
|
991
|
+
const allRecommendations = [
|
|
992
|
+
...(crossSell?.crossSell ?? []),
|
|
993
|
+
...(crossSell?.related ?? []),
|
|
994
|
+
...(crossSell?.upsell ?? []),
|
|
995
|
+
].slice(0, 6);
|
|
819
996
|
```
|
|
820
997
|
|
|
998
|
+
---
|
|
999
|
+
|
|
821
1000
|
### useProductPromotions
|
|
822
|
-
|
|
1001
|
+
|
|
1002
|
+
**What it is.** Returns the currently-active promotions (`Eshop_Promotion`)
|
|
1003
|
+
applicable to a given product — i.e. all promotions where
|
|
1004
|
+
`startsAt <= now < endsAt` and the product matches the promotion scope
|
|
1005
|
+
(`ALL_PRODUCTS`, `SPECIFIC_PRODUCTS`, `CATEGORIES`, or `LABELS`; the backend
|
|
1006
|
+
resolves it for you).
|
|
1007
|
+
|
|
1008
|
+
**What it is NOT.** It's not a computation of the *final discounted price* —
|
|
1009
|
+
that's a separate layer (the cart evaluator). This hook is purely for the
|
|
1010
|
+
**display layer**: badges, countdowns, "sale price until midnight" banners.
|
|
1011
|
+
|
|
1012
|
+
**Why it's separate.** The most conversion-effective marketing element in
|
|
1013
|
+
e-commerce is **urgency + scarcity**. "Sale ends in 2h 14m 37s" right on the
|
|
1014
|
+
PDP measurably lifts conversion — it's a well-established best practice
|
|
1015
|
+
(Amazon Lightning Deals, Booking.com "3 people booked in the last 24h",
|
|
1016
|
+
etc.). The hook delivers the data; you build the countdown component.
|
|
1017
|
+
|
|
1018
|
+
**Where to use it.**
|
|
1019
|
+
|
|
1020
|
+
- **Product card in a list** — a `-20%` badge or a "SALE" flag.
|
|
1021
|
+
- **Product detail** — a large banner with countdown above the price: "Buy
|
|
1022
|
+
before midnight to save $20."
|
|
1023
|
+
- **Cart** — a warning like "Your discount expires in 5 minutes" to nudge
|
|
1024
|
+
checkout completion.
|
|
1025
|
+
|
|
1026
|
+
**Fields returned:**
|
|
1027
|
+
|
|
1028
|
+
- `id`, `name`, `slug` — promotion identity
|
|
1029
|
+
- `type` — `FLASH_SALE` / `SEASONAL` / `CLEARANCE` / `BOGO` / `BUNDLE` / `LOYALTY`
|
|
1030
|
+
- `discountType` — `PERCENTAGE` / `FIXED_AMOUNT` / `FREE_SHIPPING` / `BUY_X_GET_Y`
|
|
1031
|
+
- `discountValue` — the value (% for PERCENTAGE, currency amount for FIXED_AMOUNT, …)
|
|
1032
|
+
- `endsAt` — end timestamp. If `null`, the promotion runs indefinitely.
|
|
1033
|
+
- `badgeText`, `badgeColor` — merchant-set badge text and color (e.g. "-30%"
|
|
1034
|
+
in red). If both are `null`, fall back to a default derived from `discountType`.
|
|
1035
|
+
- `showCountdown` — **respect this!** If the merchant opted out of showing
|
|
1036
|
+
a countdown, don't render one. Not all promotions (CLEARANCE, LOYALTY)
|
|
1037
|
+
make sense to count down.
|
|
1038
|
+
- `couponRequired` — the promotion applies only after the customer enters a
|
|
1039
|
+
code at checkout. On the PDP show an info box ("Use code SUMMER at
|
|
1040
|
+
checkout"); don't present it as an automatic discount.
|
|
1041
|
+
|
|
1042
|
+
```tsx
|
|
823
1043
|
import { useProductPromotions } from '@behio/storefront-sdk/react';
|
|
1044
|
+
import { useEffect, useState } from 'react';
|
|
824
1045
|
|
|
825
|
-
|
|
826
|
-
|
|
1046
|
+
function ProductPromotionBanner({ slug }: { slug: string }) {
|
|
1047
|
+
// Refetch every 4 minutes so the promotion list updates when one ends
|
|
1048
|
+
// and another begins. The countdown itself doesn't need a refetch —
|
|
1049
|
+
// it ticks locally off `endsAt`.
|
|
1050
|
+
const { data } = useProductPromotions(slug, { refetchIntervalMs: 4 * 60_000 });
|
|
1051
|
+
const promotions = data?.items ?? [];
|
|
827
1052
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
//
|
|
831
|
-
|
|
1053
|
+
if (!promotions.length) return null;
|
|
1054
|
+
|
|
1055
|
+
// Highest-priority promotion (the BE already sorts by priority DESC,
|
|
1056
|
+
// then createdAt ASC).
|
|
1057
|
+
const promo = promotions[0];
|
|
1058
|
+
|
|
1059
|
+
const badge = promo.badgeText ?? formatDefaultBadge(promo);
|
|
1060
|
+
const badgeColor = promo.badgeColor ?? '#ef4444';
|
|
1061
|
+
|
|
1062
|
+
return (
|
|
1063
|
+
<div className="promo-banner" style={{ backgroundColor: badgeColor + '20', borderColor: badgeColor }}>
|
|
1064
|
+
<div>
|
|
1065
|
+
<span className="badge" style={{ backgroundColor: badgeColor }}>{badge}</span>
|
|
1066
|
+
<strong>{promo.name}</strong>
|
|
1067
|
+
{promo.couponRequired && (
|
|
1068
|
+
<p>Use code <code>{promo.name}</code> at checkout to redeem</p>
|
|
1069
|
+
)}
|
|
1070
|
+
</div>
|
|
1071
|
+
{promo.showCountdown && promo.endsAt && <Countdown endsAt={promo.endsAt} />}
|
|
1072
|
+
</div>
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function Countdown({ endsAt }: { endsAt: number }) {
|
|
1077
|
+
const [now, setNow] = useState(Date.now());
|
|
1078
|
+
useEffect(() => {
|
|
1079
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
1080
|
+
return () => clearInterval(id);
|
|
1081
|
+
}, []);
|
|
1082
|
+
|
|
1083
|
+
const ms = Math.max(0, endsAt - now);
|
|
1084
|
+
if (ms === 0) return <span>Sale ended</span>;
|
|
1085
|
+
|
|
1086
|
+
const d = Math.floor(ms / 86400_000);
|
|
1087
|
+
const h = Math.floor((ms % 86400_000) / 3600_000);
|
|
1088
|
+
const m = Math.floor((ms % 3600_000) / 60_000);
|
|
1089
|
+
const s = Math.floor((ms % 60_000) / 1000);
|
|
1090
|
+
|
|
1091
|
+
if (d > 0) return <span>Ends in {d}d {h}h {m}m</span>;
|
|
1092
|
+
return <span>Ends in {h}h {m}m {s}s</span>;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function formatDefaultBadge(p: { discountType: string; discountValue: number }) {
|
|
1096
|
+
switch (p.discountType) {
|
|
1097
|
+
case 'PERCENTAGE': return `-${p.discountValue}%`;
|
|
1098
|
+
case 'FIXED_AMOUNT': return `-${p.discountValue}`;
|
|
1099
|
+
case 'FREE_SHIPPING': return 'Free shipping';
|
|
1100
|
+
case 'BUY_X_GET_Y': return `${p.discountValue}+1 FREE`;
|
|
1101
|
+
default: return 'SALE';
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
832
1104
|
```
|
|
833
1105
|
|
|
1106
|
+
**Pattern — product card badge.** If you only need a small flag on a
|
|
1107
|
+
product card in a list (no countdown), call the hook per card and pick the
|
|
1108
|
+
first promo:
|
|
1109
|
+
|
|
1110
|
+
```tsx
|
|
1111
|
+
function ProductCardBadge({ slug }: { slug: string }) {
|
|
1112
|
+
const { data } = useProductPromotions(slug);
|
|
1113
|
+
const promo = data?.items[0];
|
|
1114
|
+
if (!promo) return null;
|
|
1115
|
+
return (
|
|
1116
|
+
<span className="badge" style={{ background: promo.badgeColor ?? '#ef4444' }}>
|
|
1117
|
+
{promo.badgeText ?? formatDefaultBadge(promo)}
|
|
1118
|
+
</span>
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
Heads-up — in list contexts this fires N requests (one per card). For
|
|
1124
|
+
a 200-product page, either add a bulk endpoint of your own or keep the
|
|
1125
|
+
requests cheap with a long React Query `staleTime`.
|
|
1126
|
+
|
|
834
1127
|
## All API methods
|
|
835
1128
|
|
|
836
1129
|
### Catalog
|