@shopify/shop-minis-react 0.1.5 → 0.1.7

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 (60) hide show
  1. package/dist/_virtual/index10.js +2 -2
  2. package/dist/_virtual/index5.js +2 -3
  3. package/dist/_virtual/index5.js.map +1 -1
  4. package/dist/_virtual/index6.js +3 -2
  5. package/dist/_virtual/index6.js.map +1 -1
  6. package/dist/_virtual/index7.js +2 -2
  7. package/dist/_virtual/index9.js +2 -2
  8. package/dist/components/atoms/list.js +106 -41
  9. package/dist/components/atoms/list.js.map +1 -1
  10. package/dist/components/commerce/add-to-cart.js +82 -0
  11. package/dist/components/commerce/add-to-cart.js.map +1 -0
  12. package/dist/components/{atoms → commerce}/favorite-button.js +1 -1
  13. package/dist/components/commerce/favorite-button.js.map +1 -0
  14. package/dist/components/commerce/product-card.js +10 -10
  15. package/dist/components/commerce/product-card.js.map +1 -1
  16. package/dist/components/commerce/product-link.js +6 -6
  17. package/dist/components/commerce/product-link.js.map +1 -1
  18. package/dist/index.js +276 -274
  19. package/dist/index.js.map +1 -1
  20. package/dist/internal/components/refresh-indicator.js +83 -0
  21. package/dist/internal/components/refresh-indicator.js.map +1 -0
  22. package/dist/internal/usePullToRefresh.js +149 -0
  23. package/dist/internal/usePullToRefresh.js.map +1 -0
  24. package/dist/internal/utils/virtuoso-dom.js +20 -0
  25. package/dist/internal/utils/virtuoso-dom.js.map +1 -0
  26. package/dist/mocks.js +1 -0
  27. package/dist/mocks.js.map +1 -1
  28. package/dist/shop-minis-react/node_modules/.pnpm/@radix-ui_react-use-is-hydrated@0.1.0_@types_react@19.1.6_react@19.1.0/node_modules/@radix-ui/react-use-is-hydrated/dist/index.js +1 -1
  29. package/dist/shop-minis-react/node_modules/.pnpm/@videojs_xhr@2.7.0/node_modules/@videojs/xhr/lib/index.js +1 -1
  30. package/dist/shop-minis-react/node_modules/.pnpm/@xmldom_xmldom@0.8.10/node_modules/@xmldom/xmldom/lib/index.js +1 -1
  31. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/PopChild.js +55 -0
  32. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/PopChild.js.map +1 -0
  33. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/PresenceChild.js +35 -0
  34. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/PresenceChild.js.map +1 -0
  35. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/index.js +46 -0
  36. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/index.js.map +1 -0
  37. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/utils.js +13 -0
  38. package/dist/shop-minis-react/node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/utils.js.map +1 -0
  39. package/dist/shop-minis-react/node_modules/.pnpm/mpd-parser@1.3.1/node_modules/mpd-parser/dist/mpd-parser.es.js +1 -1
  40. package/dist/shop-minis-react/node_modules/.pnpm/use-sync-external-store@1.5.0_react@19.1.0/node_modules/use-sync-external-store/shim/index.js +1 -1
  41. package/package.json +2 -2
  42. package/src/components/atoms/list.tsx +97 -12
  43. package/src/components/commerce/add-to-cart.test.tsx +73 -0
  44. package/src/components/commerce/add-to-cart.tsx +132 -0
  45. package/src/components/{atoms → commerce}/favorite-button.tsx +1 -1
  46. package/src/components/commerce/product-card.tsx +2 -1
  47. package/src/components/commerce/product-link.test.tsx +1 -0
  48. package/src/components/commerce/product-link.tsx +2 -1
  49. package/src/components/index.ts +2 -1
  50. package/src/internal/components/refresh-indicator.tsx +103 -0
  51. package/src/internal/usePullToRefresh.ts +286 -0
  52. package/src/internal/utils/virtuoso-dom.ts +26 -0
  53. package/src/mocks.ts +1 -0
  54. package/src/stories/AddToCart.stories.tsx +186 -0
  55. package/src/stories/FavoriteButton.stories.tsx +2 -2
  56. package/src/stories/PullToRefreshList.stories.tsx +122 -0
  57. package/src/styles/animations.css +54 -0
  58. package/src/test-utils.tsx +1 -0
  59. package/dist/components/atoms/favorite-button.js.map +0 -1
  60. /package/src/components/{atoms → commerce}/favorite-button.test.tsx +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../../../../../../../../../../../../../../node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/index.mjs"],"sourcesContent":["\"use client\";\nimport { jsx, Fragment } from 'react/jsx-runtime';\nimport { useMemo, useRef, useState, useContext } from 'react';\nimport { LayoutGroupContext } from '../../context/LayoutGroupContext.mjs';\nimport { useConstant } from '../../utils/use-constant.mjs';\nimport { useIsomorphicLayoutEffect } from '../../utils/use-isomorphic-effect.mjs';\nimport { PresenceChild } from './PresenceChild.mjs';\nimport { usePresence } from './use-presence.mjs';\nimport { onlyElements, getChildKey } from './utils.mjs';\n\n/**\n * `AnimatePresence` enables the animation of components that have been removed from the tree.\n *\n * When adding/removing more than a single child, every child **must** be given a unique `key` prop.\n *\n * Any `motion` components that have an `exit` property defined will animate out when removed from\n * the tree.\n *\n * ```jsx\n * import { motion, AnimatePresence } from 'framer-motion'\n *\n * export const Items = ({ items }) => (\n * <AnimatePresence>\n * {items.map(item => (\n * <motion.div\n * key={item.id}\n * initial={{ opacity: 0 }}\n * animate={{ opacity: 1 }}\n * exit={{ opacity: 0 }}\n * />\n * ))}\n * </AnimatePresence>\n * )\n * ```\n *\n * You can sequence exit animations throughout a tree using variants.\n *\n * If a child contains multiple `motion` components with `exit` props, it will only unmount the child\n * once all `motion` components have finished animating out. Likewise, any components using\n * `usePresence` all need to call `safeToRemove`.\n *\n * @public\n */\nconst AnimatePresence = ({ children, custom, initial = true, onExitComplete, presenceAffectsLayout = true, mode = \"sync\", propagate = false, anchorX = \"left\", }) => {\n const [isParentPresent, safeToRemove] = usePresence(propagate);\n /**\n * Filter any children that aren't ReactElements. We can only track components\n * between renders with a props.key.\n */\n const presentChildren = useMemo(() => onlyElements(children), [children]);\n /**\n * Track the keys of the currently rendered children. This is used to\n * determine which children are exiting.\n */\n const presentKeys = propagate && !isParentPresent ? [] : presentChildren.map(getChildKey);\n /**\n * If `initial={false}` we only want to pass this to components in the first render.\n */\n const isInitialRender = useRef(true);\n /**\n * A ref containing the currently present children. When all exit animations\n * are complete, we use this to re-render the component with the latest children\n * *committed* rather than the latest children *rendered*.\n */\n const pendingPresentChildren = useRef(presentChildren);\n /**\n * Track which exiting children have finished animating out.\n */\n const exitComplete = useConstant(() => new Map());\n /**\n * Save children to render as React state. To ensure this component is concurrent-safe,\n * we check for exiting children via an effect.\n */\n const [diffedChildren, setDiffedChildren] = useState(presentChildren);\n const [renderedChildren, setRenderedChildren] = useState(presentChildren);\n useIsomorphicLayoutEffect(() => {\n isInitialRender.current = false;\n pendingPresentChildren.current = presentChildren;\n /**\n * Update complete status of exiting children.\n */\n for (let i = 0; i < renderedChildren.length; i++) {\n const key = getChildKey(renderedChildren[i]);\n if (!presentKeys.includes(key)) {\n if (exitComplete.get(key) !== true) {\n exitComplete.set(key, false);\n }\n }\n else {\n exitComplete.delete(key);\n }\n }\n }, [renderedChildren, presentKeys.length, presentKeys.join(\"-\")]);\n const exitingChildren = [];\n if (presentChildren !== diffedChildren) {\n let nextChildren = [...presentChildren];\n /**\n * Loop through all the currently rendered components and decide which\n * are exiting.\n */\n for (let i = 0; i < renderedChildren.length; i++) {\n const child = renderedChildren[i];\n const key = getChildKey(child);\n if (!presentKeys.includes(key)) {\n nextChildren.splice(i, 0, child);\n exitingChildren.push(child);\n }\n }\n /**\n * If we're in \"wait\" mode, and we have exiting children, we want to\n * only render these until they've all exited.\n */\n if (mode === \"wait\" && exitingChildren.length) {\n nextChildren = exitingChildren;\n }\n setRenderedChildren(onlyElements(nextChildren));\n setDiffedChildren(presentChildren);\n /**\n * Early return to ensure once we've set state with the latest diffed\n * children, we can immediately re-render.\n */\n return null;\n }\n if (process.env.NODE_ENV !== \"production\" &&\n mode === \"wait\" &&\n renderedChildren.length > 1) {\n console.warn(`You're attempting to animate multiple children within AnimatePresence, but its mode is set to \"wait\". This will lead to odd visual behaviour.`);\n }\n /**\n * If we've been provided a forceRender function by the LayoutGroupContext,\n * we can use it to force a re-render amongst all surrounding components once\n * all components have finished animating out.\n */\n const { forceRender } = useContext(LayoutGroupContext);\n return (jsx(Fragment, { children: renderedChildren.map((child) => {\n const key = getChildKey(child);\n const isPresent = propagate && !isParentPresent\n ? false\n : presentChildren === renderedChildren ||\n presentKeys.includes(key);\n const onExit = () => {\n if (exitComplete.has(key)) {\n exitComplete.set(key, true);\n }\n else {\n return;\n }\n let isEveryExitComplete = true;\n exitComplete.forEach((isExitComplete) => {\n if (!isExitComplete)\n isEveryExitComplete = false;\n });\n if (isEveryExitComplete) {\n forceRender?.();\n setRenderedChildren(pendingPresentChildren.current);\n propagate && safeToRemove?.();\n onExitComplete && onExitComplete();\n }\n };\n return (jsx(PresenceChild, { isPresent: isPresent, initial: !isInitialRender.current || initial\n ? undefined\n : false, custom: custom, presenceAffectsLayout: presenceAffectsLayout, mode: mode, onExitComplete: isPresent ? undefined : onExit, anchorX: anchorX, children: child }, key));\n }) }));\n};\n\nexport { AnimatePresence };\n"],"names":["AnimatePresence","children","custom","initial","onExitComplete","presenceAffectsLayout","mode","propagate","anchorX","isParentPresent","safeToRemove","usePresence","presentChildren","useMemo","onlyElements","presentKeys","getChildKey","isInitialRender","useRef","pendingPresentChildren","exitComplete","useConstant","diffedChildren","setDiffedChildren","useState","renderedChildren","setRenderedChildren","useIsomorphicLayoutEffect","i","key","exitingChildren","nextChildren","child","forceRender","useContext","LayoutGroupContext","jsx","Fragment","isPresent","onExit","isEveryExitComplete","isExitComplete","PresenceChild"],"mappings":";;;;;;;;AA2CK,MAACA,IAAkB,CAAC,EAAE,UAAAC,GAAU,QAAAC,GAAQ,SAAAC,IAAU,IAAM,gBAAAC,GAAgB,uBAAAC,IAAwB,IAAM,MAAAC,IAAO,QAAQ,WAAAC,IAAY,IAAO,SAAAC,IAAU,aAAc;AACjK,QAAM,CAACC,GAAiBC,CAAY,IAAIC,EAAYJ,CAAS,GAKvDK,IAAkBC,EAAQ,MAAMC,EAAab,CAAQ,GAAG,CAACA,CAAQ,CAAC,GAKlEc,IAAcR,KAAa,CAACE,IAAkB,CAAA,IAAKG,EAAgB,IAAII,CAAW,GAIlFC,IAAkBC,EAAO,EAAI,GAM7BC,IAAyBD,EAAON,CAAe,GAI/CQ,IAAeC,EAAY,MAAM,oBAAI,IAAG,CAAE,GAK1C,CAACC,GAAgBC,CAAiB,IAAIC,EAASZ,CAAe,GAC9D,CAACa,GAAkBC,CAAmB,IAAIF,EAASZ,CAAe;AACxE,EAAAe,EAA0B,MAAM;AAC5B,IAAAV,EAAgB,UAAU,IAC1BE,EAAuB,UAAUP;AAIjC,aAASgB,IAAI,GAAGA,IAAIH,EAAiB,QAAQG,KAAK;AAC9C,YAAMC,IAAMb,EAAYS,EAAiBG,CAAC,CAAC;AAC3C,MAAKb,EAAY,SAASc,CAAG,IAMzBT,EAAa,OAAOS,CAAG,IALnBT,EAAa,IAAIS,CAAG,MAAM,MAC1BT,EAAa,IAAIS,GAAK,EAAK;AAAA,IAM/C;AAAA,EACA,GAAO,CAACJ,GAAkBV,EAAY,QAAQA,EAAY,KAAK,GAAG,CAAC,CAAC;AAChE,QAAMe,IAAkB,CAAE;AAC1B,MAAIlB,MAAoBU,GAAgB;AACpC,QAAIS,IAAe,CAAC,GAAGnB,CAAe;AAKtC,aAASgB,IAAI,GAAGA,IAAIH,EAAiB,QAAQG,KAAK;AAC9C,YAAMI,IAAQP,EAAiBG,CAAC,GAC1BC,IAAMb,EAAYgB,CAAK;AAC7B,MAAKjB,EAAY,SAASc,CAAG,MACzBE,EAAa,OAAOH,GAAG,GAAGI,CAAK,GAC/BF,EAAgB,KAAKE,CAAK;AAAA,IAE1C;AAKQ,WAAI1B,MAAS,UAAUwB,EAAgB,WACnCC,IAAeD,IAEnBJ,EAAoBZ,EAAaiB,CAAY,CAAC,GAC9CR,EAAkBX,CAAe,GAK1B;AAAA,EACf;AACI,EAAI,QAAQ,IAAI,aAAa,gBACzBN,MAAS,UACTmB,EAAiB,SAAS,KAC1B,QAAQ,KAAK,+IAA+I;AAOhK,QAAM,EAAE,aAAAQ,EAAW,IAAKC,EAAWC,CAAkB;AACrD,SAAQC,EAAIC,GAAU,EAAE,UAAUZ,EAAiB,IAAI,CAACO,MAAU;AAC1D,UAAMH,IAAMb,EAAYgB,CAAK,GACvBM,IAAY/B,KAAa,CAACE,IAC1B,KACAG,MAAoBa,KAClBV,EAAY,SAASc,CAAG,GAC1BU,IAAS,MAAM;AACjB,UAAInB,EAAa,IAAIS,CAAG;AACpB,QAAAT,EAAa,IAAIS,GAAK,EAAI;AAAA;AAG1B;AAEJ,UAAIW,IAAsB;AAC1B,MAAApB,EAAa,QAAQ,CAACqB,MAAmB;AACrC,QAAKA,MACDD,IAAsB;AAAA,MAC9C,CAAiB,GACGA,MACAP,IAAe,GACfP,EAAoBP,EAAuB,OAAO,GAClDZ,KAAaG,IAAgB,GAC7BN,KAAkBA,EAAgB;AAAA,IAEzC;AACD,WAAQgC,EAAIM,GAAe,EAAE,WAAWJ,GAAW,SAAS,CAACrB,EAAgB,WAAWd,IAC9E,SACA,IAAO,QAAQD,GAAQ,uBAAuBG,GAAuB,MAAMC,GAAM,gBAAgBgC,IAAY,SAAYC,GAAQ,SAAS/B,GAAS,UAAUwB,EAAO,GAAEH,CAAG;AAAA,EACtL,CAAA,GAAG;AACZ;","x_google_ignoreList":[0]}
