@akinon/projectzero 1.106.0 → 1.107.0
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/CHANGELOG.md +2 -0
- package/app-template/CHANGELOG.md +33 -0
- package/app-template/package.json +19 -19
- package/app-template/src/hooks/use-product-cart.ts +77 -0
- package/app-template/src/hooks/use-stock-alert.ts +74 -0
- package/app-template/src/plugins.js +1 -0
- package/app-template/src/utils/variant-validation.ts +41 -0
- package/app-template/src/views/product/product-actions.tsx +165 -0
- package/app-template/src/views/product/product-info.tsx +61 -263
- package/app-template/src/views/product/product-share.tsx +56 -0
- package/app-template/src/views/product/product-variants.tsx +26 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# projectzeronext
|
|
2
2
|
|
|
3
|
+
## 1.107.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1606f335: ZERO-3527: Refactor ProductInfo component to streamline state management and improve variant handling.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [4ca44c78]
|
|
12
|
+
- Updated dependencies [28c7ea79]
|
|
13
|
+
- Updated dependencies [72bfcbf2]
|
|
14
|
+
- Updated dependencies [b6e5b624]
|
|
15
|
+
- Updated dependencies [6bfbdc27]
|
|
16
|
+
- Updated dependencies [5b500797]
|
|
17
|
+
- Updated dependencies [9442cf01]
|
|
18
|
+
- @akinon/pz-saved-card@1.107.0
|
|
19
|
+
- @akinon/next@1.107.0
|
|
20
|
+
- @akinon/pz-basket-gift-pack@1.107.0
|
|
21
|
+
- @akinon/pz-akifast@1.107.0
|
|
22
|
+
- @akinon/pz-b2b@1.107.0
|
|
23
|
+
- @akinon/pz-bkm@1.107.0
|
|
24
|
+
- @akinon/pz-checkout-gift-pack@1.107.0
|
|
25
|
+
- @akinon/pz-click-collect@1.107.0
|
|
26
|
+
- @akinon/pz-credit-payment@1.107.0
|
|
27
|
+
- @akinon/pz-gpay@1.107.0
|
|
28
|
+
- @akinon/pz-hepsipay@1.107.0
|
|
29
|
+
- @akinon/pz-masterpass@1.107.0
|
|
30
|
+
- @akinon/pz-one-click-checkout@1.107.0
|
|
31
|
+
- @akinon/pz-otp@1.107.0
|
|
32
|
+
- @akinon/pz-pay-on-delivery@1.107.0
|
|
33
|
+
- @akinon/pz-tabby-extension@1.107.0
|
|
34
|
+
- @akinon/pz-tamara-extension@1.107.0
|
|
35
|
+
|
|
3
36
|
## 1.106.0
|
|
4
37
|
|
|
5
38
|
### Minor Changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectzeronext",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.107.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"scripts": {
|
|
@@ -24,23 +24,23 @@
|
|
|
24
24
|
"test:middleware": "jest middleware-matcher.test.ts --bail"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@akinon/next": "1.
|
|
28
|
-
"@akinon/pz-akifast": "1.
|
|
29
|
-
"@akinon/pz-b2b": "1.
|
|
30
|
-
"@akinon/pz-basket-gift-pack": "1.
|
|
31
|
-
"@akinon/pz-bkm": "1.
|
|
32
|
-
"@akinon/pz-checkout-gift-pack": "1.
|
|
33
|
-
"@akinon/pz-click-collect": "1.
|
|
34
|
-
"@akinon/pz-credit-payment": "1.
|
|
35
|
-
"@akinon/pz-gpay": "1.
|
|
36
|
-
"@akinon/pz-hepsipay": "1.
|
|
37
|
-
"@akinon/pz-masterpass": "1.
|
|
38
|
-
"@akinon/pz-one-click-checkout": "1.
|
|
39
|
-
"@akinon/pz-otp": "1.
|
|
40
|
-
"@akinon/pz-pay-on-delivery": "1.
|
|
41
|
-
"@akinon/pz-saved-card": "1.
|
|
42
|
-
"@akinon/pz-tabby-extension": "1.
|
|
43
|
-
"@akinon/pz-tamara-extension": "1.
|
|
27
|
+
"@akinon/next": "1.107.0",
|
|
28
|
+
"@akinon/pz-akifast": "1.107.0",
|
|
29
|
+
"@akinon/pz-b2b": "1.107.0",
|
|
30
|
+
"@akinon/pz-basket-gift-pack": "1.107.0",
|
|
31
|
+
"@akinon/pz-bkm": "1.107.0",
|
|
32
|
+
"@akinon/pz-checkout-gift-pack": "1.107.0",
|
|
33
|
+
"@akinon/pz-click-collect": "1.107.0",
|
|
34
|
+
"@akinon/pz-credit-payment": "1.107.0",
|
|
35
|
+
"@akinon/pz-gpay": "1.107.0",
|
|
36
|
+
"@akinon/pz-hepsipay": "1.107.0",
|
|
37
|
+
"@akinon/pz-masterpass": "1.107.0",
|
|
38
|
+
"@akinon/pz-one-click-checkout": "1.107.0",
|
|
39
|
+
"@akinon/pz-otp": "1.107.0",
|
|
40
|
+
"@akinon/pz-pay-on-delivery": "1.107.0",
|
|
41
|
+
"@akinon/pz-saved-card": "1.107.0",
|
|
42
|
+
"@akinon/pz-tabby-extension": "1.107.0",
|
|
43
|
+
"@akinon/pz-tamara-extension": "1.107.0",
|
|
44
44
|
"@hookform/resolvers": "2.9.0",
|
|
45
45
|
"@next/third-parties": "14.1.0",
|
|
46
46
|
"@react-google-maps/api": "2.17.1",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"yup": "0.32.11"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@akinon/eslint-plugin-projectzero": "1.
|
|
66
|
+
"@akinon/eslint-plugin-projectzero": "1.107.0",
|
|
67
67
|
"@semantic-release/changelog": "6.0.2",
|
|
68
68
|
"@semantic-release/exec": "6.0.3",
|
|
69
69
|
"@semantic-release/git": "10.0.1",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useAddProductToBasket } from './index';
|
|
3
|
+
import { pushAddToCart } from '@theme/utils/gtm';
|
|
4
|
+
import { validateVariantSelection } from '../utils/variant-validation';
|
|
5
|
+
import { VariantType } from '@akinon/next/types';
|
|
6
|
+
|
|
7
|
+
interface Product {
|
|
8
|
+
pk: number;
|
|
9
|
+
[key: string]: any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface UseProductCartProps {
|
|
13
|
+
product: Product;
|
|
14
|
+
variants: VariantType[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AddToCartError {
|
|
18
|
+
data?: {
|
|
19
|
+
non_field_errors?: string[];
|
|
20
|
+
[key: string]: string[];
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const useProductCart = ({ product, variants }: UseProductCartProps) => {
|
|
25
|
+
const [productError, setProductError] = useState<React.ReactNode | null>(null);
|
|
26
|
+
const [addProduct, { isLoading: isAddToCartLoading }] = useAddProductToBasket();
|
|
27
|
+
|
|
28
|
+
const formatError = (error: AddToCartError) => {
|
|
29
|
+
if (error?.data?.non_field_errors) {
|
|
30
|
+
return error.data.non_field_errors;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (error?.data) {
|
|
34
|
+
return Object.keys(error.data).map(
|
|
35
|
+
(key) => `${key}: ${error.data[key].join(', ')}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return 'An error occurred';
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const addProductToCart = async () => {
|
|
43
|
+
const validation = validateVariantSelection(variants);
|
|
44
|
+
|
|
45
|
+
if (!validation.isValid) {
|
|
46
|
+
setProductError(validation.errorMessage);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await addProduct({
|
|
52
|
+
product: product.pk,
|
|
53
|
+
quantity: 1,
|
|
54
|
+
attributes: {}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
pushAddToCart(product);
|
|
58
|
+
setProductError(null);
|
|
59
|
+
return true;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const formattedError = formatError(error as AddToCartError);
|
|
62
|
+
setProductError(formattedError);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const clearProductError = () => {
|
|
68
|
+
setProductError(null);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
addProductToCart,
|
|
73
|
+
productError,
|
|
74
|
+
clearProductError,
|
|
75
|
+
isAddToCartLoading
|
|
76
|
+
};
|
|
77
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useAddStockAlertMutation } from '@akinon/next/data/client/wishlist';
|
|
3
|
+
import { Trans } from '@akinon/next/components/trans';
|
|
4
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
5
|
+
|
|
6
|
+
interface UseStockAlertProps {
|
|
7
|
+
productPk: number;
|
|
8
|
+
userEmail?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const useStockAlert = ({ productPk, userEmail }: UseStockAlertProps) => {
|
|
12
|
+
const { t } = useLocalization();
|
|
13
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
14
|
+
const [stockAlertResponseMessage, setStockAlertResponseMessage] = useState<React.ReactNode | null>(null);
|
|
15
|
+
const [productError, setProductError] = useState<React.ReactNode | null>(null);
|
|
16
|
+
|
|
17
|
+
const [addStockAlert, { isLoading: isAddToStockAlertLoading }] = useAddStockAlertMutation();
|
|
18
|
+
|
|
19
|
+
const handleSuccess = () => {
|
|
20
|
+
setStockAlertResponseMessage(React.createElement(
|
|
21
|
+
Trans,
|
|
22
|
+
{
|
|
23
|
+
i18nKey: "product.stock_alert.success_description",
|
|
24
|
+
components: {
|
|
25
|
+
Email: React.createElement('span', {}, userEmail)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
));
|
|
29
|
+
setIsModalOpen(true);
|
|
30
|
+
setProductError(null);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleError = (err: any) => {
|
|
34
|
+
if (err.status !== 401) {
|
|
35
|
+
setStockAlertResponseMessage(
|
|
36
|
+
t('product.stock_alert.error_description').toString()
|
|
37
|
+
);
|
|
38
|
+
setIsModalOpen(true);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const addProductToStockAlertList = async () => {
|
|
43
|
+
try {
|
|
44
|
+
await addStockAlert({
|
|
45
|
+
productPk,
|
|
46
|
+
email: userEmail
|
|
47
|
+
})
|
|
48
|
+
.unwrap()
|
|
49
|
+
.then(handleSuccess)
|
|
50
|
+
.catch(handleError);
|
|
51
|
+
} catch (error: any) {
|
|
52
|
+
setProductError(error?.data?.non_field_errors || null);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const closeModal = () => {
|
|
57
|
+
setIsModalOpen(false);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const clearError = () => {
|
|
61
|
+
setProductError(null);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
addProductToStockAlertList,
|
|
66
|
+
isModalOpen,
|
|
67
|
+
setIsModalOpen,
|
|
68
|
+
stockAlertResponseMessage,
|
|
69
|
+
productError,
|
|
70
|
+
isAddToStockAlertLoading,
|
|
71
|
+
closeModal,
|
|
72
|
+
clearError
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Trans } from '@akinon/next/components/trans';
|
|
3
|
+
import { VariantType } from '@akinon/next/types';
|
|
4
|
+
|
|
5
|
+
export const isVariantSelectionComplete = (variants: VariantType[]): boolean => {
|
|
6
|
+
return variants?.every((variant) =>
|
|
7
|
+
variant?.options.some((opt) => opt.is_selected)
|
|
8
|
+
);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const getUnselectedVariant = (variants: VariantType[]): VariantType | undefined => {
|
|
12
|
+
return variants.find((variant) =>
|
|
13
|
+
variant.options.every((opt) => !opt.is_selected)
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const createVariantErrorMessage = (unselectedVariant: VariantType) => {
|
|
18
|
+
const TransComponent = Trans as any;
|
|
19
|
+
return React.createElement(
|
|
20
|
+
TransComponent,
|
|
21
|
+
{
|
|
22
|
+
i18nKey: "product.please_select_variant",
|
|
23
|
+
components: {
|
|
24
|
+
VariantName: React.createElement('span', {}, unselectedVariant.attribute_name)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const validateVariantSelection = (variants: VariantType[]) => {
|
|
31
|
+
const unselectedVariant = getUnselectedVariant(variants);
|
|
32
|
+
|
|
33
|
+
if (unselectedVariant) {
|
|
34
|
+
return {
|
|
35
|
+
isValid: false,
|
|
36
|
+
errorMessage: createVariantErrorMessage(unselectedVariant)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { isValid: true, errorMessage: null };
|
|
41
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Button, Icon, Modal } from '@theme/components';
|
|
4
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
5
|
+
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
6
|
+
import { validateVariantSelection } from '../../utils/variant-validation';
|
|
7
|
+
import { VariantType } from '@akinon/next/types';
|
|
8
|
+
|
|
9
|
+
interface Product {
|
|
10
|
+
pk: number;
|
|
11
|
+
name: string;
|
|
12
|
+
[key: string]: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ProductActionsProps {
|
|
16
|
+
product: Product;
|
|
17
|
+
variants: VariantType[];
|
|
18
|
+
inStock: boolean;
|
|
19
|
+
isVariantLoading: boolean;
|
|
20
|
+
onAddToCart: () => void;
|
|
21
|
+
onAddToStockAlert: () => void;
|
|
22
|
+
onClearError: () => void;
|
|
23
|
+
isAddToCartLoading: boolean;
|
|
24
|
+
isAddToStockAlertLoading: boolean;
|
|
25
|
+
productError: React.ReactNode | null;
|
|
26
|
+
isModalOpen: boolean;
|
|
27
|
+
stockAlertResponseMessage: React.ReactNode | null;
|
|
28
|
+
onCloseModal: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const ProductActions: React.FC<ProductActionsProps> = ({
|
|
32
|
+
product,
|
|
33
|
+
variants,
|
|
34
|
+
inStock,
|
|
35
|
+
isVariantLoading,
|
|
36
|
+
onAddToCart,
|
|
37
|
+
onAddToStockAlert,
|
|
38
|
+
onClearError,
|
|
39
|
+
isAddToCartLoading,
|
|
40
|
+
isAddToStockAlertLoading,
|
|
41
|
+
productError,
|
|
42
|
+
isModalOpen,
|
|
43
|
+
stockAlertResponseMessage,
|
|
44
|
+
onCloseModal
|
|
45
|
+
}) => {
|
|
46
|
+
const { t } = useLocalization();
|
|
47
|
+
|
|
48
|
+
const checkoutProviderProps = {
|
|
49
|
+
product,
|
|
50
|
+
clearBasket: true,
|
|
51
|
+
addBeforeClick: () => validateVariantSelection(variants).isValid,
|
|
52
|
+
openMiniBasket: false,
|
|
53
|
+
className: clsx([
|
|
54
|
+
'py-2.5',
|
|
55
|
+
'bg-black',
|
|
56
|
+
'relative',
|
|
57
|
+
'hover:bg-black',
|
|
58
|
+
'before:content-[""]',
|
|
59
|
+
'before:w-6',
|
|
60
|
+
'before:h-6',
|
|
61
|
+
'before:bg-white',
|
|
62
|
+
'before:absolute',
|
|
63
|
+
'before:rounded-r-[18px]',
|
|
64
|
+
'before:left-0',
|
|
65
|
+
'after:content-[""]',
|
|
66
|
+
'after:absolute',
|
|
67
|
+
'after:w-3',
|
|
68
|
+
'after:h-3',
|
|
69
|
+
'after:bg-[#d02c2f]',
|
|
70
|
+
'after:rounded-xl',
|
|
71
|
+
'after:left-1'
|
|
72
|
+
]),
|
|
73
|
+
onError: (error: any) => {
|
|
74
|
+
const formattedError = error?.data?.non_field_errors ||
|
|
75
|
+
Object.keys(error?.data || {}).map(
|
|
76
|
+
(key) => `${key}: ${error?.data[key].join(', ')}`
|
|
77
|
+
);
|
|
78
|
+
// This would need to be handled by parent component
|
|
79
|
+
console.error('Checkout error:', formattedError);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleMainActionClick = () => {
|
|
84
|
+
onClearError();
|
|
85
|
+
|
|
86
|
+
if (inStock) {
|
|
87
|
+
onAddToCart();
|
|
88
|
+
} else {
|
|
89
|
+
onAddToStockAlert();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
{productError && (
|
|
96
|
+
<div className="mt-4 text-xs text-center text-error">
|
|
97
|
+
{productError}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
<Button
|
|
102
|
+
disabled={isAddToCartLoading || isAddToStockAlertLoading || isVariantLoading}
|
|
103
|
+
className={clsx(
|
|
104
|
+
'fixed bottom-0 right-0 w-1/2 h-14 z-[20] flex items-center justify-center fill-primary-foreground',
|
|
105
|
+
'hover:fill-primary sm:relative sm:w-full sm:mt-3 sm:font-semibold sm:h-12'
|
|
106
|
+
)}
|
|
107
|
+
onClick={handleMainActionClick}
|
|
108
|
+
data-testid="product-add-to-cart"
|
|
109
|
+
>
|
|
110
|
+
{isVariantLoading ? (
|
|
111
|
+
<Icon
|
|
112
|
+
name="spinner"
|
|
113
|
+
size={20}
|
|
114
|
+
className="animate-spin mr-4 fill-primary"
|
|
115
|
+
/>
|
|
116
|
+
) : inStock ? (
|
|
117
|
+
<span>{t('product.add_to_cart')}</span>
|
|
118
|
+
) : (
|
|
119
|
+
<>
|
|
120
|
+
<Icon name="bell" size={20} className="mr-4" />
|
|
121
|
+
<span>{t('product.add_stock_alert')}</span>
|
|
122
|
+
</>
|
|
123
|
+
)}
|
|
124
|
+
</Button>
|
|
125
|
+
|
|
126
|
+
<PluginModule
|
|
127
|
+
component={Component.AkifastCheckoutButton}
|
|
128
|
+
props={{
|
|
129
|
+
...checkoutProviderProps,
|
|
130
|
+
isPdp: true
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
<PluginModule
|
|
135
|
+
component={Component.OneClickCheckoutButtons}
|
|
136
|
+
props={checkoutProviderProps}
|
|
137
|
+
/>
|
|
138
|
+
|
|
139
|
+
<Modal
|
|
140
|
+
portalId="stock-alert-modal"
|
|
141
|
+
open={isModalOpen}
|
|
142
|
+
setOpen={onCloseModal}
|
|
143
|
+
showCloseButton={false}
|
|
144
|
+
className="w-5/6 md:max-w-md"
|
|
145
|
+
>
|
|
146
|
+
<div className="flex flex-col items-center justify-center gap-4 px-6 py-9">
|
|
147
|
+
<Icon name="bell" size={48} />
|
|
148
|
+
<h2 className="text-xl font-semibold">
|
|
149
|
+
{t('product.stock_alert.title')}
|
|
150
|
+
</h2>
|
|
151
|
+
<div className="max-w-40 text-xs text-center leading-4">
|
|
152
|
+
<p>{stockAlertResponseMessage}</p>
|
|
153
|
+
</div>
|
|
154
|
+
<Button
|
|
155
|
+
onClick={onCloseModal}
|
|
156
|
+
appearance="outlined"
|
|
157
|
+
className="font-semibold px-10 h-12"
|
|
158
|
+
>
|
|
159
|
+
{t('product.stock_alert.close_button')}
|
|
160
|
+
</Button>
|
|
161
|
+
</div>
|
|
162
|
+
</Modal>
|
|
163
|
+
</>
|
|
164
|
+
);
|
|
165
|
+
};
|
|
@@ -1,178 +1,73 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import clsx from 'clsx';
|
|
4
|
-
import { Button, Icon, Modal } from '@theme/components';
|
|
5
|
-
import { useAddProductToBasket } from '../../hooks';
|
|
6
4
|
import React, { useEffect, useState } from 'react';
|
|
7
|
-
import {
|
|
8
|
-
import { pushAddToCart, pushProductViewed } from '@theme/utils/gtm';
|
|
9
|
-
import { PriceWrapper, Variant } from '@theme/views/product';
|
|
10
|
-
import Share from '@theme/views/share';
|
|
5
|
+
import { PriceWrapper } from '@theme/views/product';
|
|
11
6
|
import { ProductPageProps } from './layout';
|
|
12
7
|
import MiscButtons from './misc-buttons';
|
|
13
|
-
import {
|
|
14
|
-
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
15
|
-
import { Trans } from '@akinon/next/components/trans';
|
|
8
|
+
import { pushProductViewed } from '@theme/utils/gtm';
|
|
16
9
|
import { useSession } from 'next-auth/react';
|
|
10
|
+
import { isVariantSelectionComplete } from '../../utils/variant-validation';
|
|
11
|
+
import { useProductCart } from '../../hooks/use-product-cart';
|
|
12
|
+
import { useStockAlert } from '../../hooks/use-stock-alert';
|
|
13
|
+
import { ProductVariants } from './product-variants';
|
|
14
|
+
import { ProductActions } from './product-actions';
|
|
15
|
+
import { ProductShare } from './product-share';
|
|
17
16
|
|
|
18
17
|
export default function ProductInfo({ data }: ProductPageProps) {
|
|
19
|
-
const { t } = useLocalization();
|
|
20
18
|
const { data: session } = useSession();
|
|
21
|
-
const [currentUrl, setCurrentUrl] = useState(null);
|
|
22
|
-
const [productError, setProductError] = useState(null);
|
|
23
|
-
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
24
|
-
const [stockAlertResponseMessage, setStockAlertResponseMessage] =
|
|
25
|
-
useState(null);
|
|
26
19
|
const [isVariantLoading, setIsVariantLoading] = useState(false);
|
|
27
20
|
|
|
28
|
-
const [addProduct, { isLoading: isAddToCartLoading }] =
|
|
29
|
-
useAddProductToBasket();
|
|
30
|
-
const [addStockAlert, { isLoading: isAddToStockAlertLoading }] =
|
|
31
|
-
useAddStockAlertMutation();
|
|
32
21
|
const inStock = data.selected_variant !== null || data.product.in_stock;
|
|
33
22
|
|
|
34
|
-
|
|
35
|
-
|
|
23
|
+
const {
|
|
24
|
+
addProductToCart,
|
|
25
|
+
productError: cartError,
|
|
26
|
+
clearProductError: clearCartError,
|
|
27
|
+
isAddToCartLoading
|
|
28
|
+
} = useProductCart({
|
|
29
|
+
product: data.product,
|
|
30
|
+
variants: data.variants
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
addProductToStockAlertList,
|
|
35
|
+
isModalOpen,
|
|
36
|
+
stockAlertResponseMessage,
|
|
37
|
+
productError: stockError,
|
|
38
|
+
isAddToStockAlertLoading,
|
|
39
|
+
closeModal,
|
|
40
|
+
clearError: clearStockError
|
|
41
|
+
} = useStockAlert({
|
|
42
|
+
productPk: data.product.pk,
|
|
43
|
+
userEmail: session?.user?.email
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const productError = cartError || stockError;
|
|
47
|
+
const clearProductError = () => {
|
|
48
|
+
clearCartError();
|
|
49
|
+
clearStockError();
|
|
50
|
+
};
|
|
36
51
|
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
isVariantSelectionComplete(data.variants) && setIsVariantLoading(false);
|
|
37
54
|
!inStock && setIsVariantLoading(false);
|
|
38
|
-
}, [data]);
|
|
55
|
+
}, [data, inStock]);
|
|
39
56
|
|
|
40
57
|
useEffect(() => {
|
|
41
58
|
if (isVariantLoading) {
|
|
42
|
-
|
|
59
|
+
clearProductError();
|
|
43
60
|
}
|
|
44
61
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
62
|
}, [isVariantLoading]);
|
|
46
63
|
|
|
47
|
-
useEffect(() => {
|
|
48
|
-
setCurrentUrl(window.location.href);
|
|
49
|
-
}, [currentUrl]);
|
|
50
|
-
|
|
51
64
|
useEffect(() => {
|
|
52
65
|
pushProductViewed(data?.product);
|
|
53
66
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
54
67
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
await addProduct({
|
|
62
|
-
product: data.product.pk,
|
|
63
|
-
quantity: 1,
|
|
64
|
-
attributes: {}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
pushAddToCart(data?.product);
|
|
68
|
-
} catch (error) {
|
|
69
|
-
setProductError(
|
|
70
|
-
error?.data?.non_field_errors ||
|
|
71
|
-
Object.keys(error?.data).map(
|
|
72
|
-
(key) => `${key}: ${error?.data[key].join(', ')}`
|
|
73
|
-
)
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const variantsSelectionCheck = () => {
|
|
79
|
-
const unselectedVariant = data.variants.find((variant) =>
|
|
80
|
-
variant.options.every((opt) => !opt.is_selected)
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
if (unselectedVariant) {
|
|
84
|
-
setProductError(() => (
|
|
85
|
-
<Trans
|
|
86
|
-
i18nKey="product.please_select_variant"
|
|
87
|
-
components={{
|
|
88
|
-
VariantName: <span>{unselectedVariant.attribute_name}</span>
|
|
89
|
-
}}
|
|
90
|
-
/>
|
|
91
|
-
));
|
|
92
|
-
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return true;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const isVariantSelectionComplete = () => {
|
|
100
|
-
return data?.variants.every((variant) =>
|
|
101
|
-
variant?.options.some((opt) => opt.is_selected)
|
|
102
|
-
);
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const addProductToStockAlertList = async () => {
|
|
106
|
-
try {
|
|
107
|
-
await addStockAlert({
|
|
108
|
-
productPk: data.product.pk,
|
|
109
|
-
email: session?.user?.email
|
|
110
|
-
})
|
|
111
|
-
.unwrap()
|
|
112
|
-
.then(handleSuccess)
|
|
113
|
-
.catch((err) => handleError(err));
|
|
114
|
-
} catch (error) {
|
|
115
|
-
setProductError(error?.data?.non_field_errors || null);
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const handleModalClick = () => {
|
|
120
|
-
setIsModalOpen(false);
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const handleSuccess = () => {
|
|
124
|
-
setStockAlertResponseMessage(() => (
|
|
125
|
-
<Trans
|
|
126
|
-
i18nKey="product.stock_alert.success_description"
|
|
127
|
-
components={{
|
|
128
|
-
Email: <span>{session?.user?.email}</span>
|
|
129
|
-
}}
|
|
130
|
-
/>
|
|
131
|
-
));
|
|
132
|
-
setIsModalOpen(true);
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const handleError = (err) => {
|
|
136
|
-
if (err.status !== 401) {
|
|
137
|
-
setStockAlertResponseMessage(
|
|
138
|
-
t('product.stock_alert.error_description').toString()
|
|
139
|
-
);
|
|
140
|
-
setIsModalOpen(true);
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const checkoutProviderProps = {
|
|
145
|
-
product: data.product,
|
|
146
|
-
clearBasket: true,
|
|
147
|
-
addBeforeClick: variantsSelectionCheck,
|
|
148
|
-
openMiniBasket: false,
|
|
149
|
-
className: clsx([
|
|
150
|
-
'py-2.5',
|
|
151
|
-
'bg-black',
|
|
152
|
-
'relative',
|
|
153
|
-
'hover:bg-black',
|
|
154
|
-
'before:content-[""]',
|
|
155
|
-
'before:w-6',
|
|
156
|
-
'before:h-6',
|
|
157
|
-
'before:bg-white',
|
|
158
|
-
'before:absolute',
|
|
159
|
-
'before:rounded-r-[18px]',
|
|
160
|
-
'before:left-0',
|
|
161
|
-
'after:content-[""]',
|
|
162
|
-
'after:absolute',
|
|
163
|
-
'after:w-3',
|
|
164
|
-
'after:h-3',
|
|
165
|
-
'after:bg-[#d02c2f]',
|
|
166
|
-
'after:rounded-xl',
|
|
167
|
-
'after:left-1'
|
|
168
|
-
]),
|
|
169
|
-
onError: (error) =>
|
|
170
|
-
setProductError(
|
|
171
|
-
error?.data?.non_field_errors ||
|
|
172
|
-
Object.keys(error?.data).map(
|
|
173
|
-
(key) => `${key}: ${error?.data[key].join(', ')}`
|
|
174
|
-
)
|
|
175
|
-
)
|
|
68
|
+
const handleVariantChange = () => {
|
|
69
|
+
clearProductError();
|
|
70
|
+
setIsVariantLoading(true);
|
|
176
71
|
};
|
|
177
72
|
|
|
178
73
|
return (
|
|
@@ -188,72 +83,26 @@ export default function ProductInfo({ data }: ProductPageProps) {
|
|
|
188
83
|
retailPrice={data.product.retail_price}
|
|
189
84
|
/>
|
|
190
85
|
</div>
|
|
191
|
-
<div className="flex flex-col">
|
|
192
|
-
{data.variants.map((variant) => (
|
|
193
|
-
<Variant
|
|
194
|
-
key={variant.attribute_key}
|
|
195
|
-
{...variant}
|
|
196
|
-
className="items-center mt-8"
|
|
197
|
-
onChange={() => {
|
|
198
|
-
setProductError(null);
|
|
199
|
-
setIsVariantLoading(true);
|
|
200
|
-
}}
|
|
201
|
-
/>
|
|
202
|
-
))}
|
|
203
|
-
</div>
|
|
204
|
-
|
|
205
|
-
{productError && (
|
|
206
|
-
<div className="mt-4 text-xs text-center text-error">
|
|
207
|
-
{productError}
|
|
208
|
-
</div>
|
|
209
|
-
)}
|
|
210
86
|
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
className={clsx(
|
|
216
|
-
'fixed bottom-0 right-0 w-1/2 h-14 z-[20] flex items-center justify-center fill-primary-foreground',
|
|
217
|
-
'hover:fill-primary sm:relative sm:w-full sm:mt-3 sm:font-semibold sm:h-12'
|
|
218
|
-
)}
|
|
219
|
-
onClick={() => {
|
|
220
|
-
setProductError(null);
|
|
221
|
-
|
|
222
|
-
if (inStock) {
|
|
223
|
-
addProductToCart();
|
|
224
|
-
} else {
|
|
225
|
-
addProductToStockAlertList();
|
|
226
|
-
}
|
|
227
|
-
}}
|
|
228
|
-
data-testid="product-add-to-cart"
|
|
229
|
-
>
|
|
230
|
-
{isVariantLoading ? (
|
|
231
|
-
<Icon
|
|
232
|
-
name="spinner"
|
|
233
|
-
size={20}
|
|
234
|
-
className="animate-spin mr-4 fill-primary"
|
|
235
|
-
/>
|
|
236
|
-
) : inStock ? (
|
|
237
|
-
<span>{t('product.add_to_cart')}</span>
|
|
238
|
-
) : (
|
|
239
|
-
<>
|
|
240
|
-
<Icon name="bell" size={20} className="mr-4" />
|
|
241
|
-
<span>{t('product.add_stock_alert')}</span>
|
|
242
|
-
</>
|
|
243
|
-
)}
|
|
244
|
-
</Button>
|
|
245
|
-
|
|
246
|
-
<PluginModule
|
|
247
|
-
component={Component.AkifastCheckoutButton}
|
|
248
|
-
props={{
|
|
249
|
-
...checkoutProviderProps,
|
|
250
|
-
isPdp: true
|
|
251
|
-
}}
|
|
87
|
+
<ProductVariants
|
|
88
|
+
variants={data.variants}
|
|
89
|
+
onVariantChange={handleVariantChange}
|
|
252
90
|
/>
|
|
253
91
|
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
92
|
+
<ProductActions
|
|
93
|
+
product={data.product}
|
|
94
|
+
variants={data.variants}
|
|
95
|
+
inStock={inStock}
|
|
96
|
+
isVariantLoading={isVariantLoading}
|
|
97
|
+
onAddToCart={addProductToCart}
|
|
98
|
+
onAddToStockAlert={addProductToStockAlertList}
|
|
99
|
+
onClearError={clearProductError}
|
|
100
|
+
isAddToCartLoading={isAddToCartLoading}
|
|
101
|
+
isAddToStockAlertLoading={isAddToStockAlertLoading}
|
|
102
|
+
productError={productError}
|
|
103
|
+
isModalOpen={isModalOpen}
|
|
104
|
+
stockAlertResponseMessage={stockAlertResponseMessage}
|
|
105
|
+
onCloseModal={closeModal}
|
|
257
106
|
/>
|
|
258
107
|
|
|
259
108
|
<MiscButtons
|
|
@@ -262,58 +111,7 @@ export default function ProductInfo({ data }: ProductPageProps) {
|
|
|
262
111
|
variants={data.variants}
|
|
263
112
|
/>
|
|
264
113
|
|
|
265
|
-
<
|
|
266
|
-
className="my-2 sm:mb-4"
|
|
267
|
-
buttonText={t('product.share')}
|
|
268
|
-
items={[
|
|
269
|
-
{
|
|
270
|
-
href: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
|
|
271
|
-
currentUrl
|
|
272
|
-
)}`,
|
|
273
|
-
iconName: 'facebook',
|
|
274
|
-
iconSize: 22
|
|
275
|
-
},
|
|
276
|
-
{
|
|
277
|
-
href: `https://twitter.com/intent/tweet?text=${encodeURIComponent(
|
|
278
|
-
currentUrl
|
|
279
|
-
)}`,
|
|
280
|
-
iconName: 'twitter',
|
|
281
|
-
iconSize: 22
|
|
282
|
-
},
|
|
283
|
-
{
|
|
284
|
-
href: `https://api.whatsapp.com/send?text=${
|
|
285
|
-
data.product.name
|
|
286
|
-
}%20${encodeURIComponent(currentUrl)}`,
|
|
287
|
-
iconName: 'whatsapp',
|
|
288
|
-
iconSize: 22
|
|
289
|
-
}
|
|
290
|
-
]}
|
|
291
|
-
/>
|
|
292
|
-
|
|
293
|
-
<Modal
|
|
294
|
-
portalId="stock-alert-modal"
|
|
295
|
-
open={isModalOpen}
|
|
296
|
-
setOpen={setIsModalOpen}
|
|
297
|
-
showCloseButton={false}
|
|
298
|
-
className="w-5/6 md:max-w-md"
|
|
299
|
-
>
|
|
300
|
-
<div className="flex flex-col items-center justify-center gap-4 px-6 py-9">
|
|
301
|
-
<Icon name="bell" size={48} />
|
|
302
|
-
<h2 className="text-xl font-semibold">
|
|
303
|
-
{t('product.stock_alert.title')}
|
|
304
|
-
</h2>
|
|
305
|
-
<div className="max-w-40 text-xs text-center leading-4">
|
|
306
|
-
<p>{stockAlertResponseMessage}</p>
|
|
307
|
-
</div>
|
|
308
|
-
<Button
|
|
309
|
-
onClick={handleModalClick}
|
|
310
|
-
appearance="outlined"
|
|
311
|
-
className="font-semibold px-10 h-12"
|
|
312
|
-
>
|
|
313
|
-
{t('product.stock_alert.close_button')}
|
|
314
|
-
</Button>
|
|
315
|
-
</div>
|
|
316
|
-
</Modal>
|
|
114
|
+
<ProductShare productName={data.product.name} className="my-2 sm:mb-4" />
|
|
317
115
|
</>
|
|
318
116
|
);
|
|
319
117
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import Share from '@theme/views/share';
|
|
3
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
4
|
+
|
|
5
|
+
interface ProductShareProps {
|
|
6
|
+
productName: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const ProductShare: React.FC<ProductShareProps> = ({
|
|
11
|
+
productName,
|
|
12
|
+
className
|
|
13
|
+
}) => {
|
|
14
|
+
const { t } = useLocalization();
|
|
15
|
+
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
setCurrentUrl(window.location.href);
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
if (!currentUrl) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const shareItems = [
|
|
26
|
+
{
|
|
27
|
+
href: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
|
|
28
|
+
currentUrl
|
|
29
|
+
)}`,
|
|
30
|
+
iconName: 'facebook' as const,
|
|
31
|
+
iconSize: 22
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
href: `https://twitter.com/intent/tweet?text=${encodeURIComponent(
|
|
35
|
+
currentUrl
|
|
36
|
+
)}`,
|
|
37
|
+
iconName: 'twitter' as const,
|
|
38
|
+
iconSize: 22
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
href: `https://api.whatsapp.com/send?text=${productName}%20${encodeURIComponent(
|
|
42
|
+
currentUrl
|
|
43
|
+
)}`,
|
|
44
|
+
iconName: 'whatsapp' as const,
|
|
45
|
+
iconSize: 22
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Share
|
|
51
|
+
className={className}
|
|
52
|
+
buttonText={t('product.share')}
|
|
53
|
+
items={shareItems}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Variant } from '@theme/views/product';
|
|
3
|
+
import { VariantType } from '@akinon/next/types';
|
|
4
|
+
|
|
5
|
+
interface ProductVariantsProps {
|
|
6
|
+
variants: VariantType[];
|
|
7
|
+
onVariantChange: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const ProductVariants: React.FC<ProductVariantsProps> = ({
|
|
11
|
+
variants,
|
|
12
|
+
onVariantChange
|
|
13
|
+
}) => {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex flex-col">
|
|
16
|
+
{variants.map((variant) => (
|
|
17
|
+
<Variant
|
|
18
|
+
key={variant.attribute_key}
|
|
19
|
+
{...variant}
|
|
20
|
+
className="items-center mt-8"
|
|
21
|
+
onChange={onVariantChange}
|
|
22
|
+
/>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
};
|