Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions examples/astro-components-demo/src/components/App.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import logo from "../assets/logo.svg";
import "./App.css";
import { Bookings } from "@wix/headless-bookings/react";
import { Ecom } from "@wix/headless-ecom/react";
import { Stores } from "@wix/headless-stores/react";

function App() {
return (
<div className="App">
<Bookings />
<Ecom />
<Stores />
<header className="App-header">
<img src={logo.src} className="App-logo" alt="logo" />
<p>
Expand Down
15 changes: 15 additions & 0 deletions examples/astro-components-demo/src/components/ui/buy-now.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BuyNow as BuyNowPrimitive } from "@wix/headless-stores/react";

export function BuyNow(props: Omit<React.ComponentProps<typeof BuyNowPrimitive>, "children">) {
return <BuyNowPrimitive productId={props.productId} variant={props.variant}>
{({ isLoading, redirectToCheckout }) => {
if (isLoading) return <>Preparing checkout...</>;

return (
<button onClick={redirectToCheckout} className="bg-blue-500 text-white p-2 rounded-md">
Yalla, Buy!
</button>
);
}}
</BuyNowPrimitive>
}
3 changes: 3 additions & 0 deletions examples/astro-components-demo/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
import React from "react";
import App from "../components/App.jsx";
import Layout from "../layouts/Layout.astro";
import { BuyNow } from "../components/ui/buy-now";

---

<Layout>
<App />
<BuyNow client:load productId="4fbd6e70-c240-4533-8dc2-9e8f3fa94d2b" variant={{ Color: "Black" }} />
</Layout>
15 changes: 13 additions & 2 deletions packages/headless-components/stores/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,24 @@
"name": "@wix/headless-stores",
"private": true,
"scripts": {
"build": "tsc"
"build": "tsc",
"test": "vitest"
},
"exports": {
"./react": "./dist/react/index.js"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "^20.9.0",
"typescript": "^5.7.3"
"@vitest/ui": "^3.1.4",
"jsdom": "^26.1.0",
"typescript": "^5.7.3",
"vitest": "^3.1.4"
},
"dependencies": {
"@wix/ecom": "^1.0.1169",
"@wix/redirects": "^1.0.79"
}
}
139 changes: 139 additions & 0 deletions packages/headless-components/stores/src/react/BuyNow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { BuyNow } from './BuyNow';

vi.mock('@wix/ecom', () => ({
checkout: {
createCheckout: vi.fn(),
ChannelType: { WEB: 'WEB' },
},
}));

vi.mock('@wix/redirects', () => ({
redirects: {
createRedirectSession: vi.fn(),
},
}));

const originalLocation = window.location;
let ecomCheckoutMock: any;
let redirectsMock: any;

beforeEach(async () => {
const ecom = await import('@wix/ecom');
ecomCheckoutMock = ecom.checkout;
const redirectsModule = await import('@wix/redirects');
redirectsMock = redirectsModule.redirects;

vi.clearAllMocks();

delete (window as any).location;
(window as any).location = { ...originalLocation, href: '' };

ecomCheckoutMock.createCheckout.mockResolvedValue({ _id: 'test-checkout-id' });
redirectsMock.createRedirectSession.mockResolvedValue({
redirectSession: { fullUrl: 'http://mocked-redirect-url.com' },
});
});

afterEach(() => {
(window as any).location = originalLocation; // Use type assertion for restoration
});

describe('BuyNow Component from @wix/headless-stores/react', () => {
const testProductId = 'test-product-123';
const testVariant = { color: 'blue' };

const renderComponent = (props = {}) => {
let capturedRedirectToCheckout: () => Promise<void> = async () => {};
const renderOutput = render(
<BuyNow
productId={testProductId}
variant={testVariant}
{...props}
>
{({ isLoading, redirectToCheckout }) => {
capturedRedirectToCheckout = redirectToCheckout as () => Promise<void>;
if (isLoading) return <div>Loading...</div>;
return <button onClick={redirectToCheckout}>Buy Product Now</button>;
}}
</BuyNow>
);
return { ...renderOutput, redirectToCheckoutDirectly: capturedRedirectToCheckout };
};

test('should render the button with children render prop', () => {
renderComponent();
expect(screen.getByRole('button', { name: /Buy Product Now/i })).toBeInTheDocument();
});

test('should show loading state and call checkout and redirect services on click', async () => {
renderComponent();
const button = screen.getByRole('button', { name: /Buy Product Now/i });
fireEvent.click(button);

expect(screen.getByText(/Loading.../i)).toBeInTheDocument();

await waitFor(() => {
expect(ecomCheckoutMock.createCheckout).toHaveBeenCalledTimes(1);
});
expect(ecomCheckoutMock.createCheckout).toHaveBeenCalledWith({
lineItems: [{
catalogReference: {
catalogItemId: testProductId,
appId: '215238eb-22a5-4c36-9e7b-e7c08025e04e',
options: {
options: testVariant,
}
},
quantity: 1
}],
channelType: 'WEB',
});

await waitFor(() => {
expect(redirectsMock.createRedirectSession).toHaveBeenCalledTimes(1);
});
expect(redirectsMock.createRedirectSession).toHaveBeenCalledWith({
ecomCheckout: { checkoutId: 'test-checkout-id' },
callbacks: {
postFlowUrl: expect.any(String),
},
});

await waitFor(() => {
expect(window.location.href).toBe('http://mocked-redirect-url.com');
});

expect(screen.getByRole('button', { name: /Buy Product Now/i })).toBeInTheDocument();
});

test('should handle checkout creation failure and reject', async () => {
ecomCheckoutMock.createCheckout.mockResolvedValueOnce({ _id: null });

const { redirectToCheckoutDirectly } = renderComponent();

await act(async () => {
await expect(redirectToCheckoutDirectly()).rejects.toThrow('Failed to create checkout');
});

expect(screen.getByRole('button', { name: /Buy Product Now/i })).toBeInTheDocument();
expect(ecomCheckoutMock.createCheckout).toHaveBeenCalledTimes(1);
expect(redirectsMock.createRedirectSession).not.toHaveBeenCalled();
});

test('should set isLoading to false and reject if redirects.createRedirectSession throws', async () => {
redirectsMock.createRedirectSession.mockRejectedValueOnce(new Error('Redirect failed'));

const { redirectToCheckoutDirectly } = renderComponent();

await act(async () => {
await expect(redirectToCheckoutDirectly()).rejects.toThrow('Redirect failed');
});

expect(screen.getByRole('button', { name: /Buy Product Now/i })).toBeInTheDocument();
expect(redirectsMock.createRedirectSession).toHaveBeenCalledTimes(1);
expect(window.location.href).not.toBe('http://mocked-redirect-url.com');
});
});
117 changes: 117 additions & 0 deletions packages/headless-components/stores/src/react/BuyNow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { checkout } from "@wix/ecom";
import { redirects } from "@wix/redirects";
import { useState } from "react";

