@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.
- package/dist/_virtual/index10.js +2 -2
- package/dist/_virtual/index5.js +2 -3
- package/dist/_virtual/index5.js.map +1 -1
- package/dist/_virtual/index6.js +3 -2
- package/dist/_virtual/index6.js.map +1 -1
- package/dist/_virtual/index7.js +2 -2
- package/dist/_virtual/index9.js +2 -2
- package/dist/components/atoms/list.js +106 -41
- package/dist/components/atoms/list.js.map +1 -1
- package/dist/components/commerce/add-to-cart.js +82 -0
- package/dist/components/commerce/add-to-cart.js.map +1 -0
- package/dist/components/{atoms → commerce}/favorite-button.js +1 -1
- package/dist/components/commerce/favorite-button.js.map +1 -0
- package/dist/components/commerce/product-card.js +10 -10
- package/dist/components/commerce/product-card.js.map +1 -1
- package/dist/components/commerce/product-link.js +6 -6
- package/dist/components/commerce/product-link.js.map +1 -1
- package/dist/index.js +276 -274
- package/dist/index.js.map +1 -1
- package/dist/internal/components/refresh-indicator.js +83 -0
- package/dist/internal/components/refresh-indicator.js.map +1 -0
- package/dist/internal/usePullToRefresh.js +149 -0
- package/dist/internal/usePullToRefresh.js.map +1 -0
- package/dist/internal/utils/virtuoso-dom.js +20 -0
- package/dist/internal/utils/virtuoso-dom.js.map +1 -0
- package/dist/mocks.js +1 -0
- package/dist/mocks.js.map +1 -1
- 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
- package/dist/shop-minis-react/node_modules/.pnpm/@videojs_xhr@2.7.0/node_modules/@videojs/xhr/lib/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/@xmldom_xmldom@0.8.10/node_modules/@xmldom/xmldom/lib/index.js +1 -1
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/dist/shop-minis-react/node_modules/.pnpm/mpd-parser@1.3.1/node_modules/mpd-parser/dist/mpd-parser.es.js +1 -1
- 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
- package/package.json +2 -2
- package/src/components/atoms/list.tsx +97 -12
- package/src/components/commerce/add-to-cart.test.tsx +73 -0
- package/src/components/commerce/add-to-cart.tsx +132 -0
- package/src/components/{atoms → commerce}/favorite-button.tsx +1 -1
- package/src/components/commerce/product-card.tsx +2 -1
- package/src/components/commerce/product-link.test.tsx +1 -0
- package/src/components/commerce/product-link.tsx +2 -1
- package/src/components/index.ts +2 -1
- package/src/internal/components/refresh-indicator.tsx +103 -0
- package/src/internal/usePullToRefresh.ts +286 -0
- package/src/internal/utils/virtuoso-dom.ts +26 -0
- package/src/mocks.ts +1 -0
- package/src/stories/AddToCart.stories.tsx +186 -0
- package/src/stories/FavoriteButton.stories.tsx +2 -2
- package/src/stories/PullToRefreshList.stories.tsx +122 -0
- package/src/styles/animations.css +54 -0
- package/src/test-utils.tsx +1 -0
- package/dist/components/atoms/favorite-button.js.map +0 -1
- /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/
|
|
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/
|
|
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.
|
|
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.
|
|
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
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
@@ -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: {
|
package/src/components/index.ts
CHANGED
|
@@ -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'
|