diff --git a/assets/css/faq.css b/assets/css/faq.css new file mode 100644 index 00000000..0602890e --- /dev/null +++ b/assets/css/faq.css @@ -0,0 +1,90 @@ +/* DESCRIPTION: Frontend styles for the FAQ shortcode section + * and the [wedocs_faq] shortcode output. */ + +.wedocs-faq-section { + margin-top: 40px; + padding-top: 30px; + border-top: 1px solid #e5e7eb; +} + +.wedocs-faq-section__title { + font-size: 24px; + font-weight: 600; + margin-bottom: 24px; +} + +.wedocs-faq-group { + margin-bottom: 24px; +} + +.wedocs-faq-group__title { + font-size: 18px; + font-weight: 600; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.wedocs-faq-group__icon { + width: 24px; + height: 24px; + object-fit: contain; + flex-shrink: 0; +} + +.wedocs-faq-item { + border: 1px solid #e5e7eb; + border-radius: 6px; + margin-bottom: 8px; + overflow: hidden; +} + +.wedocs-faq-item__question { + padding: 14px 18px; + cursor: pointer; + font-weight: 500; + font-size: 15px; + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; +} + +.wedocs-faq-item__question::-webkit-details-marker { + display: none; +} + +.wedocs-faq-item__question::after { + content: '+'; + font-size: 20px; + font-weight: 300; + color: #6b7280; + transition: transform 0.3s ease; +} + +.wedocs-faq-item[open] .wedocs-faq-item__question::after { + content: '\2212'; +} + +.wedocs-faq-item__answer { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease; +} + +.wedocs-faq-item[open] .wedocs-faq-item__answer { + grid-template-rows: 1fr; +} + +.wedocs-faq-item__answer-inner { + overflow: hidden; + padding: 0 18px; + font-size: 14px; + line-height: 1.6; + color: #4b5563; +} + +.wedocs-faq-item[open] .wedocs-faq-item__answer-inner { + padding-bottom: 14px; +} diff --git a/assets/js/faq.js b/assets/js/faq.js new file mode 100644 index 00000000..f418fb01 --- /dev/null +++ b/assets/js/faq.js @@ -0,0 +1,33 @@ +// DESCRIPTION: Smooth expand/collapse transitions for FAQ
+// elements rendered by the [wedocs_faq] shortcode. + +document.addEventListener( 'DOMContentLoaded', function () { + document.querySelectorAll( '.wedocs-faq-item' ).forEach( function ( details ) { + var summary = details.querySelector( '.wedocs-faq-item__question' ); + var answer = details.querySelector( '.wedocs-faq-item__answer' ); + + // Items open by default need the inline style set so transitions work. + if ( details.open ) { + answer.style.gridTemplateRows = '1fr'; + } + + summary.addEventListener( 'click', function ( e ) { + e.preventDefault(); + + if ( details.open ) { + // Closing: let the transition finish before removing open. + answer.style.gridTemplateRows = '0fr'; + answer.addEventListener( 'transitionend', function handler() { + details.open = false; + answer.removeEventListener( 'transitionend', handler ); + } ); + } else { + // Opening: set open first so content is measurable, then animate. + details.open = true; + // Force a reflow so the browser registers the 0fr starting state. + answer.offsetHeight; // eslint-disable-line no-unused-expressions + answer.style.gridTemplateRows = '1fr'; + } + } ); + } ); +} ); diff --git a/includes/Admin/Menu.php b/includes/Admin/Menu.php index a764fb16..d0806bb0 100644 --- a/includes/Admin/Menu.php +++ b/includes/Admin/Menu.php @@ -41,15 +41,46 @@ public function __construct() { * @return void */ public function add_admin_menu() { + $parent_slug = 'wedocs'; add_menu_page( __( 'weDocs', 'wedocs' ), __( 'weDocs', 'wedocs' ), $this->capability, - 'wedocs', + $parent_slug, array( $this, 'display_wedocs' ), 'dashicons-media-document', $this->get_menu_position() ); + + $faq = add_submenu_page( $parent_slug, __( 'FAQ', 'wedocs' ), __( 'FAQ', 'wedocs' ), $this->capability, 'wedocs-faq', array( $this, 'display_faq' ) ); + + add_action( 'load-' . $faq, [ $this, 'faq_menu_action' ] ); + } + + /** + * Fire the FAQ page load hook. + * + * @since WEDOCS_SINCE + * + * @return void + */ + public function faq_menu_action() { + /** + * Backdoor for calling the menu hook. + * This hook won't get translated even the site language is changed + */ + do_action( 'wedocs_load_faq_page' ); + } + + /** + * Display FAQ page. + * + * @since WEDOCS_SINCE + * + * @return void + */ + public function display_faq() { + wedocs_get_template_part( 'admin/faq' ); } /** @@ -78,6 +109,12 @@ public function add_admin_submenu() { $this->capability, 'edit-tags.php?taxonomy=doc_tag&post_type=docs', ), + array( + __( 'FAQ', 'wedocs' ), + $this->capability, + 'wedocs-faq', + __( 'FAQ', 'wedocs' ), + ), array( __( 'Settings', 'wedocs' ), apply_filters( 'wedocs_settings_management_capabilities', $this->capability ), @@ -91,9 +128,10 @@ public function add_admin_submenu() { ); $all_submenus = apply_filters( 'wedocs_submenu', $all_submenus ); - if ( empty( $submenu['wedocs'] ) ) { - $submenu['wedocs'] = array(); // phpcs:ignore. - } + + // Reset submenu to remove the auto-generated parent duplicate + // that WordPress creates when add_submenu_page() is used. + $submenu['wedocs'] = array(); // phpcs:ignore. array_push( $submenu['wedocs'], diff --git a/includes/Assets.php b/includes/Assets.php index 10c54a89..d1feadd5 100644 --- a/includes/Assets.php +++ b/includes/Assets.php @@ -14,6 +14,7 @@ public function __construct() { add_action( 'init', array( $this, 'register' ) ); add_action( 'init', array( $this, 'register_translations' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue' ) ); + add_action( 'wedocs_load_faq_page', array( $this, 'enqueue_faq_assets' ) ); } /** @@ -131,6 +132,36 @@ public function register() { ); } + // Register FAQ assets. + if ( file_exists( WEDOCS_PATH . '/assets/build/faq.asset.php' ) ) { + $faq_dependencies = require WEDOCS_PATH . '/assets/build/faq.asset.php'; + + wp_register_style( + 'wedocs-faq-style', + $assets_url . '/build/faq.css', + array(), + $faq_dependencies['version'], + ); + + wp_register_script( + 'wedocs-faq-script', + $assets_url . '/build/faq.js', + $faq_dependencies['dependencies'], + $faq_dependencies['version'], + true + ); + + wp_localize_script( + 'wedocs-faq-script', + 'weDocsFaqVars', + array( + 'adminUrl' => admin_url(), + 'restNonce' => wp_create_nonce( 'wp_rest' ), + 'hasManageCap' => current_user_can( 'manage_options' ), + ), + ); + } + wp_enqueue_style( 'wedocs-block-style' ); } @@ -175,4 +206,17 @@ public function admin_enqueue() { wp_enqueue_script( 'wedocs-editor-script' ); } } + + /** + * Enqueue FAQ page assets. + * + * @since WEDOCS_SINCE + * + * @return void + */ + public function enqueue_faq_assets() { + wp_enqueue_media(); + wp_enqueue_style( 'wedocs-faq-style' ); + wp_enqueue_script( 'wedocs-faq-script' ); + } } diff --git a/includes/Frontend.php b/includes/Frontend.php index d5343675..755898a3 100644 --- a/includes/Frontend.php +++ b/includes/Frontend.php @@ -73,6 +73,8 @@ public function register_scripts() { // All scripts goes here wp_register_script( 'wedocs-anchorjs', WEDOCS_ASSETS . '/js/anchor.min.js', [ 'jquery' ], WEDOCS_VERSION, true ); wp_register_script( 'wedocs-scripts', WEDOCS_ASSETS . '/js/frontend.js', [ 'jquery', 'wedocs-anchorjs' ], filemtime( WEDOCS_PATH . '/assets/js/frontend.js' ), true ); + wp_register_style( 'wedocs-faq', WEDOCS_ASSETS . '/css/faq.css', [], filemtime( WEDOCS_PATH . '/assets/css/faq.css' ) ); + wp_register_script( 'wedocs-faq', WEDOCS_ASSETS . '/js/faq.js', [], filemtime( WEDOCS_PATH . '/assets/js/faq.js' ), true ); $store_dependencies = require WEDOCS_PATH . '/assets/build/store.asset.php'; wp_register_script( 'wedocs-store-js', WEDOCS_ASSETS . '/build/store.js', $store_dependencies['dependencies'], $store_dependencies['version'], true ); diff --git a/includes/Post_Types.php b/includes/Post_Types.php index e0bf1b3c..92fa1c6e 100644 --- a/includes/Post_Types.php +++ b/includes/Post_Types.php @@ -28,6 +28,10 @@ public function __construct() { // updated_postmeta fires on subsequent changes. Listen to both. add_action( 'added_postmeta', [ $this, 'propagate_vendor_doc_meta' ], 10, 4 ); add_action( 'updated_postmeta', [ $this, 'propagate_vendor_doc_meta' ], 10, 4 ); + add_action( 'init', [ $this, 'register_faq_post_type' ] ); + add_action( 'init', [ $this, 'register_faq_taxonomy' ] ); + add_action( 'init', [ $this, 'register_faq_meta' ] ); + add_filter( 'get_terms', [ $this, 'sort_faq_groups_by_order' ], 10, 4 ); } /** @@ -223,4 +227,170 @@ public function propagate_vendor_doc_meta( $meta_id, $post_id, $meta_key, $meta_ add_action( 'updated_postmeta', [ $this, 'propagate_vendor_doc_meta' ], 10, 4 ); } + + /** + * Register the FAQ post type. + * + * @since WEDOCS_SINCE + * + * @return void + */ + public function register_faq_post_type() { + $labels = [ + 'name' => _x( 'FAQs', 'Post Type General Name', 'wedocs' ), + 'singular_name' => _x( 'FAQ', 'Post Type Singular Name', 'wedocs' ), + 'menu_name' => __( 'FAQs', 'wedocs' ), + 'all_items' => __( 'All FAQs', 'wedocs' ), + 'view_item' => __( 'View FAQ', 'wedocs' ), + 'add_new_item' => __( 'Add FAQ', 'wedocs' ), + 'add_new' => __( 'Add New', 'wedocs' ), + 'edit_item' => __( 'Edit FAQ', 'wedocs' ), + 'update_item' => __( 'Update FAQ', 'wedocs' ), + 'search_items' => __( 'Search FAQs', 'wedocs' ), + 'not_found' => __( 'No FAQs found', 'wedocs' ), + 'not_found_in_trash' => __( 'Not found in Trash', 'wedocs' ), + ]; + + $args = [ + 'labels' => $labels, + 'supports' => [ 'title', 'editor', 'page-attributes', 'custom-fields' ], + 'hierarchical' => false, + 'public' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'rest_base' => 'wedocs-faqs', + 'has_archive' => false, + 'exclude_from_search' => true, + 'publicly_queryable' => false, + 'map_meta_cap' => true, + 'capability_type' => [ 'doc', 'docs' ], + ]; + + register_post_type( 'wedocs_faq', apply_filters( 'wedocs_faq_post_type', $args ) ); + } + + /** + * Register the FAQ group taxonomy. + * + * @since WEDOCS_SINCE + * + * @return void + */ + public function register_faq_taxonomy() { + $labels = [ + 'name' => _x( 'FAQ Groups', 'Taxonomy General Name', 'wedocs' ), + 'singular_name' => _x( 'FAQ Group', 'Taxonomy Singular Name', 'wedocs' ), + 'menu_name' => __( 'FAQ Groups', 'wedocs' ), + 'all_items' => __( 'All FAQ Groups', 'wedocs' ), + 'new_item_name' => __( 'New FAQ Group', 'wedocs' ), + 'add_new_item' => __( 'Add New FAQ Group', 'wedocs' ), + 'edit_item' => __( 'Edit FAQ Group', 'wedocs' ), + 'update_item' => __( 'Update FAQ Group', 'wedocs' ), + 'search_items' => __( 'Search FAQ Groups', 'wedocs' ), + 'not_found' => __( 'Not Found', 'wedocs' ), + ]; + + $args = [ + 'labels' => $labels, + 'hierarchical' => true, + 'public' => false, + 'show_ui' => false, + 'show_in_rest' => true, + 'rest_base' => 'wedocs-faq-groups', + 'show_admin_column' => false, + 'capabilities' => [ + 'manage_terms' => 'manage_doc_terms', + 'edit_terms' => 'edit_doc_terms', + 'delete_terms' => 'delete_doc_terms', + 'assign_terms' => 'edit_docs', + ], + ]; + + register_taxonomy( 'wedocs_faq_group', [ 'wedocs_faq' ], $args ); + } + + /** + * Register FAQ post meta fields for REST API exposure. + * + * @since WEDOCS_SINCE + * + * @return void + */ + public function register_faq_meta() { + register_post_meta( 'wedocs_faq', '_faq_open_by_default', [ + 'type' => 'boolean', + 'single' => true, + 'default' => false, + 'show_in_rest' => true, + 'sanitize_callback' => 'rest_sanitize_boolean', + 'auth_callback' => function () { + return current_user_can( 'edit_docs' ); + }, + ] ); + + register_term_meta( 'wedocs_faq_group', 'icon', [ + 'type' => 'integer', + 'single' => true, + 'default' => 0, + 'show_in_rest' => true, + 'sanitize_callback' => 'absint', + ] ); + + register_term_meta( 'wedocs_faq_group', 'order', [ + 'type' => 'integer', + 'single' => true, + 'default' => 0, + 'show_in_rest' => true, + 'sanitize_callback' => 'absint', + ] ); + + register_term_meta( 'wedocs_faq_group', 'status', [ + 'type' => 'boolean', + 'single' => true, + 'default' => true, + 'show_in_rest' => true, + 'sanitize_callback' => 'rest_sanitize_boolean', + ] ); + } + + /** + * Sort FAQ group terms by their order term meta after retrieval. + * + * Uses the get_terms filter instead of meta_key query arg because + * terms without an explicit order meta row would be excluded by WP_Term_Query. + * + * @since WEDOCS_SINCE + * + * @param array $terms Array of found terms. + * @param array|null $taxonomies Array of taxonomies. + * @param array $args Term query arguments. + * @param \WP_Term_Query $term_query The WP_Term_Query instance. + * + * @return array + */ + public function sort_faq_groups_by_order( $terms, $taxonomies, $args, $term_query ) { + // Bail early for non-FAQ taxonomy queries to avoid overhead on every get_terms call. + if ( ! is_array( $taxonomies ) || ! in_array( 'wedocs_faq_group', $taxonomies, true ) ) { + return $terms; + } + + if ( empty( $terms ) || is_wp_error( $terms ) ) { + return $terms; + } + + // Only sort when terms are returned as objects (not counts, ids, etc.). + if ( ! isset( $terms[0] ) || ! is_object( $terms[0] ) ) { + return $terms; + } + + usort( $terms, function ( $a, $b ) { + $order_a = (int) get_term_meta( $a->term_id, 'order', true ); + $order_b = (int) get_term_meta( $b->term_id, 'order', true ); + + return $order_a - $order_b; + } ); + + return $terms; + } } diff --git a/includes/Shortcode.php b/includes/Shortcode.php index 8a25f8cf..e388ebeb 100644 --- a/includes/Shortcode.php +++ b/includes/Shortcode.php @@ -12,6 +12,7 @@ class Shortcode { */ public function __construct() { add_shortcode( 'wedocs', [ $this, 'shortcode' ] ); + add_shortcode( 'wedocs_faq', [ $this, 'faq_shortcode' ] ); } /** @@ -148,6 +149,7 @@ function ( $doc ) { 'more' => $args['more'], 'col' => (int) ( $docs_length === 1 ? $docs_length : $args['col'] ), 'enable_search' => wedocs_get_general_settings( 'enable_search', 'on' ), + 'show_faq' => wedocs_get_general_settings( 'show_faq', 'off' ), 'current_page' => $current_page, 'total_pages' => $total_pages, ) @@ -156,4 +158,144 @@ function ( $doc ) { // Render single documentation template. wedocs_get_template( $template_dir, $template_args ); } + + /** + * FAQ shortcode handler. + * + * @since WEDOCS_SINCE + * + * @param array $atts Shortcode attributes. + * @param string $content Shortcode content. + * + * @return string + */ + public function faq_shortcode( $atts, $content = '' ) { + Frontend::enqueue_assets(); + + ob_start(); + self::wedocs_faq( $atts ); + $content .= ob_get_clean(); + + return $content; + } + + /** + * Query and render FAQ groups and items. + * + * @since WEDOCS_SINCE + * + * @param array $args Shortcode attributes. + * + * @return void + */ + public static function wedocs_faq( $args = [] ) { + wp_enqueue_style( 'wedocs-faq' ); + wp_enqueue_script( 'wedocs-faq' ); + + $defaults = [ + 'group' => '', + 'limit' => -1, + 'orderby' => 'menu_order', + 'order' => 'ASC', + ]; + + $args = wp_parse_args( $args, $defaults ); + + $group_query_args = [ + 'taxonomy' => 'wedocs_faq_group', + 'hide_empty' => true, + ]; + + // Filter by specific group slug(s). + if ( ! empty( $args['group'] ) ) { + $group_query_args['slug'] = array_map( 'trim', explode( ',', $args['group'] ) ); + } + + $faq_groups = get_terms( $group_query_args ); + + if ( empty( $faq_groups ) || is_wp_error( $faq_groups ) ) { + return; + } + + // Build FAQ data keyed by group. + $faq_data = []; + + foreach ( $faq_groups as $group ) { + // WP deletes the meta row when storing boolean false, so an empty + // get_term_meta result can mean either "never set" (treat as active) + // or "explicitly disabled". Use metadata_exists() to tell them apart. + $has_status = metadata_exists( 'term', $group->term_id, 'status' ); + $status = get_term_meta( $group->term_id, 'status', true ); + + // Skip groups that were explicitly disabled. + if ( $has_status && ! $status ) { + continue; + } + + $faq_query_args = [ + 'post_type' => 'wedocs_faq', + 'posts_per_page' => (int) $args['limit'], + 'orderby' => $args['orderby'], + 'order' => $args['order'], + 'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + [ + 'taxonomy' => 'wedocs_faq_group', + 'field' => 'term_id', + 'terms' => $group->term_id, + ], + ], + ]; + + /** + * Filter the FAQ query arguments for a specific group. + * + * @since WEDOCS_SINCE + * + * @param array $faq_query_args WP_Query arguments. + * @param \WP_Term $group The FAQ group term. + * @param array $args Shortcode attributes. + */ + $faq_query_args = apply_filters( 'wedocs_faq_shortcode_query_args', $faq_query_args, $group, $args ); + + $faqs = get_posts( $faq_query_args ); + + if ( empty( $faqs ) ) { + continue; + } + + $faq_data[] = [ + 'group' => $group, + 'faqs' => $faqs, + ]; + } + + if ( empty( $faq_data ) ) { + return; + } + + /** + * Filter the FAQ shortcode template path. + * + * @since WEDOCS_SINCE + * + * @param string $template_dir Template file name. + */ + $template_dir = apply_filters( 'wedocs_get_faq_listing_template_dir', 'shortcode-faq.php' ); + + /** + * Filter the FAQ shortcode template arguments. + * + * @since WEDOCS_SINCE + * + * @param array $template_args Template arguments. + */ + $template_args = apply_filters( + 'wedocs_get_faq_listing_template_args', + [ + 'faq_data' => $faq_data, + ] + ); + + wedocs_get_template( $template_dir, $template_args ); + } } diff --git a/package-lock.json b/package-lock.json index 3a23f8ed..50372729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "weDocs", - "version": "2.3.0", + "version": "2.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "weDocs", - "version": "2.3.0", + "version": "2.3.1", "license": "GPL", "dependencies": { "@dnd-kit/core": "^6.0.7", @@ -15,6 +15,13 @@ "@headlessui/react": "^1.7.7", "@heroicons/react": "^2.0.14", "@tailwindcss/forms": "^0.5.3", + "@tiptap/extension-highlight": "^3.22.2", + "@tiptap/extension-link": "^3.22.2", + "@tiptap/extension-placeholder": "^3.22.2", + "@tiptap/extension-text-align": "^3.22.2", + "@tiptap/extension-underline": "^3.22.2", + "@tiptap/react": "^3.22.2", + "@tiptap/starter-kit": "^3.22.2", "@wordpress/api-fetch": "^6.21.0", "@wordpress/core-data": "^6.0.0", "@wordpress/data": "^8.3.0", @@ -5486,6 +5493,474 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tiptap/core": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz", + "integrity": "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.27.1.tgz", + "integrity": "sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.1.tgz", + "integrity": "sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.1.tgz", + "integrity": "sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.1.tgz", + "integrity": "sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.1" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.1.tgz", + "integrity": "sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.27.1.tgz", + "integrity": "sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.27.1.tgz", + "integrity": "sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.1.tgz", + "integrity": "sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.1" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.1.tgz", + "integrity": "sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.1.tgz", + "integrity": "sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.1" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.27.1.tgz", + "integrity": "sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.1.tgz", + "integrity": "sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-highlight": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.27.1.tgz", + "integrity": "sha512-IeMIUKbW4oyRkgn92BPYQ0fifkbTwVigKwa107nGDHMokz+pHQPFHy8xG3U7gtUOra/8OUo57bFgNGuP0TaH4A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.1.tgz", + "integrity": "sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.1.tgz", + "integrity": "sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.27.1.tgz", + "integrity": "sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.1.tgz", + "integrity": "sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.27.1.tgz", + "integrity": "sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.1" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.1.tgz", + "integrity": "sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.1" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.1.tgz", + "integrity": "sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.1" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.1.tgz", + "integrity": "sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.27.1.tgz", + "integrity": "sha512-lhcNDcczQ75yJOSywCHb58Hmtg1aPL/2TdYmeLPxVrP048D7rRs133sfONcgyyw0AvhnfmPOkHLTv3QtKSowhw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.1" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.1.tgz", + "integrity": "sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.1.tgz", + "integrity": "sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.27.1.tgz", + "integrity": "sha512-EXawuJBO55wd8WcTbHTMoPhv0CGQxza4yCCPB5Hqz4ZPQwahIr3ej+8yp/kimIl0xokabwZ0/Fu8STQ4AkZv5g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.1.tgz", + "integrity": "sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.1.tgz", + "integrity": "sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.1.tgz", + "integrity": "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.0", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.27.1.tgz", + "integrity": "sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.27.1", + "@tiptap/extension-floating-menu": "^3.27.1" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.27.1.tgz", + "integrity": "sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.27.1", + "@tiptap/extension-blockquote": "^3.27.1", + "@tiptap/extension-bold": "^3.27.1", + "@tiptap/extension-bullet-list": "^3.27.1", + "@tiptap/extension-code": "^3.27.1", + "@tiptap/extension-code-block": "^3.27.1", + "@tiptap/extension-document": "^3.27.1", + "@tiptap/extension-dropcursor": "^3.27.1", + "@tiptap/extension-gapcursor": "^3.27.1", + "@tiptap/extension-hard-break": "^3.27.1", + "@tiptap/extension-heading": "^3.27.1", + "@tiptap/extension-horizontal-rule": "^3.27.1", + "@tiptap/extension-italic": "^3.27.1", + "@tiptap/extension-link": "^3.27.1", + "@tiptap/extension-list": "^3.27.1", + "@tiptap/extension-list-item": "^3.27.1", + "@tiptap/extension-list-keymap": "^3.27.1", + "@tiptap/extension-ordered-list": "^3.27.1", + "@tiptap/extension-paragraph": "^3.27.1", + "@tiptap/extension-strike": "^3.27.1", + "@tiptap/extension-text": "^3.27.1", + "@tiptap/extension-underline": "^3.27.1", + "@tiptap/extensions": "^3.27.1", + "@tiptap/pm": "^3.27.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -5987,6 +6462,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -15579,6 +16060,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -19410,6 +19900,12 @@ "uc.micro": "^1.0.1" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/loader-runner": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", @@ -20834,6 +21330,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", @@ -22452,6 +22954,145 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.9", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz", + "integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.9", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz", + "integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -23513,6 +24154,12 @@ "node": ">=10.0.0" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/rtlcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", @@ -27123,6 +27770,12 @@ "node": ">= 0.8" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/package.json b/package.json index 4a97d5d3..224967ab 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,13 @@ "@headlessui/react": "^1.7.7", "@heroicons/react": "^2.0.14", "@tailwindcss/forms": "^0.5.3", + "@tiptap/extension-highlight": "^3.22.2", + "@tiptap/extension-link": "^3.22.2", + "@tiptap/extension-placeholder": "^3.22.2", + "@tiptap/extension-text-align": "^3.22.2", + "@tiptap/extension-underline": "^3.22.2", + "@tiptap/react": "^3.22.2", + "@tiptap/starter-kit": "^3.22.2", "@wordpress/api-fetch": "^6.21.0", "@wordpress/core-data": "^6.0.0", "@wordpress/data": "^8.3.0", diff --git a/src/components/Settings/GeneralSettings.js b/src/components/Settings/GeneralSettings.js index d9069122..0bafe407 100644 --- a/src/components/Settings/GeneralSettings.js +++ b/src/components/Settings/GeneralSettings.js @@ -481,6 +481,52 @@ const GeneralSettings = ( { +
+
+
+ +
+ + + +
+
+
+ +
+
+
+
diff --git a/src/faq/assets/faq.css b/src/faq/assets/faq.css new file mode 100644 index 00000000..64b057cf --- /dev/null +++ b/src/faq/assets/faq.css @@ -0,0 +1,93 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Tiptap editor styles */ +.wedocs-tiptap-content .tiptap { + outline: none; + min-height: 120px; +} + +.wedocs-tiptap-content .tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: #9ca3af; + pointer-events: none; + height: 0; +} + +.wedocs-tiptap-content .tiptap h2 { + font-size: 1.3em; + font-weight: 600; + margin: 0.75em 0 0.25em; +} + +.wedocs-tiptap-content .tiptap h3 { + font-size: 1.1em; + font-weight: 600; + margin: 0.75em 0 0.25em; +} + +.wedocs-tiptap-content .tiptap h4 { + font-size: 1em; + font-weight: 600; + margin: 0.75em 0 0.25em; +} + +.wedocs-tiptap-content .tiptap p { + margin: 0.25em 0; +} + +.wedocs-tiptap-content .tiptap ul, +.wedocs-tiptap-content .tiptap ol { + padding-left: 1.5em; + margin: 0.5em 0; +} + +.wedocs-tiptap-content .tiptap ul { + list-style-type: disc; +} + +.wedocs-tiptap-content .tiptap ol { + list-style-type: decimal; +} + +.wedocs-tiptap-content .tiptap li { + margin: 0.15em 0; +} + +.wedocs-tiptap-content .tiptap blockquote { + border-left: 3px solid #d1d5db; + padding-left: 1em; + margin: 0.5em 0; + color: #6b7280; +} + +.wedocs-tiptap-content .tiptap pre { + background: #f3f4f6; + border-radius: 0.375rem; + padding: 0.75em 1em; + margin: 0.5em 0; + font-family: monospace; + font-size: 0.9em; + overflow-x: auto; +} + +.wedocs-tiptap-content .tiptap code { + background: #f3f4f6; + border-radius: 0.25rem; + padding: 0.15em 0.3em; + font-size: 0.9em; +} + +.wedocs-tiptap-content .tiptap a { + color: #4f46e5; + text-decoration: underline; + cursor: pointer; +} + +.wedocs-tiptap-content .tiptap mark { + background-color: #fef08a; + border-radius: 0.15em; + padding: 0.05em 0.1em; +} diff --git a/src/faq/components/AddFaqForm.js b/src/faq/components/AddFaqForm.js new file mode 100644 index 00000000..fe5f84eb --- /dev/null +++ b/src/faq/components/AddFaqForm.js @@ -0,0 +1,163 @@ +// DESCRIPTION: Inline form for creating a new FAQ item within a group. +// Contains a question text field and an answer textarea with cancel/create buttons. + +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import TiptapEditor from './TiptapEditor'; + +const AddFaqForm = ( { groupId, onFaqCreated, onCancel } ) => { + const [ question, setQuestion ] = useState( '' ); + const [ answer, setAnswer ] = useState( '' ); + const [ openByDefault, setOpenByDefault ] = useState( false ); + const [ isSubmitting, setIsSubmitting ] = useState( false ); + const [ errors, setErrors ] = useState( {} ); + + const validate = () => { + const newErrors = {}; + + if ( question.trim() === '' ) { + newErrors.question = __( 'Question is required.', 'wedocs' ); + } + + if ( answer.trim() === '' ) { + newErrors.answer = __( 'Answer is required.', 'wedocs' ); + } + + setErrors( newErrors ); + return Object.keys( newErrors ).length === 0; + }; + + const handleSubmit = async () => { + if ( ! validate() || isSubmitting ) { + return; + } + + setIsSubmitting( true ); + + try { + const faq = await apiFetch( { + path: '/wp/v2/wedocs-faqs', + method: 'POST', + data: { + title: question.trim(), + content: answer.trim(), + status: 'publish', + 'wedocs-faq-groups': [ groupId ], + meta: { + _faq_open_by_default: openByDefault, + }, + }, + } ); + + if ( onFaqCreated ) { + onFaqCreated( faq ); + } + + setQuestion( '' ); + setAnswer( '' ); + setOpenByDefault( false ); + setErrors( {} ); + } catch { + setErrors( { submit: __( 'Failed to create FAQ. Please try again.', 'wedocs' ) } ); + } finally { + setIsSubmitting( false ); + } + }; + + return ( +
+
+ + { + setQuestion( e.target.value ); + if ( errors.question ) { + setErrors( ( prev ) => ( { ...prev, question: undefined } ) ); + } + } } + className={ `w-full h-11 bg-white text-gray-900 text-base !rounded-md !py-2 !px-3 ${ errors.question ? '!border-red-500' : '!border-gray-300' }` } + /> + { errors.question && ( +

{ errors.question }

+ ) } +
+ +
+ + { + setAnswer( html ); + if ( errors.answer ) { + setErrors( ( prev ) => ( { ...prev, answer: undefined } ) ); + } + } } + placeholder={ __( 'Write your Answer here.', 'wedocs' ) } + hasError={ !! errors.answer } + /> + { errors.answer && ( +

{ errors.answer }

+ ) } +
+ + { errors.submit && ( +

{ errors.submit }

+ ) } + +
+
+ + +
+
+ + { __( 'Keep It Open By Default', 'wedocs' ) } + + +
+
+
+ ); +}; + +export default AddFaqForm; diff --git a/src/faq/components/AddFaqGroupModal.js b/src/faq/components/AddFaqGroupModal.js new file mode 100644 index 00000000..b66c835b --- /dev/null +++ b/src/faq/components/AddFaqGroupModal.js @@ -0,0 +1,300 @@ +// DESCRIPTION: Modal component for creating or editing a FAQ group. +// Opens a dialog with title input and icon upload fields. + +import { __ } from '@wordpress/i18n'; +import { Fragment, useState, useEffect } from '@wordpress/element'; +import { Dialog, Transition } from '@headlessui/react'; +import apiFetch from '@wordpress/api-fetch'; + +const AddFaqGroupModal = ( { + className, + children, + onGroupCreated, + onGroupUpdated, + editGroup = null, + isOpen: externalIsOpen, + onClose: externalOnClose, +} ) => { + const isControlled = typeof externalIsOpen !== 'undefined'; + const [ internalIsOpen, setInternalIsOpen ] = useState( false ); + const isOpen = isControlled ? externalIsOpen : internalIsOpen; + + const [ title, setTitle ] = useState( '' ); + const [ icon, setIcon ] = useState( null ); + const [ titleError, setTitleError ] = useState( false ); + const [ apiError, setApiError ] = useState( '' ); + const [ isSubmitting, setIsSubmitting ] = useState( false ); + + const isEditMode = !! editGroup; + + // Pre-fill form when editing. + useEffect( () => { + if ( isOpen && editGroup ) { + setTitle( editGroup.name || '' ); + + if ( editGroup.meta?.icon && editGroup.meta.icon > 0 ) { + // Fetch the attachment to get URL. + apiFetch( { path: `/wp/v2/media/${ editGroup.meta.icon }` } ) + .then( ( media ) => { + setIcon( { + id: media.id, + url: media.source_url, + alt: media.alt_text || media.title?.rendered || '', + } ); + } ) + .catch( () => { + setIcon( null ); + } ); + } else { + setIcon( null ); + } + } + }, [ isOpen, editGroup ] ); + + const closeModal = () => { + if ( isControlled && externalOnClose ) { + externalOnClose(); + } else { + setInternalIsOpen( false ); + } + setTitle( '' ); + setIcon( null ); + setTitleError( false ); + setApiError( '' ); + }; + + const openModal = () => { + if ( ! isControlled ) { + setInternalIsOpen( true ); + } + }; + + const onTitleChange = ( e ) => { + setTitle( e.target.value ); + setTitleError( e.target.value.length === 0 ); + setApiError( '' ); + }; + + const openMediaUploader = () => { + const mediaUploader = wp.media( { + title: __( 'Select FAQ Group Icon', 'wedocs' ), + button: { text: __( 'Use this icon', 'wedocs' ) }, + multiple: false, + library: { type: [ 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif' ] }, + } ); + + mediaUploader.on( 'select', () => { + const attachment = mediaUploader.state().get( 'selection' ).first().toJSON(); + setIcon( { + id: attachment.id, + url: attachment.url, + alt: attachment.alt || attachment.title, + } ); + } ); + + mediaUploader.open(); + }; + + const removeIcon = () => { + setIcon( null ); + }; + + const handleSubmit = async () => { + if ( title.trim() === '' ) { + setTitleError( true ); + return; + } + + setIsSubmitting( true ); + + try { + if ( isEditMode ) { + const updated = await apiFetch( { + path: `/wp/v2/wedocs-faq-groups/${ editGroup.id }`, + method: 'POST', + data: { + name: title.trim(), + meta: { + icon: icon ? icon.id : 0, + }, + }, + } ); + + if ( onGroupUpdated ) { + onGroupUpdated( updated ); + } + } else { + const group = await apiFetch( { + path: '/wp/v2/wedocs-faq-groups', + method: 'POST', + data: { + name: title.trim(), + meta: { + icon: icon ? icon.id : 0, + }, + }, + } ); + + if ( onGroupCreated ) { + onGroupCreated( group ); + } + } + + closeModal(); + } catch ( error ) { + setApiError( + error?.message || __( 'Failed to save FAQ group. Please try again.', 'wedocs' ) + ); + } finally { + setIsSubmitting( false ); + } + }; + + return ( + + { ! isControlled && ( + + ) } + + + + +
+
+ +
+
+ + +
+ + { isEditMode + ? __( 'Edit FAQ Group', 'wedocs' ) + : __( 'Create New FAQ Group', 'wedocs' ) + } + + +
+
+
+ + + { titleError && ( +

+ { __( 'Title is required.', 'wedocs' ) } +

+ ) } + { apiError && ( +

+ { apiError } +

+ ) } +
+ +
+ + { icon ? ( +
+ { + +
+ ) : ( + + ) } +
+ +
+ + +
+
+
+
+
+
+
+
+
+ ); +}; + +export default AddFaqGroupModal; diff --git a/src/faq/components/EmptyFaq.js b/src/faq/components/EmptyFaq.js new file mode 100644 index 00000000..36d78627 --- /dev/null +++ b/src/faq/components/EmptyFaq.js @@ -0,0 +1,83 @@ +// DESCRIPTION: Empty state component for the FAQ page. +// Shown when no FAQs exist yet. + +import { __ } from '@wordpress/i18n'; +import AddFaqGroupModal from './AddFaqGroupModal'; + +const EmptyFaq = ( { onGroupCreated } ) => { + return ( +
+
+
+
+

