Amber Shopify Project created using ReactJS+React-Redux with GraphQL API integration. Storefront Shopify API: https://github.com/Shopify/shopify-app-js/tree/main/packages/api-clients/storefront-api-client#readme
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

ProductDetails.jsx 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { useState, useEffect } from "react";
  2. import {
  3. Box,
  4. Typography,
  5. Button,
  6. TextField,
  7. CircularProgress
  8. } from "@mui/material";
  9. import AddIcon from '@mui/icons-material/Add';
  10. import RemoveIcon from '@mui/icons-material/Remove';
  11. import { useSelector, useDispatch } from "react-redux";
  12. import { createCart, addItemToCart } from "../../redux/slices/cartSlice";
  13. import Alert from '@mui/material/Alert';
  14. import AlertTitle from '@mui/material/AlertTitle';
  15. // Utility function to check if an object is empty
  16. const isEmptyObject = (obj) => Object.keys(obj).length === 0 && obj.constructor === Object;
  17. // Check if every key in the target object matches the source object
  18. const hasMatchingProperties = (source, target) => {
  19. return Object.entries(target).every(([key, value]) => source[key] === value);
  20. };
  21. const ProductDetails = () => {
  22. const dispatch = useDispatch()
  23. const product = useSelector((state) => state.products.product.data)
  24. const cart = useSelector((state) => state.cart.cart)
  25. const [quantity, setQuantity] = useState(1)
  26. const [variantSelection, setVariantSelection] = useState({
  27. amount: "0",
  28. currencyCode: ""
  29. })
  30. const [variants, setVariants] = useState([])
  31. const [showLoader, setShowLoader] = useState(false)
  32. const [alert, setAlert] = useState({ open: false, severity: '', message: '' });
  33. useEffect(() => {
  34. if (!isEmptyObject(product)) {
  35. console.log("Product: ", product)
  36. let productVariants = product?.variants
  37. // get all variant type
  38. if (!productVariants || productVariants?.length == 0) return
  39. // we want to get the title for each variant
  40. const uniqueOptions = {};
  41. productVariants.forEach(variant => {
  42. variant.selectedOptions.forEach(option => {
  43. if (!uniqueOptions[option.name]) {
  44. uniqueOptions[option.name] = new Set();
  45. }
  46. uniqueOptions[option.name].add(option.value);
  47. });
  48. });
  49. const VariantsArr = Object.entries(uniqueOptions).map(([key, valueSet]) => ({
  50. name: key,
  51. options: Array.from(valueSet),
  52. }));
  53. // get variants value
  54. setVariants(VariantsArr)
  55. // setting Initial value for variants selection
  56. setVariantSelection((prev) => {
  57. let newVariantSelection = { ...prev }
  58. // setting inital selection
  59. VariantsArr.forEach(({ name, options }) => {
  60. newVariantSelection = { ...newVariantSelection, [name]: options[0] }
  61. })
  62. // find variant price if it all match initial variant selection
  63. for (const { selectedOptions, price, compareAtPrice, id, quantityAvailable } of productVariants) {
  64. let { amount, currencyCode } = price;
  65. if (compareAtPrice?.amount > 0) {
  66. amount = compareAtPrice?.amount
  67. currencyCode = compareAtPrice?.currencyCode
  68. }
  69. // Convert array to object
  70. const optionsObject = selectedOptions.reduce(
  71. (a, { name, value }) => ({ ...a, [name]: value }),
  72. {}
  73. );
  74. if (hasMatchingProperties(newVariantSelection, optionsObject)) {
  75. newVariantSelection = { ...newVariantSelection, amount, currencyCode, id, quantityAvailable };
  76. break; // Exit the loop when condition is met
  77. }
  78. }
  79. return newVariantSelection
  80. })
  81. }
  82. }, [product])
  83. useEffect(() => {
  84. console.log("variantSelection: ", variantSelection)
  85. }, [variantSelection])
  86. const handleVariantClick = (name, value) => {
  87. setVariantSelection({ ...variantSelection, [name]: value })
  88. setVariantSelection((prev) => {
  89. let newVariantSelection = { ...prev }
  90. newVariantSelection = { ...newVariantSelection, [name]: value }
  91. let productVariants = product?.variants
  92. // find variant price if it all match initial variant selection
  93. for (const { selectedOptions, price, compareAtPrice, id, quantityAvailable } of productVariants) {
  94. let { amount, currencyCode } = price;
  95. if (compareAtPrice?.amount > 0) {
  96. amount = compareAtPrice?.amount
  97. currencyCode = compareAtPrice?.currencyCode
  98. }
  99. // Convert array to object
  100. const optionsObject = selectedOptions.reduce(
  101. (a, { name, value }) => ({ ...a, [name]: value }),
  102. {}
  103. );
  104. if (hasMatchingProperties(newVariantSelection, optionsObject)) {
  105. newVariantSelection = { ...newVariantSelection, amount, currencyCode, id, quantityAvailable };
  106. if (quantityAvailable == 0) setQuantity(0)
  107. else setQuantity(1)
  108. break; // Exit the loop when condition is met
  109. }
  110. }
  111. return newVariantSelection
  112. })
  113. }
  114. const handleCart = () => {
  115. let cartHistory = localStorage.getItem('amber-cart');
  116. cartHistory = cartHistory ? JSON.parse(cartHistory) : {};
  117. setShowLoader(true) //cause I want to prevent user from mutiple clicking
  118. // if we got no cart, then create a new one
  119. if (isEmptyObject(cart) || isEmptyObject(cartHistory)) {
  120. dispatch(createCart())
  121. .then(() => {
  122. showAlert('success', 'Cart created successfully!');
  123. })
  124. .catch(() => {
  125. showAlert('error', 'Failed to create cart. Please try again.');
  126. })
  127. .finally(() => setShowLoader(false));
  128. } else {
  129. console.log("ADD ITEM:", variantSelection)
  130. dispatch(addItemToCart({
  131. cartId: cartHistory.id,
  132. lines: [
  133. {
  134. merchandiseId: variantSelection.id,
  135. quantity
  136. }
  137. ]
  138. }))
  139. .then(() => {
  140. showAlert('success', 'Item added to cart successfully!');
  141. })
  142. .catch(() => {
  143. showAlert('error', 'Failed to add item to cart. Please try again.');
  144. })
  145. .finally(() => setShowLoader(false));
  146. }
  147. }
  148. const showAlert = (severity, message) => {
  149. setAlert({ open: true, severity, message });
  150. // Auto-close the alert after 3 seconds
  151. setTimeout(() => {
  152. setAlert({ ...alert, open: false });
  153. }, 2000);
  154. };
  155. const handleIncrement = () => {
  156. setQuantity((prevQuantity) => (prevQuantity >= variantSelection.quantityAvailable ? variantSelection.quantityAvailable : prevQuantity + 1));
  157. };
  158. const handleDecrement = () => {
  159. setQuantity((prevQuantity) => (prevQuantity > 1 ? prevQuantity - 1 : 1));
  160. };
  161. return (
  162. <Box sx={{ position: "relative" }}>
  163. {/* Flex Container */}
  164. <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
  165. {/* Section 1: Product Info */}
  166. <Box>
  167. <Typography variant="body2" sx={{
  168. fontWeight: "400", fontSize: {
  169. xs: "0.875rem",
  170. sm: "0.875rem",
  171. md: "1.1rem",
  172. }
  173. }}>
  174. {product?.title}
  175. </Typography>
  176. {/* <Typography variant="body2" color="text.secondary">
  177. {product?.collections?.nodes[0]?.title}
  178. </Typography> */}
  179. <Typography variant="body2" sx={{
  180. fontWeight: "100", fontSize: {
  181. xs: "0.875rem",
  182. sm: "0.875rem",
  183. md: "1.1rem",
  184. }
  185. }}>
  186. {(variantSelection?.quantityAvailable == 0) ? <span style={{ color: "red", fontWeight: "bolder" }}>{`OUT OF STOCK`}</span> : `${variantSelection.currencyCode} ${parseFloat(variantSelection.amount).toFixed(2)}`}
  187. </Typography>
  188. </Box>
  189. {/* Section 2: Variants */}
  190. <Box >
  191. {variants.map(({ name, options }, index) => {
  192. return (
  193. <Box sx={{ display: (name == "Title") ? "none" : "block" }}>
  194. <Typography variant="body2" sx={{
  195. fontWeight: "400", fontSize: {
  196. xs: "0.875rem",
  197. sm: "0.875rem",
  198. md: "1.1rem",
  199. },
  200. mb: 1
  201. }}>
  202. {name}
  203. </Typography>
  204. <Box sx={{
  205. display: "flex",
  206. flexWrap: "wrap",
  207. mb: 2
  208. }} gap={2}>
  209. {options?.map((value) => (
  210. <Button
  211. key={value}
  212. variant={variantSelection[name] === value ? "contained" : "outlined"}
  213. color={variantSelection[name] === value ? "primary" : "primary"}
  214. sx={{ color: variantSelection[name] === value ? "#FFF" : "#000" }}
  215. onClick={() => handleVariantClick(name, value)}
  216. >
  217. {value}
  218. </Button>
  219. ))}
  220. </Box>
  221. </Box>
  222. )
  223. })}
  224. </Box>
  225. {/* Section 3: Quantity */}
  226. <Box sx={{ mb: 5 }}>
  227. <Typography variant="body2" sx={{
  228. fontWeight: "400", fontSize: {
  229. xs: "0.875rem",
  230. sm: "0.875rem",
  231. md: "1.1rem",
  232. },
  233. mb: 1
  234. }}>
  235. Quantity
  236. </Typography>
  237. <Box display="flex" alignItems="center" gap={2}>
  238. <Button
  239. variant="contained"
  240. color="primary"
  241. sx={{ width: "35px" }}
  242. disabled={variantSelection?.quantityAvailable == 0 || quantity == 1}
  243. onClick={handleDecrement}
  244. >
  245. <RemoveIcon />
  246. </Button>
  247. <TextField
  248. value={quantity}
  249. inputProps={{ readOnly: true, style: { textAlign: 'center' } }}
  250. size="small"
  251. sx={{ width: "100px" }}
  252. variant="outlined"
  253. />
  254. <Button
  255. variant="contained"
  256. color="primary"
  257. sx={{ width: "35px" }}
  258. disabled={variantSelection?.quantityAvailable == 0 || quantity == variantSelection?.quantityAvailable}
  259. onClick={handleIncrement}
  260. >
  261. <AddIcon />
  262. </Button>
  263. </Box>
  264. </Box>
  265. {/* Section 4: Description */}
  266. <Box sx={{ mb: 5 }}>
  267. <Typography variant="body1" sx={{ fontWeight: "400", color: "#000" }}>
  268. Description
  269. </Typography>
  270. <Typography variant="body1" color="text.secondary" sx={{ fontWeight: "400" }}>
  271. <div dangerouslySetInnerHTML={{ __html: product?.descriptionHtml }}></div>
  272. </Typography>
  273. </Box>
  274. <Button
  275. onClick={() => { handleCart() }}
  276. variant="contained"
  277. color="common.black"
  278. fullWidth
  279. disabled={variantSelection?.quantityAvailable == 0 || showLoader}
  280. sx={{
  281. backgroundColor: (theme) => theme.palette.common.black,
  282. color: "white",
  283. textTransform: "none",
  284. mt: 2,
  285. "&:hover": {
  286. backgroundColor: (theme) => theme.palette.grey[900],
  287. },
  288. }}
  289. >
  290. ADD TO CART {showLoader && <CircularProgress sx={{ ml: 1 }} color="white" size={20} />}
  291. </Button>
  292. {cart?.lines?.nodes?.length > 0 && <Button
  293. onClick={() => { window.location.href = '/cart' }}
  294. variant="contained"
  295. color="common.black"
  296. fullWidth
  297. disabled={showLoader}
  298. sx={{
  299. backgroundColor: (theme) => theme.palette.primary.main,
  300. color: "white",
  301. textTransform: "none",
  302. "&:hover": {
  303. backgroundColor: (theme) => theme.palette.grey[900],
  304. },
  305. }}
  306. >
  307. PAY NOW
  308. </Button>}
  309. </Box>
  310. {alert.open && (
  311. <Alert
  312. severity={alert.severity}
  313. onClose={() => setAlert({ ...alert, open: false })}
  314. sx={{ marginTop: 2 }}
  315. >
  316. <AlertTitle>{alert.severity === 'success' ? 'Success' : 'Error'}</AlertTitle>
  317. {alert.message}
  318. </Alert>
  319. )}
  320. </Box>
  321. );
  322. };
  323. export default ProductDetails;