Skip to content

Instantly share code, notes, and snippets.

@daguitosama
Created July 27, 2023 18:35
Show Gist options
  • Save daguitosama/5705a44b694f2efd7fdbc1ff7258246f to your computer and use it in GitHub Desktop.
Save daguitosama/5705a44b694f2efd7fdbc1ff7258246f to your computer and use it in GitHub Desktop.
Dago's Wix Headless SDK / API Interface
import {
createClient,
ApiKeyStrategy,
OAuthStrategy,
media,
Tokens,
OauthData,
LoginState
// Tokens // for the future
} from "@wix/api-client";
import { items } from "@wix/data";
import { products, collections } from "@wix/stores";
import { redirects } from "@wix/redirects";
import { cart, checkout } from "@wix/ecom";
import type { Product } from "@wix/stores/build/cjs/src/stores-catalog-v1-product.universal";
import type {
AddToCartOptions,
CreateCartOptions,
LineItemQuantityUpdate
} from "@wix/ecom/build/cjs/src/ecom-v1-cart-cart.universal";
import { Order } from "@wix/stores/build/cjs/src/stores-v2-orders.universal";
import z from "zod";
import { Env, SessionData } from "server";
import { CreateCheckoutOptions } from "@wix/ecom/build/cjs/src/ecom-v1-checkout-checkout.universal";
import { Session } from "@remix-run/server-runtime";
/**
* ZOD SCHEMAS
*/
const LineItemOptionsSchema = z.union([
z.object({
options: z.object({
Size: z.string()
})
}),
z.object({
Size: z.string(),
variantId: z.string()
})
]);
const PriceDataSchema = z.object({
amount: z.string(),
convertedAmount: z.string(),
formattedAmount: z.string(),
formattedConvertedAmount: z.string()
});
const LineItemDescriptionLineSchema = z.object({
name: z.object({ original: z.string(), translated: z.string() }),
plainText: z.object({ original: z.string(), translated: z.string() }),
lineType: z.string()
});
const LineItemSchema = z.object({
_id: z.string(),
quantity: z.number(),
catalogReference: z.object({
catalogItemId: z.string(),
appId: z.string(),
options: LineItemOptionsSchema
}),
productName: z.object({
original: z.string(),
translated: z.string()
}),
url: z.string(),
price: PriceDataSchema,
fullPrice: PriceDataSchema,
priceBeforeDiscounts: PriceDataSchema,
descriptionLines: z.array(LineItemDescriptionLineSchema),
image: z.string(),
availability: z.object({ status: z.string(), quantityAvailable: z.optional(z.number()) }),
physicalProperties: z.object({ weight: z.optional(z.number()), sku: z.optional(z.string()) })
});
export type LineItem = z.infer<typeof LineItemSchema>;
export const CartSchema = z
.object({
_id: z.string(),
lineItems: z.array(LineItemSchema),
buyerInfo: z.object({ visitorId: z.string() }),
currency: z.string(),
conversionCurrency: z.string(),
buyerLanguage: z.string(),
siteLanguage: z.string(),
taxIncludedInPrices: z.boolean(),
weightUnit: z.string(),
subtotal: PriceDataSchema,
appliedDiscounts: z.array(z.any()),
_createdDate: z.string(),
_updatedDate: z.string(),
ecomId: z.string()
})
.transform(data => {
return {
...data,
lineItems: data.lineItems.map(l => {
const line_item_prepared_image = media.getScaledToFillImageUrl(l.image, 226, 256, {
quality: 100
});
return {
...l,
image: line_item_prepared_image
};
})
};
});
export type Cart = z.infer<typeof CartSchema>;
export const ProductPageDataSchema = z
.array(
z.object({
data: z.object({
social_image: z.optional(z.string()), // 'wix:image://v1/da2...'
description: z.optional(z.string()), //
slug: z.optional(z.string()), // 'elixir-argan-oil-liquid-gold',
title: z.optional(z.string()) //'Elixir Argan Oil'
})
})
)
.transform(items => {
if (items.length) {
return {
seo: {
title: items[0].data?.title || "Not Found Title",
description: items[0].data?.description || "Not Found Title",
image: items[0].data.social_image
? media.getImageUrl(items[0].data.social_image).url
: "Not Found Social Image"
}
};
} else {
return {
seo: {
title: "not-found",
description: "not-found",
image: "not-found"
}
};
}
});
export type ProductPageData = z.infer<typeof ProductPageDataSchema>;
export type WixApi = ReturnType<typeof create_wix_api>;
export type WixCartApi = ReturnType<typeof create_wix_cart_api>;
export type WixMemberApi = ReturnType<typeof create_wix_member_api>;
export function create_wix_api({
site_id,
api_key,
account_id
}: {
site_id: string;
api_key: string;
account_id: string;
}) {
var client = createClient({
modules: { items, products, collections, redirects, cart, checkout },
auth: ApiKeyStrategy({ siteId: site_id, apiKey: api_key, accountId: account_id })
});
const HEADLESS_PRODUCTS_COLLECTION_ID = "headless-product-pages";
return Object.freeze({
content: {
//@ts-ignore
async get_test_collection(): Promise<TestCollection> {
var start = Date.now();
var result = (
await client.items
.queryDataItems({
dataCollectionId: "test-collection-for-headless-1"
})
.find()
).items;
console.log(
"[TIMING] wix_api.content.get_test_collection: ",
Date.now() - start,
" ms"
);
//@ts-ignore
return result;
},
async get_product_page_data(slug: string) {
var start = Date.now();
var items = (
await client.items
.queryDataItems({ dataCollectionId: HEADLESS_PRODUCTS_COLLECTION_ID })
.eq("slug", slug)
.find()
).items;
console.log(
"[TIMING] wix_api.content.get_product_page_data: ",
Date.now() - start,
" ms"
);
return ProductPageDataSchema.parse(items);
}
},
products: {
async get_products_by_collection_id(
collection_id: string,
limit: number
): Promise<Product[]> {
var start = Date.now();
var items = (
await client.products
.queryProducts()
.in("collectionIds", collection_id)
.limit(limit)
.find()
).items as Product[];
// optimize images
items = items.map(item => {
return {
...item,
media: {
...item.media,
mainMedia: {
image: {
url: media.getScaledToFillImageUrl(
item.media?.mainMedia?.image?.url as string,
600,
800,
{ quality: 90 }
),
width: 600,
height: 800
}
}
}
};
});
console.log(
`[TIMING] wix_api.products.get_products_by_collection_id(): `,
Date.now() - start,
" ms"
);
return items;
},
async get_products(): Promise<Product[]> {
return (await client.products.queryProducts().find()).items;
},
async get_product_by_slug(slug: string): Promise<Product | null> {
var start = Date.now();
const result =
(await client.products.queryProducts().eq("slug", slug).find()).items[0] ||
null;
console.log(
"[TIMING] wix_api.products.get_product_by_slug: ",
Date.now() - start,
" ms"
);
return result;
}
}
});
}
export function create_wix_cart_api({ client_id, env }: { client_id: string; env: Env }) {
var oauth = OAuthStrategy({
clientId: client_id
});
var client = createClient({
modules: { cart, checkout, redirects },
auth: oauth
});
return Object.freeze({
set_tokens(tokens: Tokens) {
client.auth.setTokens(tokens);
},
/**
* Fetch a Cart by the `cart_id`, if the cart does not exists return `null`.
*
* @param cart_id
* @returns
*/
async get(cart_id: string): Promise<Cart | null> {
var cart: Cart | null = null;
var start = Date.now();
try {
const data = await client.cart.getCart(cart_id);
cart = CartSchema.parse(data);
} catch (error) {
//@ts-ignore
if (error?.details?.applicationError?.code == "CART_NOT_FOUND") {
console.log(
//@ts-ignore
`[ERROR] (wix_cart_api.get) Error code: ${error?.details?.applicationError?.code}`
);
} else {
throw error;
}
}
console.log("[TIMING] wix_cart_api.get: ", Date.now() - start, " ms");
return cart;
},
/**
* TODO:
* - handle case when there is not a stored cart
* - this should return one of this types of results
* - cart: null, tokens: Tokens : when called with out a cart_id
* - cart: cart, tokens:Tokens : when called with a cart_id
* @param cart_id
* @param stored_tokens
* @returns
*/
async get_cart_and_tokens(
cart_id?: string,
stored_tokens?: Tokens
): Promise<{ cart: Cart | null; tokens: Tokens }> {
// use passed tokens or create new ones, and prime the client with it.
if (stored_tokens) {
client.auth.setTokens(stored_tokens);
} else {
const _tokens = await client.auth.generateVisitorTokens();
client.auth.setTokens(_tokens);
}
// cart processing
var cart: Cart | null = null;
if (cart_id) {
var start = Date.now();
try {
const data = await client.cart.getCart(cart_id);
cart = CartSchema.parse(data);
} catch (error) {
//@ts-ignore
if (error?.details?.applicationError?.code == "CART_NOT_FOUND") {
console.log(
//@ts-ignore
`[ERROR] (wix_cart_api.get) Error code: ${error?.details?.applicationError?.code}`
);
} else {
throw error;
}
}
console.log("[TIMING] wix_cart_api.get: ", Date.now() - start, " ms");
}
const tokens = await client.auth.getTokens();
const get_cart_and_tokens_result = { cart, tokens };
// console.log("wix_cart_api.get_cart_and_tokens_result:");
// console.log({ cart_present: !!cart, tokens_present: !!tokens });
return get_cart_and_tokens_result;
},
/**
* Attempts to create a cart with the provided options
* @param options
* @returns
*/
async create(options: CreateCartOptions): Promise<Cart> {
var start = Date.now();
const data = await client.cart.createCart(options);
const cart = CartSchema.parse(data);
console.log("[TIMING] wix_cart_api.create: ", Date.now() - start, " ms");
return cart;
},
/**
* Add items to the referenced cart
* @param param0
* @returns
*/
async add_to({
cart_id,
add_to_options
}: {
cart_id: string;
add_to_options: AddToCartOptions;
}): Promise<Cart | null> {
var cart: Cart | null = null;
var start = Date.now();
try {
var data = (await client.cart.addToCart(cart_id, add_to_options)).cart || null;
if (data) {
cart = CartSchema.parse(data);
}
// TODO: investigate the else path here
} catch (error) {
//@ts-ignore
if (error?.details?.applicationError?.code == "CART_NOT_FOUND") {
console.log(
//@ts-ignore
`[ERROR] (wix_cart_api.add_to) Error code: ${error?.details?.applicationError?.code}`
);
} else {
throw error;
}
}
console.log("[TIMING] wix_cart_api.add_to: ", Date.now() - start, " ms");
return cart;
},
async remove_from({
cart_id,
line_items_ids
}: {
cart_id: string;
line_items_ids: string[];
}) {
var start = Date.now();
var r = await client.cart.removeLineItems(cart_id, line_items_ids);
console.log("[TIMING] wix_cart_api.remove_from: ", Date.now() - start, " ms");
return r;
},
async update({
cart_id,
line_items
}: {
cart_id: string;
line_items: Array<LineItemQuantityUpdate>;
}) {
var cart: Cart | null = null;
var start = Date.now();
try {
var data = (await client.cart.updateLineItemsQuantity(cart_id, line_items)).cart;
if (data) {
cart = CartSchema.parse(data);
}
} catch (error) {
//@ts-ignore
if (error?.details?.applicationError?.code == "CART_NOT_FOUND") {
console.log(
//@ts-ignore
`[ERROR] (wix_cart_api.add_to) Error code: ${error?.details?.applicationError?.code}`
);
} else {
throw error;
}
}
console.log("[TIMING] wix_cart_api.update: ", Date.now() - start, " ms");
return cart;
},
/**
* Creates a `checkout` based on the provided `options` and:
* - creates a redirect session
* - returns the `checkout_url`
* @param options
* @returns
*/
async get_checkout_url_and_id(
options: CreateCheckoutOptions
): Promise<{ checkout_url: string; checkout_id: string }> {
var start = Date.now();
const checkout_id = (await client.checkout.createCheckout(options))._id;
if (typeof checkout_id != "string") {
throw new Error("Non valid checkout id");
}
const { WIX_CART_PAGE_URL, WIX_POST_FLOW_URL } = env;
if (!WIX_CART_PAGE_URL || !WIX_POST_FLOW_URL) {
throw new Error(
"(wix_cart_api.get_checkout_url) Wix PostFlow links are mandatory for redirection setup"
);
}
const { redirectSession } = await client.redirects.createRedirectSession({
ecomCheckout: { checkoutId: checkout_id },
callbacks: {
cartPageUrl: env.WIX_CART_PAGE_URL,
postFlowUrl: env.WIX_POST_FLOW_URL
}
});
if (typeof redirectSession?.fullUrl != "string") {
throw new Error("Non valid redirectSession.fullUrl");
}
console.log(
"[TIMING] wix_cart_api.get_checkout_url_and_id: ",
Date.now() - start,
" ms"
);
return { checkout_url: redirectSession?.fullUrl, checkout_id };
},
async get_checkout(checkout_id: string): Promise<checkout.Checkout> {
var start = Date.now();
var checkout = await client.checkout.getCheckout(checkout_id);
console.log("[TIMING] wix_cart_api.get_checkout: ", Date.now() - start, " ms");
return checkout;
},
/**
* 🚨 **Caution!** Only for testing.
* This method deletes a cart.
* @param cart_id
* @returns
*/
async _for_test_only_delete(cart_id: string): Promise<void> {
return client.cart.deleteCart(cart_id);
}
});
}
export interface RestOrder extends Order {
id: string;
}
export function create_wix_member_api({ client_id, env }: { client_id: string; env: Env }) {
var oauth = OAuthStrategy({
clientId: client_id
});
var client = createClient({
modules: {},
auth: oauth
});
return {
auth: {
is_authenticated(session: Session<SessionData>): boolean {
const stored_tokens = session.get("member_tokens");
if (!stored_tokens) {
return false;
}
const member_tokens = JSON.parse(stored_tokens) as Tokens;
// todo make sure the tokens are valid a this point
client.auth.setTokens(member_tokens);
return client.auth.loggedIn();
},
async login({ email, password }: { email: string; password: string }) {
const res = await client.auth.login({
email,
password
});
// todo: learn how to signal that
// a errors imply a null result
// and viceversa
const result: { success: { tokens: Tokens } | null; error: null | string } = {
success: null,
error: null
};
switch (res.loginState) {
case LoginState.SUCCESS: {
break;
}
case LoginState.OWNER_APPROVAL_REQUIRED: {
result.error = "Your account is pending approval";
}
case LoginState.EMAIL_VERIFICATION_REQUIRED: {
result.error = "Your account needs email verification";
}
case LoginState.FAILURE: {
// @ts-ignore
switch (res.errorCode) {
case "invalidPassword": {
result.error = "The email or the password, or both don't match.";
}
case "invalidEmail": {
result.error = "The email or the password, or both don't match.";
}
case "resetPassword": {
result.error = "Your password requires reset.";
}
}
}
default: {
result.error = "The login API responded with an unknown response";
}
}
// success path
if (res.loginState == LoginState.SUCCESS) {
if (!(res.data && res.data.sessionToken)) {
result.error = "Non valid session Tokens came back from the api";
}
const tokens = await client.auth.getMemberTokensForDirectLogin(
res.data.sessionToken
);
result.success = { tokens };
}
return result;
},
set_tokens(tokens: Tokens) {
client.auth.setTokens(tokens);
},
async get_login_url_oauth_data(): Promise<{
login_url: string;
oauth_data: OauthData;
}> {
var oauth_data = client.auth.generateOAuthData(env.AUTH_REDIRECT_URI);
var { authUrl } = await client.auth.getAuthUrl(oauth_data);
return {
login_url: authUrl,
oauth_data
};
},
async get_member_tokens({
code,
state,
oauthState
}: {
code: string;
state: string;
oauthState: OauthData;
}): Promise<Tokens | null> {
var result: Awaited<ReturnType<typeof client.auth.getMemberTokens>> | null = null;
try {
result = await client.auth.getMemberTokens(code, state, oauthState);
} catch (error) {
console.log("wix_member_api.get_member_tokens Error: ");
console.error(error);
}
return result;
}
},
orders: {
/**
* Make sure to only call this method with a
* properly authenticated client
* @returns
*/
async get_orders(): Promise<RestOrder[]> {
const res = await client.fetch(`/stores/v2/orders/query`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
query: {
paging: {
limit: 20
}
}
})
});
const data: { orders: RestOrder[] } = await res.json();
return data?.orders as RestOrder[];
},
/**
* Make sure to only call this method with a
* properly authenticated client
* @returns
*/
async get_order_by_id(order_id: string): Promise<RestOrder> {
const query = JSON.stringify({
query: {
filter: JSON.stringify({
id: order_id
})
}
});
console.log("(get_order_by_id) with id as: ", order_id);
console.log("(get_order_by_id) query as: ");
console.log(query);
const res = await client.fetch(`/stores/v2/orders/query`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: query
});
const data: { order: RestOrder } = await res.json();
console.log("get_order_by_id data: ");
console.log(JSON.stringify({ data }, null, 2));
return data?.order as RestOrder;
}
}
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment