Skip to content

Commit

Permalink
feat: SAP Commerce App (#830)
Browse files Browse the repository at this point in the history
* Initial SAP Commerce App

* Manifest

* Fix typings

* Fix loaders

* Fix typings and descriptions

* Remove unused actions and hooks

* Remove default values | Add Preview

---------

Co-authored-by: decobot <capy@deco.cx>
  • Loading branch information
Gmantiqueira and decobot authored Sep 13, 2024
1 parent 771d22a commit 2f418b1
Show file tree
Hide file tree
Showing 14 changed files with 1,359 additions and 1 deletion.
1 change: 1 addition & 0 deletions deco.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const config = {
app("crux"),
app("decohub"),
app("htmx"),
app("sap"),
...compatibilityApps,
],
};
Expand Down
5 changes: 5 additions & 0 deletions sap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<p align="center">
<strong>
SAP Commerce - Under construction
</strong>
</p>
48 changes: 48 additions & 0 deletions sap/loaders/categories/tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { SiteNavigationElement } from "../../../commerce/types.ts";
import { AppContext } from "../../mod.ts";
import { CatalogsResponse } from "../../utils/types.ts";

export interface Props {
/**
* @description Response configuration. This is the list of fields that should be returned in the response body. Examples: BASIC, DEFAULT, FULL
* @default FULL
*/
fields?: string;
/**
* @description Filter when it's needed to retrieve only brands, collections or categories. Examples: categories, brands, collections
*/
categoryType?: "default" | "categories" | "brands" | "collections";
}

/**
* @title SAP Integration
* @description WORK IN PROGRESS - Category tree loader
*/
const loader = async (
props: Props,
_req: Request,
ctx: AppContext,
): Promise<SiteNavigationElement[] | null> => {
const { api } = ctx;
const { fields } = props;

const data: CatalogsResponse = await api["GET /catalogs?:fields"](
{ fields },
).then(
(res: Response) => res.json(),
);
const _tree = data.catalogs[0].catalogVersions
.find((version) => {
version.id == "Online";
});

return [{
"@type": "SiteNavigationElement",
additionalType: "",
identifier: "",
name: "",
url: "",
}];
};

export default loader;
45 changes: 45 additions & 0 deletions sap/loaders/product/ProductList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Product } from "../../../commerce/types.ts";
import type { AppContext } from "../../mod.ts";
import { ProductDetailsResponse } from "../../utils/types.ts";
import { convertProductData } from "../../utils/transform.ts";

export interface Props {
/**
* @title Fields
* @description Response configuration. This is the list of fields that should be returned in the response body. Examples: BASIC, DEFAULT, FULL
* @default "FULL,averageRating,stock(DEFAULT),description,availableForPickup,code,url,price(DEFAULT),manufacturer,categories(FULL),priceRange,multidimensional,configuratorType,configurable,tags,images(FULL),name,purchasable,baseOptions(DEFAULT),baseProduct,variantOptions(DEFAULT),variantType,numberOfReviews,productReferences,likeProductCopy,likeProductGroup,likeProducts(code,likeProductCopy,likeProductGroup,price(DEFAULT),url,primaryFlag,msrpUSD,msrpCAD,msrpCADFormattedValue),classifications"
*/
fields: string;
/**
* @title Product codes
* @description List of product codes for shelf products.
*/
productCodes: string[];
}

/**
* @title SAP Integration
* @description Product List loader
*/
const productListLoader = (
props: Props,
_req: Request,
ctx: AppContext,
): Promise<Product[] | null> => {
const { api } = ctx;
const { productCodes, fields } = props;

return Promise.all(
productCodes.map(async (productCode) => {
const data: ProductDetailsResponse = await api[
"GET /products/:productCode"
]({ productCode, fields }).then((res: Response) => res.json());

const product = convertProductData(data);

return product;
}),
);
};

