@behio/storefront-sdk 0.1.9 → 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 +91 -85
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -806,10 +806,10 @@ platforms treat bundles as paid add-ons or plugins — here it's native.
|
|
|
806
806
|
|
|
807
807
|
- **Homepage / landing pages** — list active bundles as hero cards with cover
|
|
808
808
|
image + `-25%` badge. Drives impulse purchase.
|
|
809
|
-
- **Category pages** — show a relevant bundle ("
|
|
810
|
-
individual products in the same category.
|
|
811
|
-
- **Cart / checkout** — suggest a bundle as upsell ("
|
|
812
|
-
|
|
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
813
|
- **Dedicated `/bundles` page** — marketing landing for all active sets.
|
|
814
814
|
|
|
815
815
|
**What's in the data:**
|
|
@@ -845,7 +845,7 @@ function BundlesGrid() {
|
|
|
845
845
|
</>
|
|
846
846
|
)}
|
|
847
847
|
</div>
|
|
848
|
-
<p>{bundle.items.length}
|
|
848
|
+
<p>{bundle.items.length} products · save {bundle.savings} {bundle.currency}</p>
|
|
849
849
|
</a>
|
|
850
850
|
))}
|
|
851
851
|
</section>
|
|
@@ -877,7 +877,7 @@ function BundleDetail({ slug }: { slug: string }) {
|
|
|
877
877
|
{item.quantity}× {item.name}
|
|
878
878
|
{item.defaultPrice && (
|
|
879
879
|
<span className="text-muted">
|
|
880
|
-
({item.defaultPrice} {bundle.currency} /
|
|
880
|
+
({item.defaultPrice} {bundle.currency} /pc regular price)
|
|
881
881
|
</span>
|
|
882
882
|
)}
|
|
883
883
|
</li>
|
|
@@ -888,12 +888,12 @@ function BundleDetail({ slug }: { slug: string }) {
|
|
|
888
888
|
<strong>{bundle.bundlePrice} {bundle.currency}</strong>
|
|
889
889
|
{bundle.savings > 0 && (
|
|
890
890
|
<p>
|
|
891
|
-
|
|
892
|
-
|
|
891
|
+
Individually it would cost <s>{bundle.itemsSum} {bundle.currency}</s> —
|
|
892
|
+
you save <strong>{bundle.savings} {bundle.currency}</strong>
|
|
893
893
|
({bundle.savingsPercent}%)
|
|
894
894
|
</p>
|
|
895
895
|
)}
|
|
896
|
-
<button onClick={addToCart}>
|
|
896
|
+
<button onClick={addToCart}>Add the whole bundle to cart</button>
|
|
897
897
|
</div>
|
|
898
898
|
</article>
|
|
899
899
|
);
|
|
@@ -901,10 +901,10 @@ function BundleDetail({ slug }: { slug: string }) {
|
|
|
901
901
|
```
|
|
902
902
|
|
|
903
903
|
**Behind the scenes at checkout.** When the customer buys a bundle, Behio
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
908
|
|
|
909
909
|
---
|
|
910
910
|
|
|
@@ -913,45 +913,46 @@ položku, sklady se odepisují správně z komponent.
|
|
|
913
913
|
**What it is.** Three related lists of product recommendations shown on a
|
|
914
914
|
product detail page:
|
|
915
915
|
|
|
916
|
-
- **Related
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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.
|
|
923
924
|
|
|
924
|
-
**Why
|
|
925
|
-
|
|
926
|
-
|
|
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.
|
|
927
928
|
|
|
928
929
|
**Where to use it.**
|
|
929
930
|
|
|
930
|
-
- **Product detail page** —
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
- **Cart sidebar** — mini
|
|
934
|
-
- **Post-purchase page** — "
|
|
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.
|
|
935
936
|
|
|
936
|
-
**
|
|
937
|
-
|
|
938
|
-
|
|
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.
|
|
939
940
|
|
|
940
941
|
```tsx
|
|
941
942
|
import { useCrossSell, useBehio } from '@behio/storefront-sdk/react';
|
|
942
943
|
|
|
943
944
|
function CrossSellSection({ title, items }: { title: string; items: CrossSellItem[] }) {
|
|
944
|
-
if (!items?.length) return null; //
|
|
945
|
+
if (!items?.length) return null; // hide empty section
|
|
945
946
|
return (
|
|
946
947
|
<section>
|
|
947
948
|
<h2>{title}</h2>
|
|
948
949
|
<div className="carousel">
|
|
949
950
|
{items.map((item) => (
|
|
950
|
-
<a key={item.productId} href={`/
|
|
951
|
+
<a key={item.productId} href={`/product/${item.slug}`} className="card">
|
|
951
952
|
{item.imageUrl && <img src={item.imageUrl} alt={item.name} />}
|
|
952
953
|
<h4>{item.name}</h4>
|
|
953
|
-
<span>{item.price}
|
|
954
|
-
{item.stockCached === 0 && <span className="text-red">
|
|
954
|
+
<span>{item.price}</span>
|
|
955
|
+
{item.stockCached === 0 && <span className="text-red">Sold out</span>}
|
|
955
956
|
</a>
|
|
956
957
|
))}
|
|
957
958
|
</div>
|
|
@@ -967,15 +968,15 @@ function ProductDetail({ slug }: { slug: string }) {
|
|
|
967
968
|
{/* ... product info ... */}
|
|
968
969
|
|
|
969
970
|
<CrossSellSection
|
|
970
|
-
title="
|
|
971
|
+
title="Frequently bought together"
|
|
971
972
|
items={crossSell?.crossSell ?? []}
|
|
972
973
|
/>
|
|
973
974
|
<CrossSellSection
|
|
974
|
-
title="
|
|
975
|
+
title="You might also like"
|
|
975
976
|
items={crossSell?.related ?? []}
|
|
976
977
|
/>
|
|
977
978
|
<CrossSellSection
|
|
978
|
-
title="
|
|
979
|
+
title="Want a premium version?"
|
|
979
980
|
items={crossSell?.upsell ?? []}
|
|
980
981
|
/>
|
|
981
982
|
</>
|
|
@@ -983,7 +984,8 @@ function ProductDetail({ slug }: { slug: string }) {
|
|
|
983
984
|
}
|
|
984
985
|
```
|
|
985
986
|
|
|
986
|
-
**Tip — merge
|
|
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:
|
|
987
989
|
|
|
988
990
|
```tsx
|
|
989
991
|
const allRecommendations = [
|
|
@@ -997,58 +999,61 @@ const allRecommendations = [
|
|
|
997
999
|
|
|
998
1000
|
### useProductPromotions
|
|
999
1001
|
|
|
1000
|
-
**What it is.**
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
`
|
|
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).
|
|
1004
1007
|
|
|
1005
|
-
**
|
|
1006
|
-
|
|
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.
|
|
1008
1011
|
|
|
1009
|
-
**
|
|
1010
|
-
**urgency + scarcity**.
|
|
1011
|
-
|
|
1012
|
-
Deals, Booking "
|
|
1013
|
-
|
|
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.
|
|
1014
1017
|
|
|
1015
|
-
**
|
|
1018
|
+
**Where to use it.**
|
|
1016
1019
|
|
|
1017
|
-
- **Product card
|
|
1018
|
-
- **Product detail** —
|
|
1019
|
-
|
|
1020
|
-
- **Cart** —
|
|
1021
|
-
|
|
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.
|
|
1022
1025
|
|
|
1023
|
-
**
|
|
1026
|
+
**Fields returned:**
|
|
1024
1027
|
|
|
1025
|
-
- `id`, `name`, `slug` —
|
|
1028
|
+
- `id`, `name`, `slug` — promotion identity
|
|
1026
1029
|
- `type` — `FLASH_SALE` / `SEASONAL` / `CLEARANCE` / `BOGO` / `BUNDLE` / `LOYALTY`
|
|
1027
1030
|
- `discountType` — `PERCENTAGE` / `FIXED_AMOUNT` / `FREE_SHIPPING` / `BUY_X_GET_Y`
|
|
1028
|
-
- `discountValue` —
|
|
1029
|
-
- `endsAt` — timestamp
|
|
1030
|
-
- `badgeText`, `badgeColor` — merchant
|
|
1031
|
-
|
|
1032
|
-
- `showCountdown` — **respect this!**
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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.
|
|
1037
1041
|
|
|
1038
1042
|
```tsx
|
|
1039
1043
|
import { useProductPromotions } from '@behio/storefront-sdk/react';
|
|
1040
1044
|
import { useEffect, useState } from 'react';
|
|
1041
1045
|
|
|
1042
1046
|
function ProductPromotionBanner({ slug }: { slug: string }) {
|
|
1043
|
-
// Refetch
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
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`.
|
|
1046
1050
|
const { data } = useProductPromotions(slug, { refetchIntervalMs: 4 * 60_000 });
|
|
1047
1051
|
const promotions = data?.items ?? [];
|
|
1048
1052
|
|
|
1049
1053
|
if (!promotions.length) return null;
|
|
1050
1054
|
|
|
1051
|
-
//
|
|
1055
|
+
// Highest-priority promotion (the BE already sorts by priority DESC,
|
|
1056
|
+
// then createdAt ASC).
|
|
1052
1057
|
const promo = promotions[0];
|
|
1053
1058
|
|
|
1054
1059
|
const badge = promo.badgeText ?? formatDefaultBadge(promo);
|
|
@@ -1060,7 +1065,7 @@ function ProductPromotionBanner({ slug }: { slug: string }) {
|
|
|
1060
1065
|
<span className="badge" style={{ backgroundColor: badgeColor }}>{badge}</span>
|
|
1061
1066
|
<strong>{promo.name}</strong>
|
|
1062
1067
|
{promo.couponRequired && (
|
|
1063
|
-
<p>
|
|
1068
|
+
<p>Use code <code>{promo.name}</code> at checkout to redeem</p>
|
|
1064
1069
|
)}
|
|
1065
1070
|
</div>
|
|
1066
1071
|
{promo.showCountdown && promo.endsAt && <Countdown endsAt={promo.endsAt} />}
|
|
@@ -1076,30 +1081,31 @@ function Countdown({ endsAt }: { endsAt: number }) {
|
|
|
1076
1081
|
}, []);
|
|
1077
1082
|
|
|
1078
1083
|
const ms = Math.max(0, endsAt - now);
|
|
1079
|
-
if (ms === 0) return <span>
|
|
1084
|
+
if (ms === 0) return <span>Sale ended</span>;
|
|
1080
1085
|
|
|
1081
1086
|
const d = Math.floor(ms / 86400_000);
|
|
1082
1087
|
const h = Math.floor((ms % 86400_000) / 3600_000);
|
|
1083
1088
|
const m = Math.floor((ms % 3600_000) / 60_000);
|
|
1084
1089
|
const s = Math.floor((ms % 60_000) / 1000);
|
|
1085
1090
|
|
|
1086
|
-
if (d > 0) return <span>
|
|
1087
|
-
return <span>
|
|
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>;
|
|
1088
1093
|
}
|
|
1089
1094
|
|
|
1090
1095
|
function formatDefaultBadge(p: { discountType: string; discountValue: number }) {
|
|
1091
1096
|
switch (p.discountType) {
|
|
1092
1097
|
case 'PERCENTAGE': return `-${p.discountValue}%`;
|
|
1093
|
-
case 'FIXED_AMOUNT': return `-${p.discountValue}
|
|
1094
|
-
case 'FREE_SHIPPING': return '
|
|
1095
|
-
case 'BUY_X_GET_Y': return `${p.discountValue}+1
|
|
1096
|
-
default: return '
|
|
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';
|
|
1097
1102
|
}
|
|
1098
1103
|
}
|
|
1099
1104
|
```
|
|
1100
1105
|
|
|
1101
|
-
**Pattern — product card badge.**
|
|
1102
|
-
|
|
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:
|
|
1103
1109
|
|
|
1104
1110
|
```tsx
|
|
1105
1111
|
function ProductCardBadge({ slug }: { slug: string }) {
|
|
@@ -1114,9 +1120,9 @@ function ProductCardBadge({ slug }: { slug: string }) {
|
|
|
1114
1120
|
}
|
|
1115
1121
|
```
|
|
1116
1122
|
|
|
1117
|
-
|
|
1118
|
-
200
|
|
1119
|
-
|
|
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`.
|
|
1120
1126
|
|
|
1121
1127
|
## All API methods
|
|
1122
1128
|
|