+ + + + + + + + + + ? + + + + + + + + + + + + +

+ { __( 'Get started by creating your first FAQ', 'wedocs' ) } +

+

+ { __( 'Create frequently asked questions to help your users find answers quickly.', 'wedocs' ) } +

+

+ + + { __( 'Create a new FAQ', 'wedocs' ) } + +
+
+
+
+ ); +}; + +export default EmptyFaq; diff --git a/src/faq/components/FaqApp.js b/src/faq/components/FaqApp.js new file mode 100644 index 00000000..a10427e1 --- /dev/null +++ b/src/faq/components/FaqApp.js @@ -0,0 +1,152 @@ +// DESCRIPTION: Main FAQ app component. +// Renders the FAQ page header, groups list, and empty state. + +import { __ } from '@wordpress/i18n'; +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, + arrayMove, +} from '@dnd-kit/sortable'; +import EmptyFaq from './EmptyFaq'; +import AddFaqGroupModal from './AddFaqGroupModal'; +import FaqGroupRow from './FaqGroupRow'; + +const FaqApp = () => { + const [ groups, setGroups ] = useState( [] ); + const [ isLoading, setIsLoading ] = useState( true ); + + const sensors = useSensors( + useSensor( PointerSensor, { + activationConstraint: { + delay: 150, + tolerance: 5, + }, + } ) + ); + + const fetchGroups = async () => { + try { + const data = await apiFetch( { + path: '/wp/v2/wedocs-faq-groups?per_page=100', + } ); + setGroups( data ); + } catch { + setGroups( [] ); + } finally { + setIsLoading( false ); + } + }; + + useEffect( () => { + fetchGroups(); + }, [] ); + + const handleGroupAdded = ( group ) => { + setGroups( ( prev ) => { + const newOrder = prev.length; + + // Persist the order meta so the group appears at the end. + apiFetch( { + path: `/wp/v2/wedocs-faq-groups/${ group.id }`, + method: 'POST', + data: { meta: { order: newOrder } }, + } ).catch( () => { + // Order update failed — the group still exists, just may not sort correctly on reload. + } ); + + return [ ...prev, { ...group, meta: { ...group.meta, order: newOrder } } ]; + } ); + }; + + const handleGroupDeleted = ( groupId ) => { + setGroups( ( prev ) => prev.filter( ( g ) => g.id !== groupId ) ); + }; + + const handleGroupUpdated = ( updated ) => { + setGroups( ( prev ) => + prev.map( ( g ) => ( g.id === updated.id ? updated : g ) ) + ); + }; + + const handleDragEnd = ( event ) => { + const { active, over } = event; + + if ( ! over || active.id === over.id ) { + return; + } + + setGroups( ( prev ) => { + const oldIndex = prev.findIndex( ( g ) => g.id === active.id ); + const newIndex = prev.findIndex( ( g ) => g.id === over.id ); + const reordered = arrayMove( prev, oldIndex, newIndex ); + + // Persist the new order to the backend. + reordered.forEach( ( group, index ) => { + apiFetch( { + path: `/wp/v2/wedocs-faq-groups/${ group.id }`, + method: 'POST', + data: { meta: { order: index } }, + } ).catch( () => { + // Order persist failed — will correct on next page load. + } ); + } ); + + return reordered; + } ); + }; + + return ( + <> +
+

