@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.
Files changed (2) hide show
  1. package/README.md +320 -27
  2. 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
- ```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 ("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
- // 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} products · save {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} /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
- 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** — 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
- // Render sections on product detail page
817
- <CrossSellSection title="Často kupováno s" items={data?.crossSell} />
818
- <CrossSellSection title="Podobné produkty" items={data?.related} />
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
- ```typescript
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
- // Auto-refetch every 5 seconds so the countdown stays accurate on long-lived pages
826
- const { data } = useProductPromotions(product.slug, { refetchIntervalMs: 5000 });
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
- 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
- });
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
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.10",
4
4
  "description": "TypeScript SDK for Behio Headless E-Shop — core client + React hooks",
5
5
  "author": "Behio",
6
6
  "license": "MIT",