export default productListLoader;
53 changes: 53 additions & 0 deletions sap/loaders/product/productDetailsPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ProductDetailsPage } from "../../../commerce/types.ts";
import type { AppContext } from "../../mod.ts";
import { ProductDetailsResponse } from "../../utils/types.ts";
import {
convertCategoriesToBreadcrumb,
convertProductData,
} from "../../utils/transform.ts";
import { RequestURLParam } from "../../../website/functions/requestToParam.ts";

export interface Props {
/**
* @title Fields
* @description Response configuration. This is the list of fields that should be returned in the response body. Examples: BASIC, DEFAULT, FULL
* @default "FULL,averageRating,stock(DEFAULT),description,availableForPickup,code,url,price(DEFAULT),manufacturer,categories(FULL),priceRange,multidimensional,configuratorType,configurable,tags,images(FULL),name,purchasable,baseOptions(DEFAULT),baseProduct,variantOptions(DEFAULT),variantType,numberOfReviews,productReferences,likeProductCopy,likeProductGroup,likeProducts(code,likeProductCopy,likeProductGroup,price(DEFAULT),url,primaryFlag,msrpUSD,msrpCAD,msrpCADFormattedValue),classifications"
*/
fields: string;
/**
* @title Product code
* @description Product identifier.
*/
productCode: RequestURLParam;
}

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

const data: ProductDetailsResponse = await api["GET /products/:productCode"]({
productCode,
fields,
}).then((res: Response) => {
return res.json();
});

const breadcrumbList = convertCategoriesToBreadcrumb(data.categories);
const product = convertProductData(data);

return {
"@type": "ProductDetailsPage",
breadcrumbList,
product,
};
};

export default productDetailsLoader;
107 changes: 107 additions & 0 deletions sap/loaders/product/productListingPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { BreadcrumbList, ProductListingPage } from "../../../commerce/types.ts";
import type { AppContext } from "../../mod.ts";
import { ProductListResponse } from "../../utils/types.ts";
import {
convertBreadcrumb,
convertFacetsToFilters,
convertProductData,
getPreviousNextPagination,
} from "../../utils/transform.ts";

export interface Props {
/**
* @description Category code of the products of this page.
*/
categoryCode?: string;
/**
* @description The current result page requested.
* @hide true
* @default 0
*/
currentPage?: number;
/**
* @title Fields
* @description Response configuration. This is the list of fields that should be returned in the response body. Examples: BASIC, DEFAULT, FULL
* @default FULL
*/
fields?: string;
/**
* @title Items per page
* @description The number of results returned per page.
* @default 12
*/
pageSize?: number;
}

/**
* @title SAP Integration
* @description Product List loader
*/
const PLPLoader = async (
props: Props,
req: Request,
ctx: AppContext,
): Promise<ProductListingPage | null> => {
const { api } = ctx;
const { url: baseUrl } = req;
const { categoryCode, currentPage, fields, pageSize } = props;

const url = new URL(baseUrl);
let query = url.searchParams.get("q");
const sort = query?.split(":")[1] || "";

if (!query) {
query = `:relevance:allCategories:${categoryCode}`;
}

query = decodeURIComponent(query.replace(/\+/g, " "));

const data: ProductListResponse = await api[
"GET /users/anonymous/eluxproducts/search"
]({
currentPage,
fields: `${fields},facets,products(FULL)`,
pageSize,
query,
sort: "approvalStatusSort",
searchType: "FINISHED_GOODS",
}).then(
(
res: Response,
) => res.json(),
);

const products = data.products.map(convertProductData);

let breadcrumb: BreadcrumbList = {
"@type": "BreadcrumbList",
itemListElement: [],
numberOfItems: 0,
};
if (data.breadcrumbs) {
breadcrumb = convertBreadcrumb(data.breadcrumbs);
}

const filters = convertFacetsToFilters(data.facets);
const [previousPage, nextPage] = getPreviousNextPagination(data.pagination);

return {
"@type": "ProductListingPage",
breadcrumb,
filters,
products,
pageInfo: {
currentPage: data.pagination.currentPage,
nextPage,
previousPage,
pageTypes: ["Category", "SubCategory", "Collection"], // TODO: Filter these types.
recordPerPage: data.pagination.totalResults < data.pagination.pageSize
? data.pagination.totalResults
: data.pagination.pageSize,
records: data.pagination.totalResults,
},
sortOptions: [{ value: sort, label: sort }],
};
};