+ { __( 'All FAQs', 'wedocs' ) } + + + { __( 'New FAQ', 'wedocs' ) } + +

+
+ + { ! isLoading && groups.length === 0 && } + + { ! isLoading && groups.length > 0 && ( + + g.id ) } + strategy={ verticalListSortingStrategy } + > +
+ { groups.map( ( group ) => ( + + ) ) } +
+
+
+ ) } + + ); +}; + +export default FaqApp; diff --git a/src/faq/components/FaqConfirmDialog.js b/src/faq/components/FaqConfirmDialog.js new file mode 100644 index 00000000..7cfdf2f6 --- /dev/null +++ b/src/faq/components/FaqConfirmDialog.js @@ -0,0 +1,97 @@ +// DESCRIPTION: Reusable confirmation dialog for FAQ actions. +// Renders a modal with icon, title, message, and confirm/cancel buttons. + +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; +import { Dialog, Transition } from '@headlessui/react'; + +const FaqConfirmDialog = ( { + isOpen, + onClose, + onConfirm, + title, + message, + confirmText, + cancelText, + confirmClassName, + icon, + isProcessing = false, + processingText, +} ) => { + return ( + + + +
+ + +
+
+ + +
+ { icon && ( +
+ { icon } +
+ ) } + + + { title } + + + { message && ( +

+ { message } +

+ ) } + +
+ + +
+
+
+
+
+
+
+
+ ); +}; + +export default FaqConfirmDialog; diff --git a/src/faq/components/FaqGroupRow.js b/src/faq/components/FaqGroupRow.js new file mode 100644 index 00000000..21cf971a --- /dev/null +++ b/src/faq/components/FaqGroupRow.js @@ -0,0 +1,520 @@ +// DESCRIPTION: Single FAQ group row component. +// Renders a draggable row with group title, actions, and expandable FAQ list. + +import { __ } from '@wordpress/i18n'; +import { useState, useEffect, useRef } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, + arrayMove, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import FaqConfirmDialog from './FaqConfirmDialog'; +import AddFaqForm from './AddFaqForm'; +import FaqItem from './FaqItem'; +import AddFaqGroupModal from './AddFaqGroupModal'; + +const FaqGroupRow = ( { group, onGroupDuplicated, onGroupDeleted, onGroupUpdated } ) => { + const [ isExpanded, setIsExpanded ] = useState( false ); + const [ isActive, setIsActive ] = useState( group.meta?.status !== false ); + const [ isToggling, setIsToggling ] = useState( false ); + const [ isDuplicating, setIsDuplicating ] = useState( false ); + const [ isDeleting, setIsDeleting ] = useState( false ); + const [ showDuplicateConfirm, setShowDuplicateConfirm ] = useState( false ); + const [ showDeleteConfirm, setShowDeleteConfirm ] = useState( false ); + const [ duplicateError, setDuplicateError ] = useState( '' ); + const [ showAddForm, setShowAddForm ] = useState( false ); + const [ showEditModal, setShowEditModal ] = useState( false ); + const [ faqs, setFaqs ] = useState( [] ); + const [ faqsLoaded, setFaqsLoaded ] = useState( false ); + const [ expandHeight, setExpandHeight ] = useState( '0px' ); + const expandRef = useRef( null ); + + const faqSensors = useSensors( + useSensor( PointerSensor, { + activationConstraint: { + delay: 150, + tolerance: 5, + }, + } ) + ); + + const handleFaqDragEnd = ( event ) => { + const { active, over } = event; + + if ( ! over || active.id === over.id ) { + return; + } + + setFaqs( ( prev ) => { + const oldIndex = prev.findIndex( ( f ) => f.id === active.id ); + const newIndex = prev.findIndex( ( f ) => f.id === over.id ); + const reordered = arrayMove( prev, oldIndex, newIndex ); + + // Persist the new order to the backend. + reordered.forEach( ( faq, index ) => { + apiFetch( { + path: `/wp/v2/wedocs-faqs/${ faq.id }`, + method: 'POST', + data: { menu_order: index }, + } ).catch( () => { + // Order persist failed — will correct on next page load. + } ); + } ); + + return reordered; + } ); + }; + + useEffect( () => { + if ( isExpanded && ! faqsLoaded ) { + apiFetch( { + path: `/wp/v2/wedocs-faqs?wedocs-faq-groups=${ group.id }&per_page=100&orderby=menu_order&order=asc`, + } ).then( ( data ) => { + setFaqs( data ); + setFaqsLoaded( true ); + } ).catch( () => { + setFaqsLoaded( true ); + } ); + } + }, [ isExpanded, faqsLoaded, group.id ] ); + + // Animate expand/collapse by transitioning max-height, then + // switching to 'none' so child content can grow freely. + useEffect( () => { + const el = expandRef.current; + if ( ! el ) { + return; + } + + if ( isExpanded ) { + // Set a measured max-height to kick off the CSS transition. + setExpandHeight( `${ el.scrollHeight }px` ); + + const onEnd = () => { + setExpandHeight( 'none' ); + el.removeEventListener( 'transitionend', onEnd ); + }; + el.addEventListener( 'transitionend', onEnd ); + + return () => el.removeEventListener( 'transitionend', onEnd ); + } + + // Collapsing: first pin max-height to current value so + // the transition has a start point, then collapse to 0. + setExpandHeight( `${ el.scrollHeight }px` ); + // Force a reflow so the browser registers the starting value. + void el.offsetHeight; + setExpandHeight( '0px' ); + }, [ isExpanded ] ); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable( { id: group.id } ); + + const style = { + transform: CSS.Transform.toString( transform ), + zIndex: isDragging ? 9999 : '', + transition, + }; + + const toggleExpand = () => { + setIsExpanded( ( prev ) => ! prev ); + }; + + const handleToggle = async () => { + if ( isToggling ) { + return; + } + + const nextStatus = ! isActive; + setIsActive( nextStatus ); + setIsToggling( true ); + + try { + const updated = await apiFetch( { + path: `/wp/v2/wedocs-faq-groups/${ group.id }`, + method: 'POST', + data: { + meta: { status: nextStatus }, + }, + } ); + + if ( onGroupUpdated ) { + onGroupUpdated( updated ); + } + } catch { + // Revert on failure. + setIsActive( ! nextStatus ); + } finally { + setIsToggling( false ); + } + }; + + const handleDuplicate = async () => { + setIsDuplicating( true ); + + try { + // Try creating the group with (Copy) suffix, incrementing if the name already exists. + let duplicated = null; + const baseName = group.name + ' ' + __( '(Copy)', 'wedocs' ); + const maxAttempts = 10; + + for ( let attempt = 0; attempt < maxAttempts; attempt++ ) { + const candidateName = attempt === 0 ? baseName : baseName + ' ' + ( attempt + 1 ); + + try { + duplicated = await apiFetch( { + path: '/wp/v2/wedocs-faq-groups', + method: 'POST', + data: { + name: candidateName, + meta: { + icon: group.meta?.icon || 0, + status: isActive, + }, + }, + } ); + break; + } catch ( createError ) { + if ( createError?.code !== 'term_exists' || attempt === maxAttempts - 1 ) { + throw createError; + } + } + } + + if ( ! duplicated ) { + throw new Error( __( 'Could not create a unique group name.', 'wedocs' ) ); + } + + // Fetch all FAQs in the original group and clone them into the new group. + const originalFaqs = await apiFetch( { + path: `/wp/v2/wedocs-faqs?wedocs-faq-groups=${ group.id }&per_page=100&orderby=menu_order&order=asc&context=edit`, + } ); + + for ( const faq of originalFaqs ) { + await apiFetch( { + path: '/wp/v2/wedocs-faqs', + method: 'POST', + data: { + title: faq.title.raw, + content: faq.content.raw, + status: faq.status, + menu_order: faq.menu_order, + meta: { + _faq_open_by_default: faq.meta?._faq_open_by_default || false, + }, + 'wedocs-faq-groups': [ duplicated.id ], + }, + } ); + } + + if ( onGroupDuplicated ) { + onGroupDuplicated( duplicated ); + } + + setShowDuplicateConfirm( false ); + } catch ( error ) { + setDuplicateError( + error?.message || __( 'Something went wrong while duplicating the FAQ group.', 'wedocs' ) + ); + setShowDuplicateConfirm( false ); + } finally { + setIsDuplicating( false ); + } + }; + + const handleDelete = async () => { + setIsDeleting( true ); + + try { + // Delete all FAQs in this group before deleting the group itself. + const groupFaqs = await apiFetch( { + path: `/wp/v2/wedocs-faqs?wedocs-faq-groups=${ group.id }&per_page=100`, + } ); + + for ( const faq of groupFaqs ) { + await apiFetch( { + path: `/wp/v2/wedocs-faqs/${ faq.id }?force=true`, + method: 'DELETE', + } ); + } + + await apiFetch( { + path: `/wp/v2/wedocs-faq-groups/${ group.id }?force=true`, + method: 'DELETE', + } ); + + if ( onGroupDeleted ) { + onGroupDeleted( group.id ); + } + } catch { + setIsDeleting( false ); + setShowDeleteConfirm( false ); + } + }; + + return ( +
+
+ { /* Drag handle */ } + +
+ + { group.name } + +
+ +
+ { /* Add a New FAQ button */ } + + + { /* Edit icon */ } + + + { /* Duplicate icon */ } + + + { /* Delete icon */ } + + + { /* Visibility toggle */ } +
+ + { isActive ? __( 'Active', 'wedocs' ) : __( 'Inactive', 'wedocs' ) } + + +
+ + { /* Expand/collapse chevron */ } + +
+
+ + { /* Expanded FAQ list area with smooth transition */ } +
+
+ { faqs.length === 0 && ! showAddForm && ( +

+ { __( 'No FAQs in this group yet. Click "Add a New FAQ" to get started.', 'wedocs' ) } +

+ ) } + + + f.id ) } + strategy={ verticalListSortingStrategy } + > + { faqs.map( ( faq ) => ( + { + setFaqs( ( prev ) => + prev.map( ( f ) => ( f.id === updated.id ? updated : f ) ) + ); + } } + onFaqDeleted={ ( id ) => { + setFaqs( ( prev ) => prev.filter( ( f ) => f.id !== id ) ); + } } + /> + ) ) } + + + + { showAddForm && ( + { + setFaqs( ( prev ) => [ ...prev, faq ] ); + setShowAddForm( false ); + } } + onCancel={ () => setShowAddForm( false ) } + /> + ) } +
+
+ + { /* Duplicate confirmation dialog */ } + setShowDuplicateConfirm( false ) } + onConfirm={ handleDuplicate } + title={ __( 'Duplicate this FAQ group?', 'wedocs' ) } + message={ __( 'This will create a copy of this FAQ group along with all the FAQs inside it.', 'wedocs' ) } + confirmText={ __( 'Duplicate', 'wedocs' ) } + processingText={ __( 'Duplicating...', 'wedocs' ) } + isProcessing={ isDuplicating } + icon={ +
+ + + + +
+ } + /> + + { /* Delete confirmation dialog */ } + setShowDeleteConfirm( false ) } + onConfirm={ handleDelete } + title={ __( 'Delete this FAQ group?', 'wedocs' ) } + message={ __( 'This will permanently delete this FAQ group and all FAQs inside it. This action cannot be undone.', 'wedocs' ) } + confirmText={ __( 'Delete', 'wedocs' ) } + processingText={ __( 'Deleting...', 'wedocs' ) } + confirmClassName="bg-red-600 hover:bg-red-700 text-white" + isProcessing={ isDeleting } + icon={ +
+ + + + +
+ } + /> + + { /* Duplicate error dialog */ } + setDuplicateError( '' ) } + onConfirm={ () => setDuplicateError( '' ) } + title={ __( 'Duplication Failed', 'wedocs' ) } + message={ duplicateError } + confirmText={ __( 'OK', 'wedocs' ) } + icon={ +
+ + + + + +
+ } + /> + + { /* Edit FAQ group modal */ } + setShowEditModal( false ) } + onGroupUpdated={ ( updated ) => { + setShowEditModal( false ); + if ( onGroupUpdated ) { + onGroupUpdated( updated ); + } + } } + /> +
+ ); +}; + +export default FaqGroupRow; diff --git a/src/faq/components/FaqItem.js b/src/faq/components/FaqItem.js new file mode 100644 index 00000000..3ef682ce --- /dev/null +++ b/src/faq/components/FaqItem.js @@ -0,0 +1,307 @@ +// DESCRIPTION: Single FAQ item component with collapsed/expanded edit mode. +// Clicking expands inline editing for question, answer, and open-by-default toggle. + +import { __ } from '@wordpress/i18n'; +import { useState, useRef, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import TiptapEditor from './TiptapEditor'; +import FaqConfirmDialog from './FaqConfirmDialog'; + +const FaqItem = ( { faq, onFaqUpdated, onFaqDeleted } ) => { + const [ isEditing, setIsEditing ] = useState( false ); + const [ question, setQuestion ] = useState( faq.title.rendered ); + const [ answer, setAnswer ] = useState( faq.content.rendered ); + const [ openByDefault, setOpenByDefault ] = useState( faq.meta?._faq_open_by_default || false ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ isDeleting, setIsDeleting ] = useState( false ); + const [ showDeleteConfirm, setShowDeleteConfirm ] = useState( false ); + const [ editHeight, setEditHeight ] = useState( '0px' ); + const editRef = useRef( null ); + + useEffect( () => { + const el = editRef.current; + if ( ! el ) { + return; + } + + if ( isEditing ) { + setEditHeight( `${ el.scrollHeight }px` ); + + const onEnd = () => { + setEditHeight( 'none' ); + el.removeEventListener( 'transitionend', onEnd ); + }; + el.addEventListener( 'transitionend', onEnd ); + + return () => el.removeEventListener( 'transitionend', onEnd ); + } + + setEditHeight( `${ el.scrollHeight }px` ); + void el.offsetHeight; + setEditHeight( '0px' ); + }, [ isEditing ] ); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable( { id: faq.id } ); + + const style = { + transform: CSS.Transform.toString( transform ), + zIndex: isDragging ? 9999 : '', + transition, + }; + + const handleSave = async () => { + if ( question.trim() === '' || isSaving ) { + return; + } + + setIsSaving( true ); + + try { + const updated = await apiFetch( { + path: `/wp/v2/wedocs-faqs/${ faq.id }`, + method: 'POST', + data: { + title: question.trim(), + content: answer.trim(), + meta: { + _faq_open_by_default: openByDefault, + }, + }, + } ); + + if ( onFaqUpdated ) { + onFaqUpdated( updated ); + } + + setIsEditing( false ); + } catch { + // Stay in edit mode on failure. + } finally { + setIsSaving( false ); + } + }; + + const handleDelete = async () => { + if ( isDeleting ) { + return; + } + + setIsDeleting( true ); + + try { + await apiFetch( { + path: `/wp/v2/wedocs-faqs/${ faq.id }?force=true`, + method: 'DELETE', + } ); + + if ( onFaqDeleted ) { + onFaqDeleted( faq.id ); + } + } catch { + setIsDeleting( false ); + } + }; + + const handleCancel = () => { + setQuestion( faq.title.rendered ); + setAnswer( faq.content.rendered ); + setOpenByDefault( faq.meta?._faq_open_by_default || false ); + setIsEditing( false ); + }; + + const handleToggleOpenByDefault = async ( nextValue ) => { + setOpenByDefault( nextValue ); + + try { + const updated = await apiFetch( { + path: `/wp/v2/wedocs-faqs/${ faq.id }`, + method: 'POST', + data: { + meta: { _faq_open_by_default: nextValue }, + }, + } ); + + if ( onFaqUpdated ) { + onFaqUpdated( updated ); + } + } catch { + // Revert on failure. + setOpenByDefault( ! nextValue ); + } + }; + + return ( +
+ { /* Collapsed header — always visible */ } +
+ { /* Drag handle */ } + +
+