@@ -0,0 +1,13 @@
1
+ import { Children as o, isValidElement as r } from "react";
2
+ const l = (e) => e.key || "";
3
+ function f(e) {
4
+ const t = [];
5
+ return o.forEach(e, (n) => {
6
+ r(n) && t.push(n);
7
+ }), t;
8
+ }
9
+ export {
10
+ l as getChildKey,
11
+ f as onlyElements
12
+ };
13
+ //# sourceMappingURL=utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.js","sources":["../../../../../../../../../../../../../../node_modules/.pnpm/motion@12.17.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/motion/dist/es/framer-motion/dist/es/components/AnimatePresence/utils.mjs"],"sourcesContent":["import { Children, isValidElement } from 'react';\n\nconst getChildKey = (child) => child.key || \"\";\nfunction onlyElements(children) {\n const filtered = [];\n // We use forEach here instead of map as map mutates the component key by preprending `.$`\n Children.forEach(children, (child) => {\n if (isValidElement(child))\n filtered.push(child);\n });\n return filtered;\n}\n\nexport { getChildKey, onlyElements };\n"],"names":["getChildKey","child","onlyElements","children","filtered","Children","isValidElement"],"mappings":";AAEK,MAACA,IAAc,CAACC,MAAUA,EAAM,OAAO;AAC5C,SAASC,EAAaC,GAAU;AAC5B,QAAMC,IAAW,CAAE;AAEnB,SAAAC,EAAS,QAAQF,GAAU,CAACF,MAAU;AAClC,IAAIK,EAAeL,CAAK,KACpBG,EAAS,KAAKH,CAAK;AAAA,EAC/B,CAAK,GACMG;AACX;","x_google_ignoreList":[0]}
@@ -2,7 +2,7 @@ import L from "../../../../@videojs_vhs-utils@4.1.1/node_modules/@videojs/vhs-ut
2
2
  import T from "../../../../../../../_virtual/window.js";
