@wix/headless-stores 0.0.1
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/package.json +28 -0
- package/src/react/BuyNow.test.tsx +139 -0
- package/src/react/BuyNow.tsx +76 -0
- package/src/react/index.tsx +1 -0
- package/src/services/buy-now-service.ts +100 -0
- package/src/services/index.ts +4 -0
- package/src/utils/index.ts +38 -0
- package/src/vitest.setup.ts +1 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wix/headless-stores",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"build": "tsc",
|
|
6
|
+
"test": "vitest"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./react": "./dist/react/index.js",
|
|
10
|
+
"./services": "./dist/services/index.js"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@testing-library/dom": "^10.4.0",
|
|
14
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
15
|
+
"@testing-library/react": "^16.3.0",
|
|
16
|
+
"@types/node": "^20.9.0",
|
|
17
|
+
"@vitest/ui": "^3.1.4",
|
|
18
|
+
"jsdom": "^26.1.0",
|
|
19
|
+
"typescript": "^5.7.3",
|
|
20
|
+
"vitest": "^3.1.4"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@wix/ecom": "^1.0.1169",
|
|
24
|
+
"@wix/redirects": "^1.0.79",
|
|
25
|
+
"@wix/services-definitions": "^0.1.2",
|
|
26
|
+
"@wix/services-manager-react": "^0.1.9"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { BuyNow } from './BuyNow';
|
|
5
|
+
|
|
6
|
+
vi.mock('@wix/ecom', () => ({
|
|
7
|
+
checkout: {
|
|
8
|
+
createCheckout: vi.fn(),
|
|
9
|
+
ChannelType: { WEB: 'WEB' },
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('@wix/redirects', () => ({
|
|
14
|
+
redirects: {
|
|
15
|
+
createRedirectSession: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const originalLocation = window.location;
|
|
20
|
+
let ecomCheckoutMock: any;
|
|
21
|
+
let redirectsMock: any;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
const ecom = await import('@wix/ecom');
|
|
25
|
+
ecomCheckoutMock = ecom.checkout;
|
|
26
|
+
const redirectsModule = await import('@wix/redirects');
|
|
27
|
+
redirectsMock = redirectsModule.redirects;
|
|
28
|
+
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
|
|
31
|
+
delete (window as any).location;
|
|
32
|
+
(window as any).location = { ...originalLocation, href: '' };
|
|
33
|
+
|
|
34
|
+
ecomCheckoutMock.createCheckout.mockResolvedValue({ _id: 'test-checkout-id' });
|
|
35
|
+
redirectsMock.createRedirectSession.mockResolvedValue({
|
|
36
|
+
redirectSession: { fullUrl: 'http://mocked-redirect-url.com' },
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
(window as any).location = originalLocation; // Use type assertion for restoration
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('BuyNow Component from @wix/headless-stores/react', () => {
|
|
45
|
+
const testProductId = 'test-product-123';
|
|
46
|
+
const testVariant = { color: 'blue' };
|
|
47
|
+
|
|
48
|
+
const renderComponent = (props = {}) => {
|
|
49
|
+
let capturedRedirectToCheckout: () => Promise<void> = async () => {};
|
|
50
|
+
const renderOutput = render(
|
|
51
|
+
<BuyNow
|
|
52
|
+
productId={testProductId}
|
|
53
|
+
variant={testVariant}
|
|
54
|
+
{...props}
|
|
55
|
+
>
|
|
56
|
+
{({ isLoading, redirectToCheckout }) => {
|
|
57
|
+
capturedRedirectToCheckout = redirectToCheckout as () => Promise<void>;
|
|
58
|
+
if (isLoading) return <div>Loading...</div>;
|
|
59
|
+
return <button onClick={redirectToCheckout}>Buy Product Now</button>;
|
|
60
|
+
}}
|
|
61
|
+
</BuyNow>
|
|
62
|
+
);
|
|
63
|
+
return { ...renderOutput, redirectToCheckoutDirectly: capturedRedirectToCheckout };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
test('should render the button with children render prop', () => {
|
|
67
|
+
renderComponent();
|
|
68
|
+
expect(screen.getByRole('button', { name: /Buy Product Now/i })).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('should show loading state and call checkout and redirect services on click', async () => {
|
|
72
|
+
renderComponent();
|
|
73
|
+
const button = screen.getByRole('button', { name: /Buy Product Now/i });
|
|
74
|
+
fireEvent.click(button);
|
|
75
|
+
|
|
76
|
+
expect(screen.getByText(/Loading.../i)).toBeInTheDocument();
|
|
77
|
+
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(ecomCheckoutMock.createCheckout).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
expect(ecomCheckoutMock.createCheckout).toHaveBeenCalledWith({
|
|
82
|
+
lineItems: [{
|
|
83
|
+
catalogReference: {
|
|
84
|
+
catalogItemId: testProductId,
|
|
85
|
+
appId: '215238eb-22a5-4c36-9e7b-e7c08025e04e',
|
|
86
|
+
options: {
|
|
87
|
+
options: testVariant,
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
quantity: 1
|
|
91
|
+
}],
|
|
92
|
+
channelType: 'WEB',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await waitFor(() => {
|
|
96
|
+
expect(redirectsMock.createRedirectSession).toHaveBeenCalledTimes(1);
|
|
97
|
+
});
|
|
98
|
+
expect(redirectsMock.createRedirectSession).toHaveBeenCalledWith({
|
|
99
|
+
ecomCheckout: { checkoutId: 'test-checkout-id' },
|
|
100
|
+
callbacks: {
|
|
101
|
+
postFlowUrl: expect.any(String),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
expect(window.location.href).toBe('http://mocked-redirect-url.com');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(screen.getByRole('button', { name: /Buy Product Now/i })).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('should handle checkout creation failure and reject', async () => {
|
|
113
|
+
ecomCheckoutMock.createCheckout.mockResolvedValueOnce({ _id: null });
|
|
114
|
+
|
|
115
|
+
const { redirectToCheckoutDirectly } = renderComponent();
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
await expect(redirectToCheckoutDirectly()).rejects.toThrow('Failed to create checkout');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(screen.getByRole('button', { name: /Buy Product Now/i })).toBeInTheDocument();
|
|
122
|
+
expect(ecomCheckoutMock.createCheckout).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(redirectsMock.createRedirectSession).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('should set isLoading to false and reject if redirects.createRedirectSession throws', async () => {
|
|
127
|
+
redirectsMock.createRedirectSession.mockRejectedValueOnce(new Error('Redirect failed'));
|
|
128
|
+
|
|
129
|
+
const { redirectToCheckoutDirectly } = renderComponent();
|
|
130
|
+
|
|
131
|
+
await act(async () => {
|
|
132
|
+
await expect(redirectToCheckoutDirectly()).rejects.toThrow('Redirect failed');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(screen.getByRole('button', { name: /Buy Product Now/i })).toBeInTheDocument();
|
|
136
|
+
expect(redirectsMock.createRedirectSession).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(window.location.href).not.toBe('http://mocked-redirect-url.com');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useService } from "@wix/services-manager-react";
|
|
2
|
+
import { BuyNowServiceDefinition } from "../services/buy-now-service";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props passed to the render function of the BuyNow component
|
|
6
|
+
*/
|
|
7
|
+
export type BuyNowRenderProps = {
|
|
8
|
+
/** Whether the buy now operation is currently loading */
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
/** The name of the product being purchased */
|
|
11
|
+
productName: string;
|
|
12
|
+
/** Function to redirect the user to the checkout page */
|
|
13
|
+
redirectToCheckout: () => void;
|
|
14
|
+
/** The error message if the buy now operation fails */
|
|
15
|
+
error: string | null;
|
|
16
|
+
/** The price of the product being purchased */
|
|
17
|
+
price: string;
|
|
18
|
+
/** The currency of the product being purchased */
|
|
19
|
+
currency: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Props for the BuyNow component
|
|
24
|
+
*/
|
|
25
|
+
export type BuyNowProps = {
|
|
26
|
+
/** Render function that receives buy now state and actions */
|
|
27
|
+
children: (props: BuyNowRenderProps) => React.ReactNode;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A headless component that provides buy now functionality using the render props pattern.
|
|
32
|
+
*
|
|
33
|
+
* This component manages the state and actions for a "buy now" flow, allowing consumers
|
|
34
|
+
* to render their own UI while accessing the underlying buy now functionality.
|
|
35
|
+
*
|
|
36
|
+
* @param props - The component props
|
|
37
|
+
* @returns The rendered children with buy now state and actions
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <BuyNow>
|
|
42
|
+
* {({ isLoading, productName, redirectToCheckout, error, price, currency }) => (
|
|
43
|
+
* <div>
|
|
44
|
+
* <h2>{productName}</h2>
|
|
45
|
+
* <p>{price} {currency}</p>
|
|
46
|
+
* {error && <div className="error">{error}</div>}
|
|
47
|
+
* <button
|
|
48
|
+
* onClick={redirectToCheckout}
|
|
49
|
+
* disabled={isLoading}
|
|
50
|
+
* >
|
|
51
|
+
* {isLoading ? 'Processing...' : 'Buy Now'}
|
|
52
|
+
* </button>
|
|
53
|
+
* </div>
|
|
54
|
+
* )}
|
|
55
|
+
* </BuyNow>
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function BuyNow(props: BuyNowProps) {
|
|
59
|
+
const {
|
|
60
|
+
redirectToCheckout,
|
|
61
|
+
loadingSignal,
|
|
62
|
+
productName,
|
|
63
|
+
errorSignal,
|
|
64
|
+
price,
|
|
65
|
+
currency,
|
|
66
|
+
} = useService(BuyNowServiceDefinition);
|
|
67
|
+
|
|
68
|
+
return props.children({
|
|
69
|
+
isLoading: loadingSignal.get(),
|
|
70
|
+
error: errorSignal.get(),
|
|
71
|
+
productName: productName,
|
|
72
|
+
redirectToCheckout,
|
|
73
|
+
price,
|
|
74
|
+
currency,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BuyNow } from "./BuyNow";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineService,
|
|
3
|
+
implementService,
|
|
4
|
+
ServiceFactoryConfig,
|
|
5
|
+
Signal,
|
|
6
|
+
} from "@wix/services-definitions";
|
|
7
|
+
import { SignalsServiceDefinition } from "@wix/services-definitions/core-services/signals";
|
|
8
|
+
import { productsV3 } from "@wix/stores";
|
|
9
|
+
import { getCheckoutUrlForProduct } from "../utils";
|
|
10
|
+
|
|
11
|
+
export const BuyNowServiceDefinition = defineService<{
|
|
12
|
+
redirectToCheckout: () => Promise<void>;
|
|
13
|
+
loadingSignal: Signal<boolean>;
|
|
14
|
+
errorSignal: Signal<string | null>;
|
|
15
|
+
}>("BuyNow");
|
|
16
|
+
|
|
17
|
+
export const BuyNowServiceImplementation = implementService.withConfig<{
|
|
18
|
+
productId: string;
|
|
19
|
+
variantId?: string;
|
|
20
|
+
productName: string;
|
|
21
|
+
price: string;
|
|
22
|
+
currency: string;
|
|
23
|
+
}>()(BuyNowServiceDefinition, ({ getService, config }) => {
|
|
24
|
+
const signalsService = getService(SignalsServiceDefinition);
|
|
25
|
+
const loadingSignal = signalsService.signal(false) as Signal<boolean>;
|
|
26
|
+
const errorSignal = signalsService.signal<string | null>(null) as Signal<
|
|
27
|
+
string | null
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
redirectToCheckout: async () => {
|
|
32
|
+
loadingSignal.set(true);
|
|
33
|
+
try {
|
|
34
|
+
const checkoutUrl = await getCheckoutUrlForProduct(
|
|
35
|
+
config.productId,
|
|
36
|
+
config.variantId
|
|
37
|
+
);
|
|
38
|
+
window.location.href = checkoutUrl;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
errorSignal.set(error as string);
|
|
41
|
+
loadingSignal.set(false);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
loadingSignal,
|
|
45
|
+
errorSignal,
|
|
46
|
+
productName: config.productName,
|
|
47
|
+
price: config.price,
|
|
48
|
+
currency: config.currency,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const loadBuyNowServiceInitialData = async (
|
|
53
|
+
productSlug: string,
|
|
54
|
+
variantId?: string
|
|
55
|
+
) => {
|
|
56
|
+
const res = await productsV3.getProductBySlug(productSlug, {
|
|
57
|
+
fields: ["CURRENCY"],
|
|
58
|
+
});
|
|
59
|
+
const product = res.product!;
|
|
60
|
+
|
|
61
|
+
const selectedVariantId =
|
|
62
|
+
variantId ?? product.variantsInfo?.variants?.[0]?._id;
|
|
63
|
+
|
|
64
|
+
const price =
|
|
65
|
+
product.variantsInfo?.variants?.find((v) => v._id === selectedVariantId)
|
|
66
|
+
?.price?.actualPrice?.amount ??
|
|
67
|
+
product.actualPriceRange?.minValue?.amount;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
[BuyNowServiceDefinition]: {
|
|
71
|
+
productId: product._id!,
|
|
72
|
+
productName: product.name!,
|
|
73
|
+
price: price!,
|
|
74
|
+
currency: product.currency!,
|
|
75
|
+
...(typeof selectedVariantId !== "undefined"
|
|
76
|
+
? {
|
|
77
|
+
variantId: selectedVariantId!,
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const buyNowServiceBinding = <
|
|
85
|
+
T extends {
|
|
86
|
+
[key: string]: Awaited<
|
|
87
|
+
ReturnType<typeof loadBuyNowServiceInitialData>
|
|
88
|
+
>[typeof BuyNowServiceDefinition];
|
|
89
|
+
}
|
|
90
|
+
>(
|
|
91
|
+
servicesConfigs: T
|
|
92
|
+
) => {
|
|
93
|
+
return [
|
|
94
|
+
BuyNowServiceDefinition,
|
|
95
|
+
BuyNowServiceImplementation,
|
|
96
|
+
servicesConfigs[BuyNowServiceDefinition] as ServiceFactoryConfig<
|
|
97
|
+
typeof BuyNowServiceImplementation
|
|
98
|
+
>,
|
|
99
|
+
] as const;
|
|
100
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { checkout } from "@wix/ecom";
|
|
2
|
+
import { redirects } from "@wix/redirects";
|
|
3
|
+
|
|
4
|
+
const CATLOG_APP_ID_V3 = "215238eb-22a5-4c36-9e7b-e7c08025e04e";
|
|
5
|
+
|
|
6
|
+
export async function getCheckoutUrlForProduct(
|
|
7
|
+
productId: string,
|
|
8
|
+
variantId?: string
|
|
9
|
+
) {
|
|
10
|
+
const checkoutResult = await checkout.createCheckout({
|
|
11
|
+
lineItems: [
|
|
12
|
+
{
|
|
13
|
+
catalogReference: {
|
|
14
|
+
catalogItemId: productId,
|
|
15
|
+
appId: CATLOG_APP_ID_V3,
|
|
16
|
+
options: {
|
|
17
|
+
variantId,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
quantity: 1,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
channelType: checkout.ChannelType.WEB,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!checkoutResult._id) {
|
|
27
|
+
throw new Error("Failed to create checkout");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { redirectSession } = await redirects.createRedirectSession({
|
|
31
|
+
ecomCheckout: { checkoutId: checkoutResult._id },
|
|
32
|
+
callbacks: {
|
|
33
|
+
postFlowUrl: window.location.href,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return redirectSession?.fullUrl!;
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
|
4
|
+
"module": "ESNext" /* Specify what module code is generated. */,
|
|
5
|
+
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
|
6
|
+
"resolveJsonModule": true /* Enable importing .json files. */,
|
|
7
|
+
"rootDir": "./src" /* Specify the root folder within your source files. */,
|
|
8
|
+
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
|
|
9
|
+
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
|
10
|
+
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
|
11
|
+
"noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
|
|
12
|
+
"noImplicitThis": true /* Enable error reporting when 'this' is given the type 'any'. */,
|
|
13
|
+
"useUnknownInCatchVariables": true /* Default catch clause variables as 'unknown' instead of 'any'. */,
|
|
14
|
+
"noUnusedLocals": true /* Enable error reporting when local variables aren't read. */,
|
|
15
|
+
"noUnusedParameters": true /* Raise an error when a function parameter isn't read. */,
|
|
16
|
+
"noUncheckedIndexedAccess": true /* Add 'undefined' to a type when accessed using an index. */,
|
|
17
|
+
"noPropertyAccessFromIndexSignature": true /* Enforces using indexed accessors for keys declared using an indexed type. */,
|
|
18
|
+
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
|
|
19
|
+
"declaration": true,
|
|
20
|
+
"strict": true,
|
|
21
|
+
"noEmitOnError": false,
|
|
22
|
+
"jsx": "react-jsx"
|
|
23
|
+
},
|
|
24
|
+
"include": ["src/**/*"],
|
|
25
|
+
"exclude": [
|
|
26
|
+
"node_modules",
|
|
27
|
+
"dist",
|
|
28
|
+
"**/*.test.ts",
|
|
29
|
+
"**/*.test.tsx",
|
|
30
|
+
"**/*.spec.ts",
|
|
31
|
+
"**/*.spec.tsx",
|
|
32
|
+
"src/vitest.setup.ts"
|
|
33
|
+
]
|
|
34
|
+
}
|
package/vitest.config.ts
ADDED