setIsEditing( ( prev ) => ! prev ) } + className="font-medium text-gray-800 text-sm flex-1 truncate !m-0 py-4" + > + { faq.title.rendered } +

+
+
setIsEditing( ( prev ) => ! prev ) } + className="flex-shrink-0 ml-3 px-4" + > + + + +
+
+ + { /* Expandable edit area with smooth transition */ } +
+
+
+ + setQuestion( e.target.value ) } + className="w-full h-11 bg-white text-gray-900 text-base !rounded-md !py-2 !px-3 !border-gray-300" + /> +
+ +
+ + setAnswer( html ) } + placeholder={ __( 'Write your answer here...', 'wedocs' ) } + /> +
+ +
+
+ + + +
+
+ + { __( 'Keep It Open By Default', 'wedocs' ) } + + +
+
+
+
+ + setShowDeleteConfirm( false ) } + onConfirm={ () => { + setShowDeleteConfirm( false ); + handleDelete(); + } } + title={ __( 'Delete FAQ', 'wedocs' ) } + message={ __( 'Are you sure you want to delete this FAQ? This action cannot be undone.', 'wedocs' ) } + confirmText={ __( 'Delete', 'wedocs' ) } + confirmClassName="bg-red-600 hover:bg-red-700 text-white" + isProcessing={ isDeleting } + processingText={ __( 'Deleting...', 'wedocs' ) } + icon={ + + + + + + } + /> +
+ ); +}; + +export default FaqItem; diff --git a/src/faq/components/TiptapEditor.js b/src/faq/components/TiptapEditor.js new file mode 100644 index 00000000..93332cfa --- /dev/null +++ b/src/faq/components/TiptapEditor.js @@ -0,0 +1,373 @@ +// DESCRIPTION: Reusable rich-text editor component built on Tiptap. +// Toolbar: Bold, Italic, Underline, Strikethrough, Code, Normal/Heading dropdown, Alignments, Highlight, Link/Unlink, Lists. + +import { __ } from '@wordpress/i18n'; +import { useState, useRef, useEffect } from '@wordpress/element'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Link from '@tiptap/extension-link'; +import Placeholder from '@tiptap/extension-placeholder'; +import Underline from '@tiptap/extension-underline'; +import TextAlign from '@tiptap/extension-text-align'; +import Highlight from '@tiptap/extension-highlight'; + +const ToolbarButton = ( { onClick, isActive, disabled, title, children } ) => ( + +); + +const ToolbarDivider = () => ( + +); + +const BlockTypeDropdown = ( { editor } ) => { + const [ isOpen, setIsOpen ] = useState( false ); + const dropdownRef = useRef( null ); + + useEffect( () => { + const handleClickOutside = ( e ) => { + if ( dropdownRef.current && ! dropdownRef.current.contains( e.target ) ) { + setIsOpen( false ); + } + }; + document.addEventListener( 'mousedown', handleClickOutside ); + return () => document.removeEventListener( 'mousedown', handleClickOutside ); + }, [] ); + + const getCurrentLabel = () => { + if ( editor.isActive( 'heading', { level: 2 } ) ) { + return __( 'Heading 2', 'wedocs' ); + } + if ( editor.isActive( 'heading', { level: 3 } ) ) { + return __( 'Heading 3', 'wedocs' ); + } + if ( editor.isActive( 'heading', { level: 4 } ) ) { + return __( 'Heading 4', 'wedocs' ); + } + return __( 'Normal', 'wedocs' ); + }; + + const options = [ + { + label: __( 'Normal', 'wedocs' ), + action: () => editor.chain().focus().setParagraph().run(), + isActive: ! editor.isActive( 'heading' ), + }, + { + label: __( 'Heading 2', 'wedocs' ), + action: () => editor.chain().focus().toggleHeading( { level: 2 } ).run(), + isActive: editor.isActive( 'heading', { level: 2 } ), + }, + { + label: __( 'Heading 3', 'wedocs' ), + action: () => editor.chain().focus().toggleHeading( { level: 3 } ).run(), + isActive: editor.isActive( 'heading', { level: 3 } ), + }, + { + label: __( 'Heading 4', 'wedocs' ), + action: () => editor.chain().focus().toggleHeading( { level: 4 } ).run(), + isActive: editor.isActive( 'heading', { level: 4 } ), + }, + ]; + + return ( +
+ + { isOpen && ( +
+ { options.map( ( opt ) => ( + + ) ) } +
+ ) } +
+ ); +}; + +const Toolbar = ( { editor } ) => { + if ( ! editor ) { + return null; + } + + const setLink = () => { + const previousUrl = editor.getAttributes( 'link' ).href; + // eslint-disable-next-line no-alert + const url = window.prompt( __( 'Enter URL', 'wedocs' ), previousUrl || 'https://' ); + + if ( url === null ) { + return; + } + + if ( url === '' ) { + editor.chain().focus().extendMarkRange( 'link' ).unsetLink().run(); + return; + } + + editor.chain().focus().extendMarkRange( 'link' ).setLink( { href: url } ).run(); + }; + + return ( +
+ { /* Bold */ } + editor.chain().focus().toggleBold().run() } + isActive={ editor.isActive( 'bold' ) } + title={ __( 'Bold', 'wedocs' ) } + > + B + + + { /* Italic */ } + editor.chain().focus().toggleItalic().run() } + isActive={ editor.isActive( 'italic' ) } + title={ __( 'Italic', 'wedocs' ) } + > + I + + + { /* Underline */ } + editor.chain().focus().toggleUnderline().run() } + isActive={ editor.isActive( 'underline' ) } + title={ __( 'Underline', 'wedocs' ) } + > + U + + + { /* Strikethrough */ } + editor.chain().focus().toggleStrike().run() } + isActive={ editor.isActive( 'strike' ) } + title={ __( 'Strikethrough', 'wedocs' ) } + > + S + + + { /* Inline Code */ } + editor.chain().focus().toggleCode().run() } + isActive={ editor.isActive( 'code' ) } + title={ __( 'Inline Code', 'wedocs' ) } + > + { '{}' } + + + + + { /* Block type dropdown (Normal / Heading) */ } + + + + + { /* Align Left */ } + editor.chain().focus().setTextAlign( 'left' ).run() } + isActive={ editor.isActive( { textAlign: 'left' } ) } + title={ __( 'Align Left', 'wedocs' ) } + > + + + + + + + + + { /* Align Center */ } + editor.chain().focus().setTextAlign( 'center' ).run() } + isActive={ editor.isActive( { textAlign: 'center' } ) } + title={ __( 'Align Center', 'wedocs' ) } + > + + + + + + + + + { /* Align Right */ } + editor.chain().focus().setTextAlign( 'right' ).run() } + isActive={ editor.isActive( { textAlign: 'right' } ) } + title={ __( 'Align Right', 'wedocs' ) } + > + + + + + + + + + { /* Align Justify */ } + editor.chain().focus().setTextAlign( 'justify' ).run() } + isActive={ editor.isActive( { textAlign: 'justify' } ) } + title={ __( 'Justify', 'wedocs' ) } + > + + + + + + + + + + + { /* Highlight */ } + editor.chain().focus().toggleHighlight().run() } + isActive={ editor.isActive( 'highlight' ) } + title={ __( 'Highlight', 'wedocs' ) } + > + + + + + + + { /* Link */ } + + + + + + + + { /* Unlink */ } + editor.chain().focus().unsetLink().run() } + disabled={ ! editor.isActive( 'link' ) } + title={ __( 'Unlink', 'wedocs' ) } + > + + + + + + + + + + { /* Bullet List */ } + editor.chain().focus().toggleBulletList().run() } + isActive={ editor.isActive( 'bulletList' ) } + title={ __( 'Bullet List', 'wedocs' ) } + > + + + + + + + + + + + { /* Ordered List */ } + editor.chain().focus().toggleOrderedList().run() } + isActive={ editor.isActive( 'orderedList' ) } + title={ __( 'Ordered List', 'wedocs' ) } + > + + + + + 1 + 2 + 3 + + + +
+ ); +}; + +const TiptapEditor = ( { content, onChange, placeholder, hasError, id } ) => { + const editor = useEditor( { + extensions: [ + StarterKit.configure( { + heading: { levels: [ 2, 3, 4 ] }, + } ), + Underline, + Link.configure( { + openOnClick: false, + HTMLAttributes: { rel: 'noopener noreferrer nofollow' }, + } ), + Placeholder.configure( { + placeholder: placeholder || __( 'Write your answer here...', 'wedocs' ), + } ), + TextAlign.configure( { + types: [ 'heading', 'paragraph' ], + } ), + Highlight, + ], + content: content || '', + onUpdate: ( { editor: ed } ) => { + const html = ed.getHTML(); + // Tiptap returns '

' for empty content. + onChange( html === '

' ? '' : html ); + }, + } ); + + return ( +
+ + +
+ ); +}; + +export default TiptapEditor; diff --git a/src/faq/index.js b/src/faq/index.js new file mode 100644 index 00000000..4a25761f --- /dev/null +++ b/src/faq/index.js @@ -0,0 +1,19 @@ +// DESCRIPTION: Entry point for the FAQ Builder React app. +// Mounts the FaqApp component into the #wedocs-faq-app container. + +import './assets/faq.css'; +import FaqApp from './components/FaqApp'; +import { createRoot } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +// Set up REST API nonce for authentication. +if ( window.weDocsFaqVars?.restNonce ) { + apiFetch.use( apiFetch.createNonceMiddleware( window.weDocsFaqVars.restNonce ) ); +} + +const container = document.getElementById( 'wedocs-faq-app' ); + +if ( container ) { + const root = createRoot( container ); + root.render( ); +} diff --git a/templates/admin/faq.php b/templates/admin/faq.php new file mode 100644 index 00000000..b9c2090b --- /dev/null +++ b/templates/admin/faq.php @@ -0,0 +1,3 @@ +
+
+
diff --git a/templates/shortcode-faq.php b/templates/shortcode-faq.php new file mode 100644 index 00000000..cc9f0f5c --- /dev/null +++ b/templates/shortcode-faq.php @@ -0,0 +1,61 @@ + WP_Term, 'faqs' => WP_Post[] ] entries. + +if ( empty( $faq_data ) ) { + return; +} +?> + +
+

+ +

+ + + +
+

+ term_id, 'icon', true ); + + if ( $icon_id ) : + $icon_url = wp_get_attachment_image_url( $icon_id, 'thumbnail' ); + + if ( $icon_url ) : + ?> + <?php echo esc_attr( $group->name ); ?> + + name ); ?> +

+ +
+ ID, '_faq_open_by_default', true ); + ?> +
> + + post_title ); ?> + +
+
+ post_content ) ); ?> +
+
+
+ +
+
+ + +
diff --git a/templates/shortcode.php b/templates/shortcode.php index e320c607..340e1dac 100644 --- a/templates/shortcode.php +++ b/templates/shortcode.php @@ -122,6 +122,80 @@ class='children has-icon + + + + 1 ) : ?> + + + + +