3
3
  import { forEachMediaGroup as Z } from "../../../../@videojs_vhs-utils@4.1.1/node_modules/@videojs/vhs-utils/es/media-groups.js";
4
4
  import J from "../../../../@videojs_vhs-utils@4.1.1/node_modules/@videojs/vhs-utils/es/decode-b64-to-uint8-array.js";
5
- import { l as Q } from "../../../../../../../_virtual/index7.js";
5
+ import { l as Q } from "../../../../../../../_virtual/index6.js";
6
6
  /*! @name mpd-parser @version 1.3.1 @license Apache-2.0 */
7
7
  const w = (e) => !!e && typeof e == "object", E = (...e) => e.reduce((n, t) => (typeof t != "object" || Object.keys(t).forEach((r) => {
8
8
  Array.isArray(n[r]) && Array.isArray(t[r]) ? n[r] = n[r].concat(t[r]) : w(n[r]) && w(t[r]) ? n[r] = E(n[r], t[r]) : n[r] = t[r];
@@ -1,4 +1,4 @@
1
- import { __module as r } from "../../../../../../../_virtual/index9.js";
1
+ import { __module as r } from "../../../../../../../_virtual/index10.js";
2
2
  import { __require as o } from "../cjs/use-sync-external-store-shim.production.js";
3
3
  import { __require as i } from "../cjs/use-sync-external-store-shim.development.js";
4
4
  var e;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shopify/shop-minis-react",
3
3
  "license": "SEE LICENSE IN LICENSE.txt",
4
- "version": "0.1.5",
4
+ "version": "0.1.7",
5
5
  "sideEffects": false,
6
6
  "type": "module",
7
7
  "engines": {
@@ -38,7 +38,7 @@
38
38
  "typescript": ">=5.0.0"
39
39
  },
40
40
  "dependencies": {
41
- "@shopify/shop-minis-platform": "0.4.0",
41
+ "@shopify/shop-minis-platform": "0.5.0",
42
42
  "@tailwindcss/vite": "4.1.8",
43
43
  "@types/color": "3.0.6",
44
44
  "@types/lodash": "4.17.20",
@@ -1,12 +1,18 @@
1
- import {useCallback, useRef} from 'react'
1
+ import {useCallback, useEffect, useRef} from 'react'
2
2
 
3
3
  import {Virtuoso, VirtuosoProps} from 'react-virtuoso'
4
4
 
5
+ import {RefreshIndicator} from '../../internal/components/refresh-indicator'
6
+ import {usePullToRefresh} from '../../internal/usePullToRefresh'
7
+ import {findVirtuosoScrollableElement} from '../../internal/utils/virtuoso-dom'
5
8
  import {cn} from '../../lib/utils'
6
9
  import '../../styles/utilities.css'
7
10
 
8
11
  import {Pagination} from './pagination'
9
12
 
13
+ const DEFAULT_REFRESH_PULL_THRESHOLD = 200
14
+ const ELEMENT_BIND_DELAY = 100
15
+
10
16
  interface Props<T = any>
11
17
  extends Omit<
12
18
  VirtuosoProps<T, unknown>,
@@ -19,6 +25,9 @@ interface Props<T = any>
19
25
  fetchMore?: () => Promise<void>
20
26
  loadingComponent?: React.ReactNode
21
27
  isFetchingMore?: boolean
28
+ onRefresh?: () => Promise<void>
29
+ refreshing?: boolean
30
+ enablePullToRefresh?: boolean
22
31
  }
23
32
 
24
33
  export function List<T = any>({
@@ -31,9 +40,20 @@ export function List<T = any>({
31
40
  fetchMore,
32
41
  loadingComponent,
33
42
  isFetchingMore,
43
+ onRefresh,
44
+ refreshing,
45
+ enablePullToRefresh = true,
34
46
  ...virtuosoProps
35
47
  }: Props<T>) {
36
48
  const inFlightFetchMoreRef = useRef<Promise<void> | null>(null)
49
+ const virtuosoRef = useRef<any>(null)
50
+ const containerRef = useRef<HTMLDivElement>(null)
51
+
52
+ const {state: pullToRefreshState, bindToElement} = usePullToRefresh({
53
+ onRefresh,
54
+ threshold: DEFAULT_REFRESH_PULL_THRESHOLD,
55
+ enabled: enablePullToRefresh && Boolean(onRefresh),
56
+ })
37
57
 
38
58
  const _fetchMore = useCallback(() => {
39
59
  // Dedupe concurrent calls by returning the same in-flight promise
@@ -68,18 +88,83 @@ export function List<T = any>({
68
88
 
69
89
  const classNames = cn(showScrollbar ? undefined : 'no-scrollbars', className)
70
90
 
91
+ useEffect(() => {
92
+ if (containerRef.current && enablePullToRefresh && onRefresh) {
93
+ let cleanup: (() => void) | undefined
94
+
95
+ const findAndBind = () => {
96
+ if (!containerRef.current) return
97
+
98
+ const scrollableElement = findVirtuosoScrollableElement(
99
+ containerRef.current
100
+ )
101
+ cleanup = bindToElement(scrollableElement)
102
+ }
103
+
104
+ const timeoutId = setTimeout(findAndBind, ELEMENT_BIND_DELAY)
105
+
106
+ return () => {
107
+ clearTimeout(timeoutId)
108
+ if (cleanup) cleanup()
109
+ }
110
+ }
111
+ return undefined
112
+ }, [bindToElement, enablePullToRefresh, onRefresh])
113
+
114
+ const EnhancedHeader = useCallback(() => {
115
+ const effectivePullDistance = refreshing
116
+ ? Math.max(pullToRefreshState.pullDistance, 140)
117
+ : pullToRefreshState.pullDistance
118
+
119
+ const refreshHeaderHeight = Math.min(
120
+ Math.max(effectivePullDistance, 0),
121
+ 140
122
+ )
123
+
124
+ return (
125
+ <>
126
+ {enablePullToRefresh && onRefresh && (
127
+ <div
128
+ className="flex items-center justify-center"
129
+ style={{
130
+ height: refreshHeaderHeight,
131
+ overflow: 'hidden',
132
+ }}
133
+ >
134
+ <RefreshIndicator
135
+ pullDistance={pullToRefreshState.pullDistance}
136
+ threshold={DEFAULT_REFRESH_PULL_THRESHOLD}
137
+ isRefreshing={refreshing ?? false}
138
+ canRefresh={pullToRefreshState.canRefresh}
139
+ className="relative top-0 inset-x-auto"
140
+ />
141
+ </div>
142
+ )}
143
+ {header && <div>{header}</div>}
144
+ </>
145
+ )
146
+ }, [header, enablePullToRefresh, onRefresh, pullToRefreshState, refreshing])
147
+
71
148
  return (
72
- <Virtuoso
73
- className={classNames}
74
- style={{height}}
75
- data={items}
76
- itemContent={itemContent}
77
- components={{
78
- Header: header ? () => <div>{header}</div> : undefined,
79
- Footer,
149
+ <div
150
+ ref={containerRef}
151
+ className={cn('relative transition-all duration-200', classNames)}
152
+ style={{
153
+ height,
80
154
  }}
81
- endReached={fetchMore ? _fetchMore : undefined}
82
- {...virtuosoProps}
83
- />
155
+ >
156
+ <Virtuoso
157
+ ref={virtuosoRef}
158
+ className="h-full w-full"
159
+ data={items}
160
+ itemContent={itemContent}
161
+ components={{
162
+ Header: EnhancedHeader,
163
+ Footer,
164
+ }}
165
+ endReached={fetchMore ? _fetchMore : undefined}
166
+ {...virtuosoProps}
167
+ />
168
+ </div>
84
169
  )
85
170
  }
@@ -0,0 +1,73 @@
1
+ import {describe, expect, it, vi} from 'vitest'
2
+
3
+ import {render, screen, mockMinisSDK, resetAllMocks} from '../../test-utils'
4
+
5
+ import {AddToCartButton} from './add-to-cart'
6
+
7
+ // Mock hooks
8
+ vi.mock('../../hooks/shop/useShopCartActions', () => ({
9
+ useShopCartActions: () => ({
10
+ addToCart: mockMinisSDK.addToCart,
11
+ buyProduct: mockMinisSDK.buyProduct,
12
+ }),
13
+ }))
14
+
15
+ describe('AddToCartButton', () => {
16
+ const defaultProps = {
17
+ productId: 'gid://shopify/Product/123',
18
+ productVariantId: 'gid://shopify/ProductVariant/456',
19
+ }
20
+
21
+ // eslint-disable-next-line jest/require-top-level-describe
22
+ beforeEach(() => {
23
+ resetAllMocks()
24
+ })
25
+
26
+ it('renders with default text', () => {
27
+ render(<AddToCartButton {...defaultProps} />)
28
+
29
+ expect(screen.getByRole('button')).toBeInTheDocument()
30
+ expect(screen.getByText('Add to cart')).toBeInTheDocument()
31
+ })
32
+
33
+ it('renders with required props', () => {
34
+ render(<AddToCartButton {...defaultProps} />)
35
+
36
+ const button = screen.getByRole('button')
37
+ expect(button).toBeInTheDocument()
38
+ })
39
+
40
+ it('respects disabled prop', () => {
41
+ render(<AddToCartButton {...defaultProps} disabled />)
42
+
43
+ const button = screen.getByRole('button')
44
+ expect(button).toBeDisabled()
45
+ })
46
+
47
+ it('applies custom className', () => {
48
+ render(<AddToCartButton {...defaultProps} className="custom-class" />)
49
+
50
+ const button = screen.getByRole('button')
51
+ expect(button).toHaveClass('custom-class')
52
+ })
53
+
54
+ it('renders with different sizes', () => {
55
+ const {rerender} = render(<AddToCartButton {...defaultProps} size="sm" />)
56
+
57
+ expect(screen.getByRole('button')).toBeInTheDocument()
58
+
59
+ rerender(<AddToCartButton {...defaultProps} size="default" />)
60
+ expect(screen.getByRole('button')).toBeInTheDocument()
61
+
62
+ rerender(<AddToCartButton {...defaultProps} size="lg" />)
63
+ expect(screen.getByRole('button')).toBeInTheDocument()
64
+ })
65
+
66
+ it('renders with discount codes prop', () => {
67
+ const discountCodes = ['SUMMER20', 'FREESHIP']
68
+
69
+ render(<AddToCartButton {...defaultProps} discountCodes={discountCodes} />)
70
+
71
+ expect(screen.getByRole('button')).toBeInTheDocument()
72
+ })
73
+ })
@@ -0,0 +1,132 @@
1
+ import * as React from 'react'
2
+ import {useState, useCallback} from 'react'
3
+
4
+ import {CheckIcon} from 'lucide-react'
5
+ import {motion, AnimatePresence} from 'motion/react'
6
+
7
+ import {useErrorToast, useShopCartActions} from '../../hooks'
8
+ import {cn} from '../../lib/utils'
9
+ import {Button} from '../atoms/button'
10
+
11
+ interface AddToCartButtonProps {
12
+ disabled?: boolean
13
+ className?: string
14
+ size?: 'default' | 'sm' | 'lg'
15
+ /**
16
+ * The discount codes to apply to the cart.
17
+ */
18
+ discountCodes?: string[]
19
+ /**
20
+ * The GID of the product. E.g. `gid://shopify/Product/123`.
21
+ */
22
+ productId: string
23
+ /**
24
+ * The GID of the product variant. E.g. `gid://shopify/ProductVariant/456`.
25
+ */
26
+ productVariantId: string
27
+ }
28
+
29
+ export function AddToCartButton({
30
+ disabled = false,
31
+ className,
32
+ size = 'default',
33
+ productId,
34
+ productVariantId,
35
+ discountCodes,
36
+ }: AddToCartButtonProps) {
37
+ const {addToCart} = useShopCartActions()
38
+ const [isAdded, setIsAdded] = useState(false)
39
+ const timeoutRef = React.useRef<number | undefined>(undefined)
40
+
41
+ const {showErrorToast} = useErrorToast()
42
+
43
+ const handleClick = useCallback(async () => {
44
+ if (isAdded || disabled) return
45
+
46
+ try {
47
+ // Call the callback if provided
48
+ if (productId && productVariantId) {
49
+ // Optimistic update with error toast
50
+ addToCart({
51
+ productId,
52
+ productVariantId,
53
+ quantity: 1,
54
+ discountCodes,
55
+ })
56
+ .then(() => {})
57
+ .catch(() => {
58
+ showErrorToast({message: 'Failed to add to cart'})
59
+ })
60
+ }
61
+
62
+ // Show success state
63
+ setIsAdded(true)
64
+
65
+ // Clear any existing timeout
66
+ if (timeoutRef.current) {
67
+ clearTimeout(timeoutRef.current)
68
+ }
69
+
70
+ // Reset to initial state after delay
71
+ timeoutRef.current = window.setTimeout(() => {
72
+ setIsAdded(false)
73
+ }, 2000)
74
+ } catch (error) {
75
+ // Handle error - reset to initial state
76
+ setIsAdded(false)
77
+ console.error('Failed to add to cart:', error)
78
+ }
79
+ }, [
80
+ isAdded,
81
+ disabled,
82
+ addToCart,
83
+ productId,
84
+ productVariantId,
85
+ discountCodes,
86
+ showErrorToast,
87
+ ])
88
+
89
+ // Cleanup timeout on unmount
90
+ React.useEffect(() => {
91
+ return () => {
92
+ if (timeoutRef.current) {
93
+ clearTimeout(timeoutRef.current)
94
+ }
95
+ }
96
+ }, [])
97
+
98
+ return (
99
+ <Button
100
+ onClick={handleClick}
101
+ disabled={disabled}
102
+ className={cn(
103
+ 'relative overflow-hidden transition-all duration-300',
104
+ className
105
+ )}
106
+ size={size}
107
+ >
108
+ <div className="relative flex items-center justify-center">
109
+ <AnimatePresence>
110
+ {isAdded && (
111
+ <motion.div
112
+ initial={{scale: 0, rotate: -180}}
113
+ animate={{scale: 1, rotate: 0}}
114
+ exit={{scale: 0, rotate: 180}}
115
+ transition={{
116
+ duration: 0.4,
117
+ ease: [0.175, 0.885, 0.32, 1.275], // bounce effect
118
+ }}
119
+ className="absolute left-0"
120
+ style={{x: -8}}
121
+ >
122
+ <CheckIcon className="size-4" />
123
+ </motion.div>
124
+ )}
125
+ </AnimatePresence>
126
+ <span className={cn(isAdded && 'pl-5', 'transition-all duration-300')}>
127
+ {isAdded ? 'Added to cart' : 'Add to cart'}
128
+ </span>
129
+ </div>
130
+ </Button>
131
+ )
132
+ }
@@ -1,6 +1,6 @@
1
1
  import {Heart} from 'lucide-react'
2
2
 
3
- import {IconButton} from './icon-button'
3
+ import {IconButton} from '../atoms/icon-button'
4
4
 
5
5
  export function FavoriteButton({
6
6
  onClick,
@@ -7,12 +7,13 @@ import {useShopNavigation} from '../../hooks/navigation/useShopNavigation'
7
7
  import {useSavedProductsActions} from '../../hooks/user/useSavedProductsActions'
8
8
  import {formatMoney} from '../../lib/formatMoney'
9
9
  import {cn} from '../../lib/utils'
10
- import {FavoriteButton} from '../atoms/favorite-button'
11
10
  import {Image} from '../atoms/image'
12
11
  import {ProductVariantPrice} from '../atoms/product-variant-price'
13
12
  import {Touchable} from '../atoms/touchable'
14
13
  import {Badge} from '../ui/badge'
15
14
 
15
+ import {FavoriteButton} from './favorite-button'
16
+
16
17
  // Context definition
17
18
  interface ProductCardContextValue {
18
19
  // Core data
@@ -248,6 +248,7 @@ describe('ProductLink', () => {
248
248
  isFavorited: false,
249
249
  selectedVariant: {
250
250
  id: 'selected-variant-id',
251
+ title: 'Selected Variant',
251
252
  isFavorited: false,
252
253
  price: {amount: '29.99', currencyCode: 'USD'},
253
254
  compareAtPrice: null,
@@ -9,10 +9,11 @@ import {useShopNavigation} from '../../hooks/navigation/useShopNavigation'
9
9
  import {useSavedProductsActions} from '../../hooks/user/useSavedProductsActions'
10
10
  import {formatMoney} from '../../lib/formatMoney'
11
11
  import {cn} from '../../lib/utils'
12
- import {FavoriteButton} from '../atoms/favorite-button'
13
12
  import {Touchable} from '../atoms/touchable'
14
13
  import {Card, CardContent, CardAction} from '../ui/card'
15
14
 
15
+ import {FavoriteButton} from './favorite-button'
16
+
16
17
  const productLinkVariants = cva('', {
17
18
  variants: {
18
19
  layout: {
@@ -1,5 +1,6 @@
1
1
  export * from './MinisContainer'
2
2
 
3
+ export * from './commerce/add-to-cart'
3
4
  export * from './commerce/product-card'
4
5
  export * from './commerce/product-link'
5
6
  export * from './commerce/merchant-card'
@@ -7,6 +8,7 @@ export * from './commerce/product-card-skeleton'
7
8
  export * from './commerce/merchant-card-skeleton'
8
9
  export * from './commerce/quantity-selector'
9
10
  export * from './commerce/search'
11
+ export * from './commerce/favorite-button'
10
12
 
11
13
  export * from './content/image-content-wrapper'
12
14
 
@@ -14,7 +16,6 @@ export * from './navigation/minis-router'
14
16
  export * from './navigation/transition-link'
15
17
 
16
18
  export * from './atoms/button'
17
- export * from './atoms/favorite-button'
18
19
  export * from './atoms/icon-button'
19
20
  export * from './atoms/image'
20
21
  export * from './atoms/touchable'
@@ -0,0 +1,103 @@
1
+ import {forwardRef, useEffect, useState} from 'react'
2
+
3
+ import {cn} from '../../lib/utils'
4
+
5
+ export interface PullToRefreshIndicatorProps {
6
+ pullDistance: number
7
+ threshold: number
8
+ isRefreshing: boolean
9
+ canRefresh: boolean
10
+ className?: string
11
+ }
12
+
13
+ export const RefreshIndicator = forwardRef<
14
+ HTMLDivElement,
15
+ PullToRefreshIndicatorProps
16
+ >(({pullDistance, threshold, isRefreshing, canRefresh, className}, ref) => {
17
+ const [showBumpAnimation, setShowBumpAnimation] = useState(false)
18
+
19
+ const progress = Math.min(pullDistance / threshold, 1)
20
+
21
+ const spinnerProgress = 0.54 + progress * (1 - 0.54)
22
+
23
+ const scale = isRefreshing ? 1 : 0.5 + progress * 0.5
24
+
25
+ const opacity = isRefreshing ? 1 : Math.min(progress * 1.5, 1)
26
+
27
+ const translateY = isRefreshing ? 0 : progress * 3
28
+
29
+ useEffect(() => {
30
+ if (isRefreshing && !showBumpAnimation) {
31
+ setShowBumpAnimation(true)
32
+ const timer = setTimeout(() => setShowBumpAnimation(false), 360)
33
+ return () => clearTimeout(timer)
34
+ }
35
+ return undefined
36
+ }, [isRefreshing, showBumpAnimation])
37
+
38
+ return (
39
+ <div
40
+ ref={ref}
41
+ className={cn(
42
+ 'flex items-center justify-center w-full h-full',
43
+ 'transition-all duration-200 ease-out',
44
+ className
45
+ )}
46
+ style={{
47
+ transform: `translateY(${translateY}px)`,
48
+ }}
49
+ >
50
+ <div
51
+ className={cn(
52
+ 'flex flex-col items-center space-y-2 rounded-full px-4 py-2 backdrop-blur-sm transition-all duration-200',
53
+ canRefresh || isRefreshing
54
+ ? 'bg-primary/20 border-2 border-primary/40'
55
+ : 'bg-background/90'
56
+ )}
57
+ style={{
58
+ opacity,
59
+ transform: `scale(${scale})`,
60
+ }}
61
+ >
62
+ <div
63
+ className={cn(
64
+ 'h-8 w-8 transition-all duration-200',
65
+ showBumpAnimation && 'animate-bump'
66
+ )}
67
+ >
68
+ <svg
69
+ viewBox="0 0 52 58"
70
+ fill="none"
71
+ xmlns="http://www.w3.org/2000/svg"
72
+ className={cn(
73
+ 'h-full w-full transition-colors duration-200',
74
+ canRefresh || isRefreshing
75
+ ? 'text-primary'
76
+ : 'text-muted-foreground'
77
+ )}
78
+ >
79
+ <path
80
+ className={cn(
81
+ 'shop-spinner-path',
82
+ !isRefreshing && 'shop-spinner-progress',
83
+ isRefreshing && 'animate-shop-spin'
84
+ )}
85
+ d="M3 13C5 11.75 10.4968 6.92307 21.5 6.4999C34.5 5.99993 42 13 45 23C48.3 34 42.9211 48.1335 30.5 51C17.5 54 6.6 46 6 37C5.46667 29 10.5 25 14 23"
86
+ stroke="currentColor"
87
+ strokeWidth="8"
88
+ strokeLinecap="square"
89
+ strokeLinejoin="miter"
90
+ style={
91
+ {
92
+ '--spinner-progress': isRefreshing ? '1' : spinnerProgress,
93
+ } as React.CSSProperties
94
+ }
95
+ />
96
+ </svg>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ )
101
+ })
102
+
103
+ RefreshIndicator.displayName = 'RefreshIndicator'