diff --git a/docs/configure-cms.md b/docs/configure-cms.md
index e524d643..e2afe6b1 100644
--- a/docs/configure-cms.md
+++ b/docs/configure-cms.md
@@ -54,7 +54,7 @@ To add the shop in your website selling items and services you have to:
4. **(optional)** install the plugin `myCred` to add **"virtual points"** in your website, this will allow you to define a relation between money and your virtual point, so any user can buy items in the shopt through virtual points and buy virtual points with €/$
-
+
Besides items you can also sell:
@@ -72,6 +72,24 @@ Besides items you can also sell:
**Note:** if you want to sell a cumulative item you can use the `SKU itemsend_ITEM-ID_stack`.
+### 3D Model Viewer
+
+In any product it is possible to enable/disable the 3D viewer via settings on Inventory -> 3D Viewer.
+
+
+
+It currently suports:
+- mounts/pets/companion under itemsend SKU
+- transmog items under transmog-item SKU
+
+Examples:
+
+
+
+
+
+
+
## Troubleshooting
For everything ask help on [Discord](https://discord.gg/gkt4y2x) in the channel `#acore-cms` (section `TOOLS`), you can also tag @Helias for any issue about this CMS.
diff --git a/docs/images/3d-viewer-example-mount.png b/docs/images/3d-viewer-example-mount.png
new file mode 100644
index 00000000..d0c02373
Binary files /dev/null and b/docs/images/3d-viewer-example-mount.png differ
diff --git a/docs/images/3d-viewer-example-pet.png b/docs/images/3d-viewer-example-pet.png
new file mode 100644
index 00000000..a490c6da
Binary files /dev/null and b/docs/images/3d-viewer-example-pet.png differ
diff --git a/docs/images/3d-viewer-example-transmog.png b/docs/images/3d-viewer-example-transmog.png
new file mode 100644
index 00000000..69d292d9
Binary files /dev/null and b/docs/images/3d-viewer-example-transmog.png differ
diff --git a/docs/images/3d-viewer-option.png b/docs/images/3d-viewer-option.png
new file mode 100644
index 00000000..223576d1
Binary files /dev/null and b/docs/images/3d-viewer-option.png differ
diff --git a/docs/shop.png b/docs/images/shop.png
similarity index 100%
rename from docs/shop.png
rename to docs/images/shop.png
diff --git a/src/acore-wp-plugin/src/Hooks/WooCommerce/FieldElements.php b/src/acore-wp-plugin/src/Hooks/WooCommerce/FieldElements.php
index c72d57d1..81ffb692 100644
--- a/src/acore-wp-plugin/src/Hooks/WooCommerce/FieldElements.php
+++ b/src/acore-wp-plugin/src/Hooks/WooCommerce/FieldElements.php
@@ -114,4 +114,62 @@ public static function destAccount(): void {
ID, '_custom_3d_checkbox', true);
+
+ if ($custom_3d_checkbox !== 'yes') {
+ return;
+ }
+
+ ?>
+
+
+
+
+ itemId);
+
$current_user = wp_get_current_user();
if ($current_user) {
diff --git a/src/acore-wp-plugin/src/Hooks/WooCommerce/Products.php b/src/acore-wp-plugin/src/Hooks/WooCommerce/Products.php
new file mode 100644
index 00000000..e0d8b7d3
--- /dev/null
+++ b/src/acore-wp-plugin/src/Hooks/WooCommerce/Products.php
@@ -0,0 +1,19 @@
+ '_custom_3d_checkbox',
+ 'label' => __('3D Viewer', 'woocommerce'),
+ 'description' => __('Check this box to enable the 3D viewer for this product.', 'woocommerce'),
+ ));
+}
+add_action('woocommerce_product_options_inventory_product_data', 'add_custom_3d_checkbox_field');
+
+
+function save_3d_checkbox_field($post_id) {
+ $custom_checkbox = isset($_POST['_custom_3d_checkbox']) ? 'yes' : 'no';
+ update_post_meta($post_id, '_custom_3d_checkbox', $custom_checkbox);
+}
+add_action('woocommerce_process_product_meta', 'save_3d_checkbox_field');
+?>
diff --git a/src/acore-wp-plugin/src/Hooks/WooCommerce/TransmogItemSend.php b/src/acore-wp-plugin/src/Hooks/WooCommerce/TransmogItemSend.php
index 9206bfde..61966bfb 100755
--- a/src/acore-wp-plugin/src/Hooks/WooCommerce/TransmogItemSend.php
+++ b/src/acore-wp-plugin/src/Hooks/WooCommerce/TransmogItemSend.php
@@ -34,6 +34,8 @@ public static function before_add_to_cart_button() {
return;
}
+ FieldElements::get3dViewer($itemId);
+
$current_user = wp_get_current_user();
if ($current_user) {
diff --git a/src/acore-wp-plugin/src/boot.php b/src/acore-wp-plugin/src/boot.php
index 2e401b4d..ea262eec 100644
--- a/src/acore-wp-plugin/src/boot.php
+++ b/src/acore-wp-plugin/src/boot.php
@@ -20,3 +20,4 @@
require_once ACORE_PATH_PLG . 'src/Hooks/User/Include.php';
require_once ACORE_PATH_PLG . 'src/Hooks/WooCommerce/WooCommerce.php';
+require_once ACORE_PATH_PLG . 'src/Hooks/WooCommerce/Products.php';
diff --git a/src/acore-wp-plugin/web/libraries/wow-model-viewer/character_modeling.js b/src/acore-wp-plugin/web/libraries/wow-model-viewer/character_modeling.js
new file mode 100644
index 00000000..8573574a
--- /dev/null
+++ b/src/acore-wp-plugin/web/libraries/wow-model-viewer/character_modeling.js
@@ -0,0 +1,286 @@
+/* Author: https://github.com/Miorey/wow-model-viewer */
+
+import "./setup.js";
+
+const NOT_DISPLAYED_SLOTS = [
+ 2, // neck
+ 11, // finger1
+ 12, // finger1
+ 13, // trinket1
+ 14, // trinket2
+];
+
+const modelingType = {
+ ARMOR: 128,
+ CHARACTER: 16,
+ COLLECTION: 1024,
+ HELM: 2,
+ HUMANOIDNPC: 32,
+ ITEM: 1,
+ ITEMVISUAL: 512,
+ NPC: 8,
+ OBJECT: 64,
+ PATH: 256,
+ SHOULDER: 4,
+};
+
+const characterPart = () => {
+ const ret = {
+ Face: `face`,
+ "Skin Color": `skin`,
+ "Hair Style": `hairStyle`,
+ "Hair Color": `hairColor`,
+ "Facial Hair": `facialStyle`,
+ Mustache: `facialStyle`,
+ Beard: `facialStyle`,
+ Sideburns: `facialStyle`,
+ "Face Shape": `facialStyle`,
+ Eyebrow: `facialStyle`,
+ "Jaw Features": undefined,
+ "Face Features": undefined,
+ "Skin Type": undefined,
+ Ears: window.WOTLK_TO_RETAIL_DISPLAY_ID_API ? undefined : `ears`,
+ "Fur Color": window.WOTLK_TO_RETAIL_DISPLAY_ID_API ? undefined : `furColor`,
+ Snout: `snout`,
+ Blindfold: undefined,
+ Tattoo: undefined,
+ "Eye Color": undefined,
+ "Tattoo Color": undefined,
+ Armbands: undefined,
+ "Jewelry Color": undefined,
+ Bracelets: undefined,
+ Necklace: undefined,
+ Earring: undefined,
+ "Primary Color": window.WOTLK_TO_RETAIL_DISPLAY_ID_API
+ ? undefined
+ : `primaryColor`,
+ "Secondary Color Strength": window.WOTLK_TO_RETAIL_DISPLAY_ID_API
+ ? undefined
+ : `secondaryColorStrength`,
+ "Secondary Color": window.WOTLK_TO_RETAIL_DISPLAY_ID_API
+ ? undefined
+ : `secondaryColor`,
+ "Horn Color": window.WOTLK_TO_RETAIL_DISPLAY_ID_API
+ ? undefined
+ : `hornColor`,
+ Horns: window.WOTLK_TO_RETAIL_DISPLAY_ID_API ? undefined : `horns`,
+ "Body Size": window.WOTLK_TO_RETAIL_DISPLAY_ID_API ? undefined : `bodySize`,
+ };
+ // console.log(ret);
+ return ret;
+};
+
+function optionalChaining(choice) {
+ //todo replace by `part.Choices[character[CHARACTER_PART[prop]]]?.Id` when it works on almost all frameworks
+ return choice ? choice.Id : undefined;
+}
+
+/**
+ *
+ * @param {Object} character - The character object.
+ * @param {number} character.face - Description for face.
+ * @param {number} character.facialStyle - Description for facialStyle.
+ * @param {number} character.gender - Description for gender.
+ * @param {number} character.hairColor - Description for hairColor.
+ * @param {number} character.hairStyle - Description for hairStyle.
+ * @param {Array>} character.items - Description for items. (Optional)
+ * @param {number} character.race - Description for race.
+ * @param {number} character.skin - Description for skin.
+ * @param {Object} fullOptions - Zaming API character options payload.
+ * @return {[]}
+ */
+function getCharacterOptions(character, fullOptions) {
+ const options = fullOptions.Options;
+ const missingChoice = [];
+ const ret = [];
+ for (const prop in characterPart()) {
+ const part = options.find((e) => e.Name === prop);
+
+ if (!part) {
+ continue;
+ }
+
+ const newOption = {
+ optionId: part.Id,
+ choiceId: characterPart()[prop]
+ ? optionalChaining(part.Choices[character[characterPart()[prop]]])
+ : part.Choices[0].Id,
+ };
+ if (newOption.choiceId === undefined) {
+ missingChoice.push(characterPart()[prop]);
+ }
+ ret.push(newOption);
+ }
+ console.warn(
+ `In character: `,
+ character,
+ `the following options are missing`,
+ missingChoice
+ );
+
+ return ret;
+}
+
+/**
+ * This function return the design choices for a character this does not work for NPC / Creature / Items
+ * @param {Object} model - The model object to generate options from.
+ * @param {{}} fullOptions - The type of the model.
+ * @returns {{models: {id: string, type: number}, charCustomization: {options: []}, items: (*|*[])}|{models: {id, type}}
+ */
+function optionsFromModel(model, fullOptions) {
+ const { race, gender } = model;
+
+ // slot ids on model viewer
+ const characterItems = model.items
+ ? model.items.filter((e) => !NOT_DISPLAYED_SLOTS.includes(e[0]))
+ : [];
+ const options = getCharacterOptions(model, fullOptions);
+ let charCustomization = {
+ options: options,
+ };
+ const ret = {
+ items: characterItems,
+ models: {
+ id: race * 2 - 1 + gender,
+ type: modelingType.CHARACTER,
+ },
+ };
+ if (!model.noCharCustomization) {
+ ret.charCustomization = charCustomization;
+ }
+ return ret;
+}
+
+/**
+ *
+ * @param item{number}: Item id
+ * @param slot{number}: Item slot number
+ * @param displayId{number}: DisplayId of the item
+ * @param env {('classic'|'live')}: select game env
+ * @return {Promise}
+ */
+async function getDisplaySlot(item, slot, displayId, env = `live`) {
+ if (typeof item !== `number`) {
+ throw new Error(`item must be a number`);
+ }
+
+ if (typeof slot !== `number`) {
+ throw new Error(`slot must be a number`);
+ }
+
+ if (typeof displayId !== `number`) {
+ throw new Error(`displayId must be a number`);
+ }
+
+ try {
+ const jsonPath =
+ env === `classic` && [21, 22].includes(slot)
+ ? `${window.CONTENT_PATH}meta/item/${displayId}.json`
+ : `${window.CONTENT_PATH}meta/armor/${slot}/${displayId}.json`;
+ await fetch(jsonPath).then((response) => response.json());
+
+ return {
+ displaySlot: slot,
+ displayId: displayId,
+ };
+ } catch (e) {
+ if (!window.WOTLK_TO_RETAIL_DISPLAY_ID_API) {
+ throw Error(
+ `Item not found and window.WOTLK_TO_RETAIL_DISPLAY_ID_API not set`
+ );
+ }
+ const resp = await fetch(
+ `${window.WOTLK_TO_RETAIL_DISPLAY_ID_API}/${item}/${displayId}`
+ ).then((response) => response.json());
+ const res = resp.data || resp;
+ if (res.newDisplayId !== displayId) {
+ return {
+ displaySlot: slot,
+ displayId: res.newDisplayId,
+ };
+ }
+ }
+
+ // old slots to new slots
+ const retSlot = {
+ 5: 20, // chest
+ 16: 21, // main hand
+ 18: 22, // off hand
+ }[slot];
+
+ if (!retSlot) {
+ console.warn(
+ `Item: ${item} display: ${displayId} or slot: ${slot} not found for `
+ );
+
+ return {
+ displaySlot: slot,
+ displayId: displayId,
+ };
+ }
+
+ return {
+ displaySlot: retSlot,
+ displayId: displayId,
+ };
+}
+
+/**
+ * Returns a 2-dimensional list the inner list contains on first position the item slot, the second the item
+ * display-id ex: [[1,1170],[3,4925]]
+ * @param {*[{item: {entry: number, displayid: number}, transmog: {entry: number, displayid: number}, slot: number}]} equipments
+ * @param env {('classic'|'live')}: select game enve
+ * @returns {Promise}
+ */
+async function findItemsInEquipments(equipments, env = `live`) {
+ for (const equipment of equipments) {
+ if (NOT_DISPLAYED_SLOTS.includes(equipment.slot)) {
+ continue;
+ }
+
+ const displayedItem =
+ Object.keys(equipment.transmog).length !== 0
+ ? equipment.transmog
+ : equipment.item;
+ const displaySlot = await getDisplaySlot(
+ displayedItem.entry,
+ equipment.slot,
+ displayedItem.displayid,
+ env
+ );
+ equipment.displaySlot = displaySlot.displaySlot;
+ equipment.displayId = displaySlot.displayId;
+ Object.assign(displaySlot, equipment);
+ }
+ return equipments
+ .filter((e) => e.displaySlot)
+ .map((e) => [e.displaySlot, e.displayId]);
+}
+
+/**
+ *
+ * @param {number} race
+ * @param {number} gender
+ * @returns {Promise