// const CATALOG_APP_ID = "1380b703-ce81-ff05-f115-39571d94dfcd";
const CATLOG_APP_ID_V3 = "215238eb-22a5-4c36-9e7b-e7c08025e04e";

/**
* @typedef {object} BuyNowRenderProps
* @property {boolean} isLoading - Indicates if the checkout process is currently in progress.
* @property {() => Promise<void>} redirectToCheckout - A function to initiate the checkout process.
* When called, it creates a checkout for the specified product
* and then redirects the user to the Wix checkout page.
* It handles setting the loading state internally.
*/

/**
* @typedef {object} BuyNowProps
* @property {string} productId - The unique identifier of the product to be purchased.
* @property {Record<string, string>} [variant] - An optional record of product variants (e.g., color, size).
* The keys are variant identifiers and values are the selected options.
* @property {(props: BuyNowRenderProps) => React.ReactNode} children - A render prop function that receives the loading state
* and the checkout initiation function.
* This allows for custom rendering of the UI.
*/

/**
* The `BuyNow` component provides a mechanism to initiate a direct purchase for a single product.
* It encapsulates the logic for creating an e-commerce checkout and redirecting the user to the
* Wix checkout page.
*
* This component uses a render prop pattern (`children`) to provide flexibility in how the UI
* (e.g., a buy button, loading indicator) is rendered. The render prop receives `isLoading` state
* and a `redirectToCheckout` function.
*
* The `redirectToCheckout` function is asynchronous. It first sets `isLoading` to true, then calls
* the Wix Ecom SDK to create a checkout with the specified `productId` and `variant`.
* If successful, it uses the Wix Redirects SDK to generate a redirect session and then navigates
* the user to the checkout URL by setting `window.location.href`.
* The `isLoading` state is set to false when the process completes (either successfully or on error).
*
* @param {BuyNowProps} props - The props for the BuyNow component.
* @returns {React.ReactNode} The output of the children render prop.
*
* @example
* ```tsx
* import { BuyNow } from '@wix/headless-stores/react';
*
* const MyProductPage = ({ productId, productVariant }) => {
* return (
* <BuyNow productId={productId} variant={productVariant}>
* {({ isLoading, redirectToCheckout }) => {
* if (isLoading) {
* return <p>Preparing your order...</p>;
* }
* return (
* <button onClick={redirectToCheckout} style={{ padding: '10px', background: 'blue', color: 'white' }}>
* Buy This Product Now!
* </button>
* );
* }}
* </BuyNow>
* );
* };
* ```
*/
export function BuyNow(props: {
productId: string;
variant?: Record<string, string>;
children: (props: {
isLoading: boolean;
redirectToCheckout: () => void; // Note: internally it's async, JSDoc reflects the exposed type
}) => React.ReactNode;
}) {
const [isLoading, setIsLoading] = useState(false);

const redirectToCheckout = async () => {
try {
setIsLoading(true);

const checkoutResult = await checkout.createCheckout({
lineItems: [{
catalogReference: {
catalogItemId: props.productId,
appId: CATLOG_APP_ID_V3,
options: {
options: props.variant,
}
},
quantity: 1
}],
channelType: checkout.ChannelType.WEB,
});

if (!checkoutResult._id) {
throw new Error("Failed to create checkout");
}

const { redirectSession } = await redirects.createRedirectSession({
ecomCheckout: { checkoutId: checkoutResult._id },
callbacks: {
postFlowUrl: window.location.href,
},
});

window.location.href = redirectSession?.fullUrl!;
} finally {
setIsLoading(false);
}
}

return props.children({
isLoading,
redirectToCheckout,
})
}

4 changes: 1 addition & 3 deletions packages/headless-components/stores/src/react/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export function Stores() {
return <div>Stores</div>;
}
export { BuyNow } from "./BuyNow";
1 change: 1 addition & 0 deletions packages/headless-components/stores/src/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
11 changes: 10 additions & 1 deletion packages/headless-components/stores/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,14 @@
"noEmitOnError": false,
"jsx": "react-jsx"
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"src/vitest.setup.ts"
]
}
9 changes: 9 additions & 0 deletions packages/headless-components/stores/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/vitest.setup.ts'], // Adjusted path to be relative to this config file
},
});
Loading