export default PLPLoader;
23 changes: 23 additions & 0 deletions sap/manifest.gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// DO NOT EDIT. This file is generated by deco.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.

import * as $$$0 from "./loaders/categories/tree.ts";
import * as $$$2 from "./loaders/product/productDetailsPage.ts";
import * as $$$1 from "./loaders/product/ProductList.ts";
import * as $$$3 from "./loaders/product/productListingPage.ts";

const manifest = {
"loaders": {
"sap/loaders/categories/tree.ts": $$$0,
"sap/loaders/product/productDetailsPage.ts": $$$2,
"sap/loaders/product/ProductList.ts": $$$1,
"sap/loaders/product/productListingPage.ts": $$$3,
},
"name": "sap",
"baseUrl": import.meta.url,
};

export type Manifest = typeof manifest;

export default manifest;
71 changes: 71 additions & 0 deletions sap/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { App, FnContext } from "deco/mod.ts";
import { createHttpClient } from "../utils/http.ts";
import manifest, { Manifest } from "./manifest.gen.ts";
import { PreviewContainer } from "../utils/preview.tsx";
import { Markdown } from "../decohub/components/Markdown.tsx";
import { API } from "./utils/client/client.ts";

export type AppContext = FnContext<State, Manifest>;

/** @title */
export interface Props {
/**
* @title Api url
*/
apiUrl: string;

/**
* @title Base site ID
*/
baseSiteId: string;
}

export interface State extends Props {
api: ReturnType<typeof createHttpClient<API>>;
}

/**
* @title SAP
* @description Loaders, actions and workflows for adding SAP Commerce to your website.
* @category Ecommmerce
* @logo https://fakestoreapi.com/icons/logo.png
*/
export default function SAP(props: Props): App<Manifest, State> {
const { apiUrl, baseSiteId } = props;

const api = createHttpClient<API>({
base: `${apiUrl}/${baseSiteId}/`,
headers: new Headers({
"Content-Type": "application/json",
Accept: "application/json",
}),
});

return {
state: { ...props, api },
manifest,
};
}
export const preview = async () => {
const markdownContent = await Markdown(
new URL("./README.md", import.meta.url).href,
);
return {
Component: PreviewContainer,
props: {
name: "SAP Commerce",
owner: "deco.cx",
description:
"Loaders, actions and workflows for adding SAP Commerce Platform to your website.",
logo:
"https://www.sap.com/dam/application/shared/logos/sap-logo-svg.svg/sap-logo-svg.svg",
images: [],
tabs: [
{
title: "About",
content: markdownContent(),
},
],
},
};
};
4 changes: 4 additions & 0 deletions sap/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { proxy } from "deco/clients/withManifest.ts";
import { Manifest } from "./manifest.gen.ts";

export const invoke = proxy<Manifest>();
21 changes: 21 additions & 0 deletions sap/utils/client/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {
CatalogsResponse,
ProductDetailsResponse,
ProductListResponse,
} from "../types.ts";

export interface API {
"GET /users/anonymous/eluxproducts/search": {
response: ProductListResponse;
};
"GET /catalogs?:fields": {
response: CatalogsResponse;
};
"GET /products/:productCode": {
response: ProductDetailsResponse;
searchParams: {
productCode: string;
fields: string;
};
};
}
Empty file added sap/utils/constants.ts
Empty file.
Loading

0 comments on commit 2f418b1

Please sign in to comment.