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; 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; 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; export type WixApi = ReturnType; export type WixCartApi = ReturnType; export type WixMemberApi = ReturnType; 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 { 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 { 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 { return (await client.products.queryProducts().find()).items; }, async get_product_by_slug(slug: string): Promise { 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 { 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 { 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 { 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; }) { 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 { 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 { 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): 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 { var result: Awaited> | 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 { 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 { 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; } } }; }