Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] - feat: WooCommerce Integration #900

Closed
wants to merge 10 commits into from
1 change: 1 addition & 0 deletions deco.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const config = {
app("decohub"),
app("htmx"),
app("sap"),
app("woocommerce"),
...compatibilityApps,
],
};
Expand Down
1 change: 1 addition & 0 deletions decohub/apps/woocommerce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default, preview } from "../../woocommerce/mod.ts";
6 changes: 4 additions & 2 deletions decohub/manifest.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import * as $$$$$$$$$$$25 from "./apps/vtex.ts";
import * as $$$$$$$$$$$26 from "./apps/wake.ts";
import * as $$$$$$$$$$$27 from "./apps/wap.ts";
import * as $$$$$$$$$$$28 from "./apps/weather.ts";
import * as $$$$$$$$$$$29 from "./apps/workflows.ts";
import * as $$$$$$$$$$$29 from "./apps/woocommerce.ts";
import * as $$$$$$$$$$$30 from "./apps/workflows.ts";

const manifest = {
"apps": {
Expand Down Expand Up @@ -64,7 +65,8 @@ const manifest = {
"decohub/apps/wake.ts": $$$$$$$$$$$26,
"decohub/apps/wap.ts": $$$$$$$$$$$27,
"decohub/apps/weather.ts": $$$$$$$$$$$28,
"decohub/apps/workflows.ts": $$$$$$$$$$$29,
"decohub/apps/woocommerce.ts": $$$$$$$$$$$29,
"decohub/apps/workflows.ts": $$$$$$$$$$$30,
},
"name": "decohub",
"baseUrl": import.meta.url,
Expand Down
4 changes: 3 additions & 1 deletion vtex/loaders/intelligentSearch/productListingPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,9 @@ const loader = async (
? filtersFromPathname(pageTypes)
: baseSelectedFacets;
const selected = withDefaultFacets(selectedFacets, ctx);
const fselected = selected.filter((f) => f.key !== "price");
const fselected = props.priceFacets
? selected
: selected.filter((f) => f.key !== "price");
const isInSeachFormat = Boolean(selected.length) || Boolean(args.query);
const pathQuery = queryFromPathname(isInSeachFormat, pageTypes, url.pathname);
const searchArgs = { ...args, query: args.query || pathQuery };
Expand Down
44 changes: 44 additions & 0 deletions woocommerce/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<p align="center">
<strong>
WooCommerce Integration for Your E-Commerce Solution
</strong>
</p>
<p align="center">
Loaders, actions, and workflows for integrating WooCommerce into your deco.cx website.
</p>

<p align="center">
WooCommerce is a powerful, customizable e-commerce platform built on WordPress. It provides a robust set of tools and services for businesses to establish and manage their online stores with ease.

This app wraps the WooCommerce API into a comprehensive set of loaders/actions/workflows,
enabling non-technical users to interact with and manage their headless commerce solution efficiently.
</p>

# Installation

1. Install via DecoHub.
2. Complete the required fields:

1. **Store URL**: Enter the URL of your WooCommerce store. For example, if your store is accessible at www.store.com, make sure to use this URL.
2. **Consumer Key**: Obtain this from the WooCommerce settings. [Follow this guide](https://woocommerce.com/document/woocommerce-rest-api/) for instructions on generating your API keys.
3. **Consumer Secret**: Obtain this alongside the Consumer Key from WooCommerce.

Optional Step: To use a custom search engine (such as Algolia or Typesense), you will need to provide the API Key and API Token. [Refer to this guide](https://woocommerce.com/document/woocommerce-rest-api/#authentication) for generating these credentials.

Configure WooCommerce to send updates to this app by adding Deco as an affiliate. To do this, [follow this guide](https://woocommerce.com/document/woocommerce-rest-api/#authentication) and use the following endpoint as the notification URL:
- `https://{account}.deco.site/live/invoke/woocommerce/actions/trigger.ts`

Configure the event listener in Deco. To do this:
- Open Blocks > Workflows
- Create a new instance of `events.ts` by clicking on `+`
- Name the block `woocommerce-trigger`. Note that this name is crucial and should not be altered.

🎉 Your WooCommerce setup is complete! You should now see WooCommerce loaders/actions/workflows available for your sections.

If you wish to index WooCommerce's product data into Deco, click the button below. Please be aware that this is a resource-intensive operation and may impact your page views quota.
<div style="display: flex; justify-content: center; padding: 8px">
<form target="_blank" action="/live/invoke/workflows/actions/start.ts">
<input style="display: none" name="props" value="eyJrZXkiOiJ3b29jb21tZXIvY29tcG9uZW50L2luZGV4LnRzIn0"/>
<button style="color: white; background-color: #F71963; border-radius: 4px; padding: 4px 8px">Start indexing workflow</button>
</form>
</div>
38 changes: 38 additions & 0 deletions woocommerce/loaders/product/productCategory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { RequestURLParam } from "../../../website/functions/requestToParam.ts";
import { AppContext } from "../../mod.ts";
import { Category } from "../../utils/types.ts";

export interface Props {
slug?: RequestURLParam;
}

/**
* @title WooCommerce Integration
* @description Product Category loader
*/
async function loader(
props: Props,
req: Request,
ctx: AppContext,
): Promise<Category | null> {
const { slug } = props;
const { api } = ctx;

const urlPathname = new URL(req.url).pathname;

const pathname = (slug || urlPathname).split("/").filter(Boolean).pop();

if (!pathname) return null;

const categories = await api["GET /wc/v3/products/categories"]({
slug,
}).then((res) => res.json());

const category = categories.find((item) => item.slug === pathname);

if (!category) return null;

return category;
}

export default loader;
42 changes: 42 additions & 0 deletions woocommerce/loaders/product/productDetailsPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { RequestURLParam } from "../../../website/functions/requestToParam.ts";
import type { ProductDetailsPage } from "../../../commerce/types.ts";
import { AppContext } from "../../mod.ts";
import { toBreadcrumbList, toProduct } from "../../utils/transform.ts";

export interface Props {
slug: RequestURLParam;
}

/**
* @title WooCommerce Integration
* @description Product Details Page loader
*/
async function loader(
props: Props,
_req: Request,
ctx: AppContext,
): Promise<ProductDetailsPage | null> {
const { slug } = props;
const { api } = ctx;

if (!slug) return null;

const [product] = await api["GET /wc/v3/products"]({
slug,
}).then((res) => res.json());

if (!product) return null;

return {
"@type": "ProductDetailsPage",
product: toProduct(product),
breadcrumbList: toBreadcrumbList(product.categories),
seo: {
title: product.name,
description: product.short_description || product.description,
canonical: product.slug,
},
};
}

export default loader;
88 changes: 88 additions & 0 deletions woocommerce/loaders/product/productList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Product } from "../../../commerce/types.ts";
import { AppContext } from "../../mod.ts";
import { toProduct } from "../../utils/transform.ts";
import { Order, OrderBy, Status, StockStatus } from "../../utils/types.ts";

export interface SearchProps {
search: string;
}

export interface ProductIDProps {
ids: string[];
}

export interface Props {
props: SearchProps | ProductIDProps;
/**
* @title Per Page
* @default 10
* @description Maximum number of items to be returned in result set. Default is 10.
*/
per_page?: number;
/**
* @description Order sort attribute ascending or descending. Default is desc.
*/
order?: Order;
/**
* @title Order By
* @description Sort collection by object attribute. Default is date.
*/
orderby?: OrderBy;
/**
* @title Status
* @description Limit result set to products assigned a specific status. Options: any, draft, pending, private and publish. Default is any.
*/
status?: Status;
/**
* @title Stock Status
* @description Limit result set to products with specified stock status. Default: instock.
*/
stock_status?: StockStatus;
/**
* @title Exclude IDs
* @description Ensure result set excludes specific IDs.
*/
exclude?: string[];
}

async function loader(
p: Props,
_req: Request,
ctx: AppContext,
): Promise<Product[] | null> {
const { api } = ctx;

const props = p.props ??
(p as unknown as Props["props"]);

let products;

const queryParams: Omit<Props, "props"> = {
order: p.order ?? "desc",
orderby: p.orderby ?? "date",
status: p.status ?? "any",
stock_status: p.stock_status ?? "instock",
per_page: p.per_page,
exclude: p.exclude,
};

if ("search" in props) {
products = await api["GET /wc/v3/products"]({
search: props.search,
...queryParams,
}).then((res) => res.json());
}

if ("ids" in props) {
products = await api["GET /wc/v3/products"]({
include: props.ids,
...queryParams,
}).then((res) => res.json());
}

if (!products) return null;

return products.map((product) => toProduct(product));
}

export default loader;
94 changes: 94 additions & 0 deletions woocommerce/loaders/product/productListingPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { ProductListingPage } from "../../../commerce/types.ts";
import { AppContext } from "../../mod.ts";
import { toProduct } from "../../utils/transform.ts";
import { Order, OrderBy, Status, StockStatus } from "../../utils/types.ts";
import { WOOCOMMERCE_SORT_OPTIONS } from "../../utils/utils.ts";

export interface Props {
/**
* @description overrides the query term at url
*/
query?: string;
/**
* @default 1
*/
page: number;
/**
* @title Per Page
* @default 12
* @description Maximum number of items to be returned in result set. Default is 12.
*/
per_page: number;
order?: Order;
order_by?: OrderBy;
status?: Status;
stock_status?: StockStatus;
}

/**
* @title WooCommerce Integration
* @description Product Listing Page loader
*/
async function loader(
props: Props,
req: Request,
ctx: AppContext,
): Promise<ProductListingPage | null> {
const url = new URL(req.url);
const pathname = url.pathname.split("/").filter(Boolean).pop();

const { per_page = 12, query } = props;
const { api } = ctx;

const page = Number(url.searchParams.get("page")) || props.page || 1;

const category = await ctx.invoke.woocommerce.loaders.product.productCategory(
{
slug: pathname,
},
);

const products = await api["GET /wc/v3/products"]({
...props,
page,
per_page,
category: !query ? category?.id?.toString() : undefined,
search: query,
}).then((res) => res.json());

if (!products) return null;

const totalPages = Math.ceil((category?.count ?? 0) / props.per_page);
const notHasNextPage = totalPages == page;

return {
"@type": "ProductListingPage",
products: products.map((product) => toProduct(product)),
sortOptions: WOOCOMMERCE_SORT_OPTIONS,
filters: [],
pageInfo: {
previousPage: page == 1 ? undefined : Number(page + 1).toString(),
currentPage: page,
nextPage: notHasNextPage ? undefined : Number(page + 1).toString(),
},
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem" as const,
name: category?.name,
position: 1,
item: new URL(category?.slug ?? "").href,
},
],
numberOfItems: category?.count ?? 0,
},
seo: {
title: query || category?.name || pathname?.replaceAll("-", " ") || "",
description: category?.description ?? "",
canonical: pathname || req.url,
},
};
}

export default loader;
Loading
Loading