diff --git a/src/sd-specific.ts b/src/sd-specific.ts index e69de29..ad6297b 100755 --- a/src/sd-specific.ts +++ b/src/sd-specific.ts @@ -0,0 +1,504 @@ +import { + QUERYPARAM_MODELSTATEID, + QUERYPARAM_SETTINGSURL, + QUERYPARAM_SLUG, +} from "@AppBuilderShared/types/shapediver/queryparams"; +import {IAppBuilderUrlBuilderData} from "@AppBuilderShared/utils/urlbuilder"; +import { + IAddItemToCartData, + IAddItemToCartReply, + IECommerceApiActions, + IGetParentPageInfoReply, + IGetUserProfileReply, + IMessageToParentData, + IMessageToParentReply, + IScrollingApiLoadMoreData, + IScrollingApiLoadMoreReply, + IScrollingApiSetParametersData, + IScrollingApiSetParametersReply, + IUpdateSharingLinkData, + IUpdateSharingLinkReply, +} from "./shared/modules/ecommerce/types/ecommerceapi"; +import {IScrollingApiItemTypeSelect} from "./shared/modules/ecommerce/types/scrollingapi"; + +/** + * Type definition for product categories. + */ +interface ICategory { + children: ICategory[]; + id: number; + name: string; + path: string; + slug: string; + thumbnail_url: string; +} + +/** + * Type definition for product graphics. + */ +interface IProduct { + category_id: number; + category_name: string; + category_structure: Array; + description: string; + downloadable_files: { + height: number; + image_size: string; + keep_horizontal_orientation: boolean; + keep_vertical_orientation: boolean; + parameterValues: { + file_url: string; + }; + sessionId: Array; + width: number; + }[]; + graphic_id: number; + graphic_name: string; + tags: string[]; +} + +/** + * Product/category API response. + */ +interface IGraphicsApiResponse { + categories?: ICategory[]; + products?: IProduct[]; +} + +/** + * Create query string for fetching from graphics API + * @param categories Optional categories to append as URL parameter "categories" + * @param tag Optional tag to append as URL parameter "tags" + * @param search Optional search term to append as URL parameter "search" + * @returns + */ +function getGraphicsApiQueryString( + categories?: string[] | string, + tags?: string, + search?: string, + categoryIds?: number[], +): string { + const params = new URLSearchParams(); + if (Array.isArray(categories) && categories.length === 0) + categories = undefined; + + if (!categories && !tags && !search) categories = "all"; + + if (categoryIds && categoryIds.length > 0) { + params.set( + "category_id", + categoryIds[categoryIds.length - 1].toString(), + ); + if (!tags && !search) { + // if a product category has been chosen, but no search term + // is present, get all products in that category + params.set("tags", "all"); + } + } else if (categories) { + if (Array.isArray(categories)) + params.set("categories", categories.join(",")); + else params.set("categories", categories); + if (categories !== "all" && !tags && !search) { + // if a product category has been chosen, but no search term + // is present, get all products in that category + params.set("tags", "all"); + } + } + if (tags) params.set("tags", tags); + if (search) params.set("search", search); + + return params.toString(); +} + +/** Query string used for the latest API call. */ +let latestQuery: string = getGraphicsApiQueryString(""); +/** Query string for fetching all categories. */ +const allCategoriesQuery: string = getGraphicsApiQueryString(); +/** Depth of categories for the latest API call. */ +let latestCategorySlugs: Array = []; +/** Page size to be used for data returned to the App Builder iframe. */ +let pageSize: number = 10; +/** Current index. */ +let currentIndex: number = 0; +/** Cached responses of previous API calls. */ +const cachedResults: Record = {}; +/** Identifier for category items. */ +const categoryIdentifier = "\u200B\u2063"; +/** Callback for resetting cache in App Builder app. */ +let resetCacheCallback: (() => void) | null = null; + +/** + * Clear the cache of previous API calls. + */ +function clearCache() { + latestQuery = getGraphicsApiQueryString(); + latestCategorySlugs = []; + pageSize = 10; + currentIndex = 0; + for (const key in cachedResults) delete cachedResults[key]; + if (resetCacheCallback) resetCacheCallback(); +} + +/** + * Fetch from Graphics API. + */ +async function fetchFromGraphicsApi( + queryString: string, + ignoreCache: boolean, +): Promise { + if (!ignoreCache && cachedResults[queryString]) { + return cachedResults[queryString]; + } + + const base = + "https://test1.tarablooms.in/wp-json/shapediver/v1/graphic-components"; + + const url = `${base}?${queryString}`; + + const resp = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error( + `Failed to fetch from graphics api: ${resp.status} ${resp.statusText} ${text}`, + ); + } + + const json = await resp.json().catch((err) => { + throw new Error( + `Failed to parse graphics api response: ${String(err)}`, + ); + }); + + // Basic runtime shape check + if (!json || typeof json !== "object") + throw new Error("Invalid graphics api response: not an object"); + + const result = json as IGraphicsApiResponse; + cachedResults[queryString] = result; + return result; +} + +/** + * Return and map cached results. + * @returns + */ +async function returnAndMapCachedResults(): Promise< + IScrollingApiLoadMoreReply +> { + const result: IScrollingApiLoadMoreReply = { + hasNextPage: false, + items: [], + }; + + // map categories + const categoryData = await fetchFromGraphicsApi(allCategoriesQuery, false); + let categories = categoryData.categories; + if (categories) { + for (let i = 0; i < latestCategorySlugs.length; i++) { + const categorySlug = latestCategorySlugs[i]; + const matchedCategory: ICategory | undefined = categories.find( + (cat) => cat.slug === categorySlug, + ); + if (!matchedCategory) + throw new Error( + `Category with slug "${categorySlug}" not found`, + ); + categories = matchedCategory.children; + } + + // map categories to items + const items: IScrollingApiItemTypeSelect[] = categories.map((p) => ({ + item: `search:${categoryIdentifier}` + p.name, + data: { + displayname: p.name, + imageUrl: p.thumbnail_url, + data: p, + }, + })); + result.items = items; + } + + // map products + const products = cachedResults[latestQuery].products; + if (products) { + // map products to items + const items: IScrollingApiItemTypeSelect[] = products + .slice(currentIndex, currentIndex + pageSize) + .map((p) => ({ + item: p.graphic_id + "", + data: { + displayname: p.graphic_name, + tooltip: p.description, + imageUrl: p.downloadable_files.find((f) => + f.sessionId.includes("thumbnail"), + )?.parameterValues.file_url, + data: p, + }, + })); + currentIndex += pageSize; + + result.hasNextPage = currentIndex < products.length; + result.items = result.items.concat(items); + } + + // console.log(result); + + return result; +} + +/** + * Handler for setting parameters for the scrolling API. + * @param data + * @returns + */ +async function scrollingApiSetParameters( + data: IScrollingApiSetParametersData, +): Promise> { + if (data.source !== "graphics") { + console.debug(`Unsupported data source name: ${data.source}`); + clearCache(); + return {hasNextPage: false, items: []}; + } + + if (data.reset) { + resetCacheCallback = data.reset; + } + + if (data.terms) { + const categories: string[] = []; + const categoryIds: number[] = []; + let tags: string = ""; + let search: string = ""; + const categoryData = await fetchFromGraphicsApi( + allCategoriesQuery, + false, + ); + let cachedCategories = categoryData.categories; + data.terms?.forEach((v) => { + if (v.startsWith(categoryIdentifier)) { + if (cachedCategories) { + const categoryName = v.substring(categoryIdentifier.length); + // Check if the category exists in the fetched categories + const matchedCategory = cachedCategories.find( + (cat) => cat.name === categoryName, + ); + if (matchedCategory) { + categories.push(matchedCategory.slug); + categoryIds.push(matchedCategory.id); + cachedCategories = matchedCategory.children; + } + } + } else if (v.startsWith("tag:")) tags = v.substring("tag:".length); + else search = v; + }); + const queryString = getGraphicsApiQueryString( + categories, + tags, + search, + categoryIds, + ); + if (queryString !== latestQuery) { + latestQuery = queryString; + latestCategorySlugs = categories; + currentIndex = 0; + } + } + + if (data.pageSize !== undefined) { + pageSize = data.pageSize; + currentIndex = 0; + } + + await fetchFromGraphicsApi(latestQuery, false); + return returnAndMapCachedResults(); +} + +/** + * Handler for loading more items for the scrolling API. + * @param data + * @returns + */ +async function scrollingApiLoadMore( + data: IScrollingApiLoadMoreData, +): Promise> { + if (data.source !== "graphics") { + console.debug(`Unsupported data source name: ${data.source}`); + clearCache(); + return {hasNextPage: false, items: []}; + } + + await fetchFromGraphicsApi(latestQuery, false); + return returnAndMapCachedResults(); +} + +/** + * This function is a placeholder for image upload implementation. + * + * What this function is expected to do: + * + * * Present a UI to the user to select one or several images to upload. + * * Upload the images. + * * Return true if the upload was successful and the graphics selection UI should be refreshed. + * * Return false if the upload failed or was cancelled by the user. + * + * @returns + */ +async function imageUpload(): Promise { + // TODO Tara Blooms: Implement image upload to your backend here. + console.log("Uploading image..."); + return Promise.resolve(true); +} + +/** + * Specific implementation of IECommerceApiActions for the App Builder iframe. + * This overrides some of the default actions. + */ +class SpecificECommerceApiActions implements IECommerceApiActions { + constructor(private defaultActions?: IECommerceApiActions) {} + + getParentPageInfo(): Promise { + if (this.defaultActions) return this.defaultActions.getParentPageInfo(); + + return Promise.resolve({href: window.location.href}); + } + + closeConfigurator(): Promise { + if (this.defaultActions) return this.defaultActions.closeConfigurator(); + + return Promise.resolve(false); + } + + addItemToCart(data: IAddItemToCartData): Promise { + // here we skip the default action on purpose + //if (this.defaultActions) return this.defaultActions.addItemToCart(data); + + const {description, modelStateId} = data; + + // try to parse description as JSON + let pricingParameters: Record = {}; + if (description) { + try { + pricingParameters = JSON.parse(description); + } catch (e) { + console.warn( + `Failed to parse pricing parameters from description: ${description}`, + e, + ); + } + } + + // TODO Tara Blooms: + // Use pricing parameters to request price(s) from your backend, + // display user interface for confirming the addition to the cart, + // and finally return the cart item id. + // In case the user denies adding to the cart, return an empty cart item id. + + console.log( + "Adding item to cart with modelStateId:", + modelStateId, + "and pricing parameters:", + pricingParameters, + ); + + const reply: IAddItemToCartReply = { + id: "DUMMY_ID", + }; + + return Promise.resolve(reply); + } + + getUserProfile(): Promise { + // NOTE: This action is not used yet by App Builder, therefore no + // reason to implement it. + if (this.defaultActions) return this.defaultActions.getUserProfile(); + + const reply: IGetUserProfileReply = { + id: "DUMMY_ID", + email: "john@doe.com", + name: "John Doe", + }; + + return Promise.resolve(reply); + } + + updateSharingLink( + data: IUpdateSharingLinkData, + ): Promise { + // here we skip the default action on purpose + //if (this.defaultActions) + // return this.defaultActions.updateSharingLink(data); + + // TODO Tara Blooms: Here you could show a user interface for sharing the link + // via email, social media, etc. + // For now, we just update the URL in the browser. + const {modelStateId, imageUrl} = data; + console.log( + imageUrl + ? `Model state with image data URL: ${imageUrl.substring(0, 10)}` + : "Model state without image", + ); + const url = new URL(window.location.href); + url.searchParams.set(QUERYPARAM_MODELSTATEID, modelStateId); + const href = url.toString(); + history.replaceState(history.state, "", href); + return Promise.resolve({href}); + } + + async scrollingApiSetParameters( + data: IScrollingApiSetParametersData, + ): Promise> { + // connection to the graphics API + return scrollingApiSetParameters(data); + } + + async scrollingApiLoadMore( + data: IScrollingApiLoadMoreData, + ): Promise> { + // connection to the graphics API + return scrollingApiLoadMore(data); + } + + async messageToParent( + data: IMessageToParentData, + ): Promise { + if (data.type === "uploadImages") { + const success = await imageUpload(); + if (success) clearCache(); + return Promise.resolve({ + notification: { + data: { + message: success + ? "Image uploaded successfully." + : "Image upload cancelled or failed.", + }, + }, + }); + } + return Promise.resolve({}); + } +} + +(globalThis as {[key: string]: any}).specificECommerceApiActionsFactory = ( + defaultActions: IECommerceApiActions, +) => new SpecificECommerceApiActions(defaultActions); + +const urlParams = new URLSearchParams(window.location.search); + +/** + * URL builder options for development environment. + */ +export const developmentUrlBuilderOptions: IAppBuilderUrlBuilderData = { + baseUrl: `https://appbuilder.shapediver.com/v1/main/${urlParams.get("appBuilderVersion") ?? "development"}/`, + settingsUrl: urlParams.get(QUERYPARAM_SETTINGSURL) ?? "_stringselect.json", + modelStateId: urlParams.get(QUERYPARAM_MODELSTATEID) ?? undefined, + slug: urlParams.get(QUERYPARAM_SLUG) ?? undefined, +}; + +(globalThis as {[key: string]: any}).developmentUrlBuilderOptions = + developmentUrlBuilderOptions; diff --git a/src/sd-wp.php b/src/sd-wp.php index fead630..aba85f1 100755 --- a/src/sd-wp.php +++ b/src/sd-wp.php @@ -497,13 +497,19 @@ public function add_custom_product_fields() { 'desc_tip' => 'true', 'description' => __('Optional. Enter a URL of the JSON file defining the App Builder theme of the configurator. If left empty, the default theme configured in the ShapeDiver plugin settings will be used. This can be a relative or absolute URL.', 'woocommerce') )); + woocommerce_wp_text_input(array( + 'id' => '_parameters_settings_url', + 'label' => __('Parameters Settings URL', 'woocommerce'), + 'desc_tip' => 'true', + 'description' => __('Optional. Value to use for the "parameters_settings_url" input of the configurator.', 'woocommerce') + )); echo ''; } // Save custom product fields public function save_custom_product_fields($post_id) { - $fields = array('_slug', '_embedding_ticket', '_model_view_url', '_model_state_id', '_configurator_url', '_settings_url'); + $fields = array('_slug', '_embedding_ticket', '_model_view_url', '_model_state_id', '_configurator_url', '_settings_url', '_parameters_settings_url'); foreach ($fields as $field) { if (isset($_POST[$field])) { update_post_meta($post_id, $field, sanitize_text_field(wp_unslash($_POST[$field]))); @@ -518,6 +524,7 @@ public function get_product_data() { if (!$product) { wp_send_json_error('Invalid product'); } + $data = array( 'id' => $product->get_id(), 'name' => esc_html($product->get_name()), @@ -528,8 +535,19 @@ public function get_product_data() { 'model_state_id' => sanitize_text_field(get_post_meta($product_id, '_model_state_id', true)), 'slug' => sanitize_text_field(get_post_meta($product_id, '_slug', true)), 'settings_url' => sanitize_text_field(get_post_meta($product_id, '_settings_url', true)), - //'query_params' => array(), // placeholder for returning custom query parameters ); + + // Prepare further query parameters to be appended to the configurator URL. + // This can be used to pass initial parameter values to the configurator. + // Related documentation: + // https://help.shapediver.com/doc/embed-apps-in-external-websites#EmbedAppsinexternalwebsites-URLparameters + $parameter_settings_url = sanitize_text_field(get_post_meta($product_id, '_parameters_settings_url', true)); + if (!empty($parameter_settings_url)) { + $query_params = array(); + $query_params['_parameters_settings_url'] = $parameter_settings_url; + $data['query_params'] = $query_params; + } + wp_send_json_success($data); } diff --git a/tsconfig.json b/tsconfig.json index 91e541f..0f11da5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ "sourceMap": true }, "include": [ - "src/sd-wp.ts" + "src/sd-wp.ts", + "src/sd-specific.ts", ] } \ No newline at end of file