diff --git a/base-tailwind.config.js b/base-tailwind.config.js new file mode 100644 index 0000000000..2047d53595 --- /dev/null +++ b/base-tailwind.config.js @@ -0,0 +1,66 @@ +import { + scopedPreflightStyles, + isolateInsideOfContainer, +} from 'tailwindcss-scoped-preflight'; + +const rootClass = '.dokan-layout'; //We will use this class to scope the styles. + +/** @type {import('tailwindcss').Config} */ +const baseConfig = { + important: rootClass, + content: [ './src/**/*.{js,jsx,ts,tsx}', '!./**/*.asset.php' ], + theme: { + extend: { + backgroundColor: { + dokan: { + sidebar: { + DEFAULT: + 'var(--dokan-sidebar-background-color, #F05025)', + hover: 'var(--dokan-sidebar-hover-background-color, #F05025)', + }, + btn: { + DEFAULT: + 'var(--dokan-button-background-color, #F05025)', + hover: 'var(--dokan-button-hover-background-color, #F05025)', + }, + }, + }, + textColor: { + dokan: { + sidebar: { + DEFAULT: 'var(--dokan-sidebar-text-color, #CFCFCF)', + hover: 'var(--dokan-sidebar-hover-text-color, #ffffff)', + }, + btn: { + DEFAULT: 'var(--dokan-button-text-color, #ffffff)', + hover: 'var(--dokan-button-hover-text-color, #ffffff)', + }, + }, + }, + borderColor: { + dokan: { + btn: { + DEFAULT: 'var(--dokan-button-border-color, #F05025)', + hover: 'var(--dokan-button-hover-border-color, #F05025)', + }, + }, + }, + colors: { + primary: 'var(--dokan-button-background-color, #F05025)', + dokan: { + sidebar: 'var(--dokan-button-background-color, #1B233B)', + btn: 'var(--dokan-button-background-color, #F05025)', + }, + }, + }, + }, + plugins: [ + scopedPreflightStyles( { + isolationStrategy: isolateInsideOfContainer( rootClass, {} ), + } ), + require( '@tailwindcss/typography' ), + require( '@tailwindcss/forms' ), + ], +}; + +module.exports = baseConfig; diff --git a/includes/Assets.php b/includes/Assets.php index 7f79cca66f..f87825b08a 100644 --- a/includes/Assets.php +++ b/includes/Assets.php @@ -2,6 +2,7 @@ namespace WeDevs\Dokan; +use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; use WeDevs\Dokan\Admin\Notices\Helper; use WeDevs\Dokan\ReverseWithdrawal\SettingsHelper; use WeDevs\Dokan\ProductCategory\Helper as CategoryHelper; @@ -360,6 +361,10 @@ public function get_styles() { 'src' => DOKAN_PLUGIN_ASSEST . '/css/dokan-tailwind.css', 'version' => filemtime( DOKAN_DIR . '/assets/css/dokan-tailwind.css' ), ], + 'dokan-react-frontend' => [ + 'src' => DOKAN_PLUGIN_ASSEST . '/css/frontend.css', + 'version' => filemtime( DOKAN_DIR . '/assets/css/frontend.css' ), + ], ]; return $styles; @@ -373,6 +378,7 @@ public function get_styles() { public function get_scripts() { global $wp_version; + $frontend_shipping_asset = require DOKAN_DIR . '/assets/js/frontend.asset.php'; $suffix = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min'; $asset_url = DOKAN_PLUGIN_ASSEST; $asset_path = DOKAN_DIR . '/assets/'; @@ -567,6 +573,11 @@ public function get_scripts() { 'deps' => [ 'jquery' ], 'version' => filemtime( $asset_path . 'js/dokan-frontend.js' ), ], + 'dokan-react-frontend' => [ + 'src' => $asset_url . '/js/frontend.js', + 'deps' => $frontend_shipping_asset['dependencies'], + 'version' => $frontend_shipping_asset['version'], + ], ]; return $scripts; @@ -873,6 +884,11 @@ public function dokan_dashboard_scripts() { self::load_form_validate_script(); $this->load_gmap_script(); + $wc_instance = WCAdminAssets::get_instance(); + $wc_instance->register_scripts(); + + wp_enqueue_script( 'dokan-react-frontend' ); + wp_enqueue_style( 'dokan-react-frontend' ); wp_enqueue_script( 'jquery' ); wp_enqueue_script( 'jquery-ui' ); wp_enqueue_script( 'jquery-ui-autocomplete' ); diff --git a/includes/REST/CustomersController.php b/includes/REST/CustomersController.php new file mode 100644 index 0000000000..af72481175 --- /dev/null +++ b/includes/REST/CustomersController.php @@ -0,0 +1,363 @@ +namespace, '/' . $this->rest_base . '/search', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'search_customers' ), + 'permission_callback' => array( $this, 'search_customers_permissions_check' ), + 'args' => array( + 'search' => array( + 'description' => __( 'Search string.', 'dokan-lite' ), + 'type' => 'string', + 'required' => true, + ), + 'exclude' => array( + 'description' => __( 'Comma-separated list of customer IDs to exclude.', 'dokan-lite' ), + 'type' => 'string', + ), + ), + ), + ) + ); + } + + /** + * Check if a given request has access to perform an action. + * + * @param WP_REST_Request $request Full details about the request. + * @param string $action The action to check (view, create, edit, delete). + * + * @return WP_Error|boolean + */ + protected function check_permission( $request, $action ) { + if ( ! $this->check_vendor_permission() ) { + $messages = [ + 'view' => __( 'Sorry, you cannot list resources.', 'dokan-lite' ), + 'create' => __( 'Sorry, you are not allowed to create resources.', 'dokan-lite' ), + 'edit' => __( 'Sorry, you are not allowed to edit this resource.', 'dokan-lite' ), + 'delete' => __( 'Sorry, you are not allowed to delete this resource.', 'dokan-lite' ), + 'batch' => __( 'Sorry, you are not allowed to batch update resources.', 'dokan-lite' ), + 'search' => __( 'Sorry, you are not allowed to search customers.', 'dokan-lite' ), + ]; + return new WP_Error( "dokan_rest_cannot_$action", $messages[ $action ], [ 'status' => rest_authorization_required_code() ] ); + } + return true; + } + + /** + * Check if the current user has vendor permissions. + * + * @return bool + */ + public function check_vendor_permission(): bool { + return dokan_is_user_seller( dokan_get_current_user_id() ); + } + + /** + * Get all customers. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response + */ + public function get_items( $request ) { + return $this->perform_vendor_action( + function () use ( $request ) { + return parent::get_items( $request ); + } + ); + } + + /** + * Get a single customer. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response + */ + public function get_item( $request ) { + return $this->perform_vendor_action( + function () use ( $request ) { + return parent::get_item( $request ); + } + ); + } + + /** + * Create a customer. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response + */ + public function create_item( $request ) { + return $this->perform_vendor_action( + function () use ( $request ) { + return parent::create_item( $request ); + } + ); + } + + /** + * Update a customer. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response + */ + public function update_item( $request ) { + return $this->perform_vendor_action( + function () use ( $request ) { + return parent::update_item( $request ); + } + ); + } + + /** + * Delete a customer. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response + */ + public function delete_item( $request ) { + return $this->perform_vendor_action( + function () use ( $request ) { + return parent::delete_item( $request ); + } + ); + } + + public function batch_items( $request ) { + return $this->perform_vendor_action( + function () use ( $request ) { + return parent::batch_items( $request ); + } + ); + } + + /** + * Search customers for the current vendor. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|WP_REST_Response + * @throws \Exception + */ + public function search_customers( $request ) { + if ( ! current_user_can( 'edit_shop_orders' ) ) { + return new WP_Error( 'dokan_rest_cannot_search', __( 'You do not have permission to search customers.', 'dokan-lite' ), [ 'status' => rest_authorization_required_code() ] ); + } + + $term = $request->get_param( 'search' ); + $exclude = $request->get_param( 'exclude' ) ? explode( ',', $request->get_param( 'exclude' ) ) : []; + $limit = ''; + + if ( empty( $term ) ) { + return new WP_Error( 'dokan_rest_empty_search', __( 'Search term is required.', 'dokan-lite' ), [ 'status' => 400 ] ); + } + + $ids = []; + // Search by ID. + if ( is_numeric( $term ) ) { + $customer = new WC_Customer( intval( $term ) ); + + // Customer exists. + if ( 0 !== $customer->get_id() ) { + $ids = [ $customer->get_id() ]; + } + } + + // Usernames can be numeric so we first check that no users was found by ID before searching for numeric username, this prevents performance issues with ID lookups. + if ( empty( $ids ) ) { + $data_store = WC_Data_Store::load( 'customer' ); + + // If search is smaller than 3 characters, limit result set to avoid + // too many rows being returned. + if ( 3 > strlen( $term ) ) { + $limit = 20; + } + $ids = $data_store->search_customers( $term, $limit ); + } + + $found_customers = []; + + $ids = array_diff( $ids, $exclude ); + + foreach ( $ids as $id ) { + if ( ! dokan_customer_has_order_from_this_seller( $id ) ) { + continue; + } + + $customer = new WC_Customer( $id ); + $found_customers[ $id ] = [ + 'id' => $id, + 'name' => sprintf( + '%s', + $customer->get_first_name() . ' ' . $customer->get_last_name() + ), + 'email' => $customer->get_email(), + ]; + } + + /** + * Filter the found customers for Dokan REST API search. + * + * This filter allows you to modify the list of customers found during a search + * before it is returned by the REST API. + * + * @since DOKAN_SINCE + * + * @param array $found_customers An array of found customers. Each customer is an array containing: + * 'id' => (int) The customer's ID. + * 'name' => (string) The customer's full name. + * 'email' => (string) The customer's email address. + * @param string $term The search term used to find customers. + * @param array $exclude An array of customer IDs to exclude from the search results. + * @param int $limit The maximum number of results to return (if any). + * + * @return array The filtered array of found customers. + */ + $found_customers = apply_filters( 'dokan_json_search_found_customers', $found_customers, $term, $exclude, $limit ); + + return rest_ensure_response( array_values( $found_customers ) ); + } + + /** + * Prepare a single customer for create or update. + * + * @param WP_REST_Request $request Request object. + * @param bool $creating If is creating a new object. + * + * @return WP_Error|WC_Data + */ + protected function prepare_object_for_database( $request, $creating = false ) { + $customer = parent::prepare_object_for_database( $request, $creating ); + + if ( is_wp_error( $customer ) ) { + return $customer; + } + + if ( ! $customer instanceof WC_Customer ) { + return new WP_Error( 'dokan_rest_invalid_customer', __( 'Invalid customer.', 'dokan-lite' ), [ 'status' => 400 ] ); + } + + // Add any Dokan-specific customer preparation here + + return apply_filters( "dokan_rest_pre_insert_{$this->post_type}_object", $customer, $request, $creating ); + } + + /** + * Perform an action with vendor permission check. + * + * @param callable $action The action to perform. + * + * @return mixed The result of the action. + */ + private function perform_vendor_action( callable $action ) { + add_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ] ); + $result = $action(); + remove_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ] ); + return $result; + } + + /** + * Check if a given request has access to get items. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function get_items_permissions_check( $request ) { + return $this->check_permission( $request, 'view' ); + } + + /** + * Check if a given request has access to get a specific item. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function get_item_permissions_check( $request ) { + return $this->check_permission( $request, 'view' ); + } + + /** + * Check if a given request has access to create a customer. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function create_item_permissions_check( $request ) { + return $this->check_permission( $request, 'create' ); + } + + /** + * Check if a given request has access to update a customer. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function update_item_permissions_check( $request ) { + return $this->check_permission( $request, 'edit' ); + } + + /** + * Check if a given request has access to delete a customer. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function delete_item_permissions_check( $request ) { + return $this->check_permission( $request, 'delete' ); + } + + /** + * Check if a given request has access to batch items. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function batch_items_permissions_check( $request ) { + return $this->check_permission( $request, 'batch' ); + } + + /** + * Check if a given request has access to search customers. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function search_customers_permissions_check( $request ) { + return $this->check_permission( $request, 'search' ); + } +} diff --git a/includes/REST/DokanDataContinentsController.php b/includes/REST/DokanDataContinentsController.php new file mode 100644 index 0000000000..783396ae59 --- /dev/null +++ b/includes/REST/DokanDataContinentsController.php @@ -0,0 +1,123 @@ +set_woocommerce_rest_check_permissions(); + + return parent::get_items( $request ); + } + + /** + * Return the list of states for a given country. + * + * @since 3.5.0 + * @param WP_REST_Request $request Request data. + * @return WP_Error|WP_REST_Response + */ + public function get_item( $request ) { + $this->set_woocommerce_rest_check_permissions(); + return parent::get_item( $request ); + } + + /** + * Check if a given request has access to read an item. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|boolean + */ + public function get_item_permissions_check( $request ) { + $this->set_woocommerce_rest_check_permissions(); + $item_permission = $this->get_items_permissions_check( $request ); + + if ( is_wp_error( $item_permission ) ) { + return $item_permission; + } + + return parent::get_item_permissions_check( $request ); + } + + /** + * Check if a given request has access to read items. + * + * @since DOKAN_SINCE + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|boolean + */ + public function get_items_permissions_check( $request ) { + // phpcs:ignore WordPress.WP.Capabilities.Unknown + if ( current_user_can( dokan_admin_menu_capability() ) || current_user_can( 'dokandar' ) ) { + return true; + } + + return new WP_Error( + 'dokan_pro_permission_failure', + __( 'You are not allowed to do this action.', 'dokan-lite' ), + [ + 'status' => rest_authorization_required_code(), + ] + ); + } + + private function set_woocommerce_rest_check_permissions() { + add_filter( 'woocommerce_rest_check_permissions', [ $this, 'add_subscriptions_read_permission_to_vendors' ], 10, 4 ); + } + + /** + * Add permissions. + * + * @since DOKAN_PRO_SINCE + * + * @param $permission + * @param $context + * @param $object_id + * @param $obj + * + * @return true + */ + public function add_subscriptions_read_permission_to_vendors( $permission, $context, $object_id, $obj ) { + if ( 'read' === $context ) { + return true; + } + + return $permission; + } +} diff --git a/includes/REST/DokanDataCountriesController.php b/includes/REST/DokanDataCountriesController.php new file mode 100644 index 0000000000..908f00311d --- /dev/null +++ b/includes/REST/DokanDataCountriesController.php @@ -0,0 +1,123 @@ +set_woocommerce_rest_check_permissions(); + + return parent::get_items( $request ); + } + + /** + * Return the list of states for a given country. + * + * @since 3.5.0 + * @param WP_REST_Request $request Request data. + * @return WP_Error|WP_REST_Response + */ + public function get_item( $request ) { + $this->set_woocommerce_rest_check_permissions(); + return parent::get_item( $request ); + } + + /** + * Check if a given request has access to read an item. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|boolean + */ + public function get_item_permissions_check( $request ) { + $this->set_woocommerce_rest_check_permissions(); + $item_permission = $this->get_items_permissions_check( $request ); + + if ( is_wp_error( $item_permission ) ) { + return $item_permission; + } + + return parent::get_item_permissions_check( $request ); + } + + /** + * Check if a given request has access to read items. + * + * @since DOKAN_SINCE + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|boolean + */ + public function get_items_permissions_check( $request ) { + // phpcs:ignore WordPress.WP.Capabilities.Unknown + if ( current_user_can( dokan_admin_menu_capability() ) || current_user_can( 'dokandar' ) ) { + return true; + } + + return new WP_Error( + 'dokan_pro_permission_failure', + __( 'You are not allowed to do this action.', 'dokan-lite' ), + [ + 'status' => rest_authorization_required_code(), + ] + ); + } + + private function set_woocommerce_rest_check_permissions() { + add_filter( 'woocommerce_rest_check_permissions', [ $this, 'add_subscriptions_read_permission_to_vendors' ], 10, 4 ); + } + + /** + * Add permissions. + * + * @since DOKAN_PRO_SINCE + * + * @param $permission + * @param $context + * @param $object_id + * @param $obj + * + * @return true + */ + public function add_subscriptions_read_permission_to_vendors( $permission, $context, $object_id, $obj ) { + if ( 'read' === $context ) { + return true; + } + + return $permission; + } +} diff --git a/includes/REST/Manager.php b/includes/REST/Manager.php index 10d0a5970d..6f861f4dab 100644 --- a/includes/REST/Manager.php +++ b/includes/REST/Manager.php @@ -201,6 +201,10 @@ private function get_rest_api_class_map() { DOKAN_DIR . '/includes/REST/VendorDashboardController.php' => '\WeDevs\Dokan\REST\VendorDashboardController', DOKAN_DIR . '/includes/REST/ProductBlockController.php' => '\WeDevs\Dokan\REST\ProductBlockController', DOKAN_DIR . '/includes/REST/CommissionControllerV1.php' => '\WeDevs\Dokan\REST\CommissionControllerV1', + DOKAN_DIR . '/includes/REST/CustomersController.php' => '\WeDevs\Dokan\REST\CustomersController', + DOKAN_DIR . '/includes/REST/DokanDataCountriesController.php' => '\WeDevs\Dokan\REST\DokanDataCountriesController', + DOKAN_DIR . '/includes/REST/DokanDataContinentsController.php' => '\WeDevs\Dokan\REST\DokanDataContinentsController', + DOKAN_DIR . '/includes/REST/OrderControllerV3.php' => '\WeDevs\Dokan\REST\OrderControllerV3', ) ); } diff --git a/includes/REST/OrderControllerV3.php b/includes/REST/OrderControllerV3.php new file mode 100644 index 0000000000..74167afca9 --- /dev/null +++ b/includes/REST/OrderControllerV3.php @@ -0,0 +1,63 @@ +get_data(); + $downloads = $data['downloads']; + $updated_response = []; + + foreach ( $downloads as $download ) { + $product = dokan()->product->get( $download->product_id ); + $download->product = [ + 'id' => $product->get_id(), + 'name' => $product->get_name(), + 'slug' => $product->get_slug(), + 'link' => $product->get_permalink(), + ]; + + /** + * @var $file \WC_Product_Download + */ + $file = $product->get_file( $download->download_id ); + $download->file_data = $file->get_data(); + $download->file_data['file_title'] = wc_get_filename_from_url( $product->get_file_download_path( $download->download_id ) ); + + $updated_response[] = $download; + } + + return rest_ensure_response( $updated_response ); + } +} diff --git a/includes/REST/ProductController.php b/includes/REST/ProductController.php index ee78d383e0..d0f1f970f1 100644 --- a/includes/REST/ProductController.php +++ b/includes/REST/ProductController.php @@ -54,6 +54,58 @@ class ProductController extends DokanRESTController { */ protected $post_status = [ 'publish', 'pending', 'draft' ]; + /** + * Class constructor. + * + * @since DOKAN_SINCE + */ + public function __construct() { + add_filter( "dokan_rest_{$this->post_type}_object_query", [ $this, 'add_only_downloadable_query' ], 10, 2 ); + } + + /** + * Add only downloadable meta query. + * + * @since DOKAN_SINCE + * + * @param array $args + * + * @param \WP_REST_Request $request + */ + public function add_only_downloadable_query( $args, $request ) { + if ( true === dokan_string_to_bool( $request->get_param( 'only_downloadable' ) ) ) { + $args['meta_query'][] = [ + 'key' => '_downloadable', + 'value' => 'yes', + 'compare' => '=', + ]; + } + + return $args; + } + + /** + * Product API query parameters collections. + * + * @since DOKAN_SINCE + * + * @return array Query parameters. + */ + public function get_product_collection_params() { + $schema = parent::get_product_collection_params(); + + $schema['only_downloadable'] = [ + 'description' => __( 'If truthy value then only downloadable products will be returned', 'dokan' ), + 'type' => [ 'boolean', 'string' ], + 'enum' => [ true, false, 0, 1 ], + 'sanitize_callback' => 'dokan_string_to_bool', + 'validate_callback' => 'dokan_string_to_bool', + 'default' => false, + ]; + + return $schema; + } + /** * Register all routes related with stores * diff --git a/package.json b/package.json index a98b03c555..7e6828effa 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "release:dev": "npm install && npm run build && npm run clean-files && npm run makepot && npm run zip" }, "devDependencies": { + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", "@wordpress/scripts": "^27.9.0", "chartjs-adapter-moment": "^1.0.1", "debounce": "^1.2.1", @@ -34,6 +36,7 @@ "papaparse": "^5.4.1", "replace-in-file": "^6.3.5", "tailwindcss": "^3.3.3", + "tailwindcss-scoped-preflight": "^3.4.5", "vue": "^2.7.14", "vue-chartjs": "^3.5.1", "vue-color": "^2.8.1", @@ -50,6 +53,14 @@ "wp-readme-to-markdown": "^1.0.1" }, "dependencies": { - "@wordpress/i18n": "^5.8.0" + "@getdokan/dokan-ui": "^1.0.14", + "@wordpress/components": "^28.9.0", + "@wordpress/data": "^10.9.0", + "@wordpress/dom-ready": "^4.9.0", + "@wordpress/element": "^6.9.0", + "@wordpress/hooks": "^4.9.0", + "@wordpress/i18n": "^5.8.0", + "@wordpress/plugins": "^7.10.0", + "react-router-dom": "^6.27.0" } } diff --git a/src/Dashboard/index.tsx b/src/Dashboard/index.tsx new file mode 100644 index 0000000000..4bf0eadc89 --- /dev/null +++ b/src/Dashboard/index.tsx @@ -0,0 +1,41 @@ +import {createRoot} from "@wordpress/element"; +import domReady from "@wordpress/dom-ready"; +import Layout from "../Layout"; +import getRoutes, { withRouter } from "../Routing"; +import { + createHashRouter, + RouterProvider, +} from "react-router-dom"; +import './tailwind.scss'; + +const App = () => { + const routes = getRoutes(); + + const mapedRoutes = routes.map((route) => { + const WithRouterComponent = withRouter( route.element ); + + return { + path: route.path, + element: + + , + } + }); + + const router = createHashRouter(mapedRoutes); + + return ; +} + +domReady( function () { + const rootElement = document.querySelector( '.dashboard-content-area' ); + const root = createRoot( rootElement! ); + root.render( + + ); +} ); diff --git a/src/Dashboard/tailwind.scss b/src/Dashboard/tailwind.scss new file mode 100644 index 0000000000..0a10673143 --- /dev/null +++ b/src/Dashboard/tailwind.scss @@ -0,0 +1,2 @@ +@config './../../base-tailwind.config.js'; +@import '../base-tailwind'; diff --git a/src/Layout/404.tsx b/src/Layout/404.tsx new file mode 100644 index 0000000000..53a2e17550 --- /dev/null +++ b/src/Layout/404.tsx @@ -0,0 +1,9 @@ +const NotFound = () => { + return ( +
+

404 - Not Found!

+
+ ); +} + +export default NotFound; diff --git a/src/Layout/ContentArea.tsx b/src/Layout/ContentArea.tsx new file mode 100644 index 0000000000..21c178bf61 --- /dev/null +++ b/src/Layout/ContentArea.tsx @@ -0,0 +1,17 @@ +import Sidebar from './Sidebar'; +import {Slot} from "@wordpress/components"; + +const ContentArea = ( { children } ) => { + return ( + <> + +
+ + { children } + +
+ + ); +}; + +export default ContentArea; diff --git a/src/Layout/Footer.tsx b/src/Layout/Footer.tsx new file mode 100644 index 0000000000..0ba155f3ef --- /dev/null +++ b/src/Layout/Footer.tsx @@ -0,0 +1,7 @@ +import {Slot} from "@wordpress/components"; + +const Footer = () => { + return <>; +}; + +export default Footer; diff --git a/src/Layout/Header.tsx b/src/Layout/Header.tsx new file mode 100644 index 0000000000..b8303c345f --- /dev/null +++ b/src/Layout/Header.tsx @@ -0,0 +1,21 @@ +import {Slot} from "@wordpress/components"; +import {useNavigate} from "react-router-dom"; + +const Header = ( { title = '' } ) => { + const navigate = useNavigate(); + + return ( +
+ +
+ { title && (

{title}

)} +
+
+ +
+ +
+ ); +}; + +export default Header; diff --git a/src/Layout/Sidebar.tsx b/src/Layout/Sidebar.tsx new file mode 100644 index 0000000000..cf785e0ec7 --- /dev/null +++ b/src/Layout/Sidebar.tsx @@ -0,0 +1,5 @@ +const Sidebar = () => { + return <>; +}; + +export default Sidebar; diff --git a/src/Layout/index.tsx b/src/Layout/index.tsx new file mode 100644 index 0000000000..0de5d0ed26 --- /dev/null +++ b/src/Layout/index.tsx @@ -0,0 +1,78 @@ +import { createContext, useContext, useState } from '@wordpress/element'; +import Header from './Header'; +import Footer from './Footer'; +import ContentArea from './ContentArea'; +import { + SlotFillProvider +} from '@wordpress/components'; +import { PluginArea } from '@wordpress/plugins'; +import { DokanToaster } from "@getdokan/dokan-ui"; + +// Create a ThemeContext +const ThemeContext = createContext( null ); + +// Create a ThemeProvider component +const ThemeProvider = ( { children } ) => { + const [ theme, setTheme ] = useState( 'light' ); // Example theme state + + return ( + + { children } + + ); +}; + +export type DokanRoute = { + id: string; + title?: string; + icon?: JSX.Element | React.ReactNode; + element: JSX.Element | React.ReactNode; + header?: JSX.Element | React.ReactNode; + footer?: JSX.Element | React.ReactNode; + path: string; + exact?: boolean; + order?: number; + parent?: string; +}; + +interface LayoutProps { + children: React.ReactNode; + route: DokanRoute; + title?: string; + headerComponent?: JSX.Element|React.ReactNode; + footerComponent?: JSX.Element|React.ReactNode; +} + +// Create a Layout component that uses the ThemeProvider +const Layout = ( { + children, + route, + title = '', + headerComponent, + footerComponent, +}: LayoutProps ) => { + return ( + + +
+ { headerComponent ? ( + headerComponent + ) : ( +
+ ) } + { children } + { footerComponent ? footerComponent :
} +
+ + +
+
+ ); +}; + +// Custom hook to use the ThemeContext +export const useTheme = () => { + return useContext( ThemeContext ); +}; + +export default Layout; diff --git a/src/Routing/index.tsx b/src/Routing/index.tsx new file mode 100644 index 0000000000..e2ad48b45d --- /dev/null +++ b/src/Routing/index.tsx @@ -0,0 +1,68 @@ +import NotFound from "../Layout/404"; +import {__} from "@wordpress/i18n"; +import {DokanRoute} from "../Layout"; +import { isValidElement, cloneElement, createElement } from '@wordpress/element'; +import { useNavigate, useParams, useLocation, redirect, replace, useMatches, useNavigation, } from 'react-router-dom'; + +export function withRouter(Component) { + function ComponentWithRouterProp(props) { + let navigate = useNavigate(); + let params = useParams(); + let location = useLocation(); + let matches = useMatches(); + const navigation = useNavigation(); + + const routerProps = { + navigate, + params, + location, + redirect, + replace, + matches, + navigation + }; + + // Check if Component is a valid element + if (isValidElement(Component)) { + // If it's a valid element, clone it and pass the router props + return cloneElement(Component, { ...props, ...routerProps }); + } + + // If it's a function component, render it with the router props + return createElement(Component, { + ...props, + ...routerProps + }); + } + + return ComponentWithRouterProp; +} + +const getRoutes = () => { + let routes : Array = []; + + routes.push( + { + id: 'dokan-base', + title: __( 'Dashboard', 'dokan-lite' ), + element:

Dashboard body

, + path: '/', + exact: true, + order: 10, + } + ); + + // @ts-ignore + routes = wp.hooks.applyFilters('dokan-dashboard-routes', routes) as Array; + routes.push( + { + id: 'dokan-404', + element: , + path: '*', + } + ); + + return routes; +} + +export default getRoutes; diff --git a/src/base-tailwind.scss b/src/base-tailwind.scss new file mode 100644 index 0000000000..4cb24cb3cc --- /dev/null +++ b/src/base-tailwind.scss @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +// Reset unwanted table styles but keep structure intact +@layer base { + .dokan-layout { + table, th, td { + margin: 0; + padding: 0; + border: 0; + border-spacing: 0; + border-collapse: collapse; + font-size: inherit; + font-weight: inherit; + text-align: inherit; + vertical-align: inherit; + box-sizing: border-box; + } + } +} diff --git a/tests/php/src/REST/CustomersControllerTest.php b/tests/php/src/REST/CustomersControllerTest.php new file mode 100644 index 0000000000..0121dfd473 --- /dev/null +++ b/tests/php/src/REST/CustomersControllerTest.php @@ -0,0 +1,628 @@ +controller = new CustomersController(); + + // Create test customers with specific data + $this->customer_data = [ + [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john.doe@example.com', + 'username' => 'johndoe', + ], + [ + 'first_name' => 'Jane', + 'last_name' => 'Smith', + 'email' => 'jane.smith@example.com', + 'username' => 'janesmith', + ], + ]; + + foreach ( $this->customer_data as $data ) { + $this->customers[] = $this->factory()->customer->create( $data ); + } + } + + /** + * Test route registration + */ + public function test_register_routes() { + $routes = $this->server->get_routes(); + + $base_endpoints = [ + "/$this->namespace/customers", + "/$this->namespace/customers/(?P[\\d]+)", + "/$this->namespace/customers/search", + "/$this->namespace/customers/batch", + ]; + + foreach ( $base_endpoints as $endpoint ) { + $this->assertArrayHasKey( $endpoint, $routes ); + } + + // Verify HTTP methods for each endpoint + $this->assertCount( 2, $routes[ "/$this->namespace/customers" ] ); + $this->assertCount( 3, $routes[ "/$this->namespace/customers/(?P[\\d]+)" ] ); + $this->assertCount( 1, $routes[ "/$this->namespace/customers/search" ] ); + $this->assertCount( 1, $routes[ "/$this->namespace/customers/batch" ] ); + } + + /** + * Test permission checks for each endpoint + */ + public function test_endpoint_permissions() { + $test_cases = [ + [ 'GET', 'customers', 401, 'dokan_rest_cannot_view' ], + [ 'POST', 'customers', 400, 'rest_missing_callback_param' ], + [ 'GET', "customers/{$this->customers[0]}", 401, 'dokan_rest_cannot_view' ], + [ 'PUT', "customers/{$this->customers[0]}", 401, 'dokan_rest_cannot_edit' ], + [ 'DELETE', "customers/{$this->customers[0]}", 401, 'dokan_rest_cannot_delete' ], + [ 'POST', 'customers/batch', 401, 'dokan_rest_cannot_batch' ], + [ 'GET', 'customers/search', 400, 'rest_missing_callback_param' ], + ]; + + foreach ( $test_cases as [ $method, $endpoint, $expected_status, $expected_code ] ) { + wp_set_current_user( 0 ); + + $response = $this->request( $method, $endpoint ); + + $this->assertEquals( $expected_status, $response->get_status() ); + $this->assertEquals( $expected_code, $response->get_data()['code'] ); + } + } + + /** + * Test get_items functionality + */ + public function test_get_items() { + wp_set_current_user( $this->seller_id1 ); + + // Create orders for customers with the vendor + foreach ( $this->customers as $customer_id ) { + $this->factory()->order->set_seller_id( $this->seller_id1 )->create( + [ + 'customer_id' => $customer_id, + ] + ); + } + + // Test default listing + $response = $this->get_request( 'customers' ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 3, $response->get_data() ); + + // Test with per_page parameter + $response = $this->get_request( 'customers', [ 'per_page' => 1 ] ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $response->get_data() ); + + // Test with ordering + $response = $this->get_request( + 'customers', [ + 'orderby' => 'registered_date', + 'order' => 'desc', + ] + ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( + strtotime( $data[0]['date_created'] ) >= strtotime( $data[1]['date_created'] ) + ); + } + + /** + * Test get_item functionality + */ + public function test_get_item() { + wp_set_current_user( $this->seller_id1 ); + + // Create order to establish vendor-customer relationship + $this->factory()->order->set_seller_id( $this->seller_id1 )->create( + [ + 'customer_id' => $this->customers[0], + ] + ); + + // Test valid customer fetch + $response = $this->get_request( "customers/{$this->customers[0]}" ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( $this->customers[0], $data['id'] ); + $this->assertEquals( 'john.doe@example.com', $data['email'] ); + + // Test invalid customer ID + $response = $this->get_request( 'customers/999999' ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Test create_item functionality + */ + public function test_create_item() { + wp_set_current_user( $this->seller_id1 ); + + $test_cases = [ + // Valid customer data + [ + 'data' => [ + 'email' => 'new.customer@example.com', + 'first_name' => 'New', + 'last_name' => 'Customer', + 'username' => 'newcustomer', + 'password' => 'password123', + ], + 'expected_status' => 201, + 'assertions' => function ( $response ) { + $data = $response->get_data(); + $this->assertEquals( 'new.customer@example.com', $data['email'] ); + $this->assertEquals( 'New', $data['first_name'] ); + }, + ], + // Missing required fields + [ + 'data' => [ + 'first_name' => 'Invalid', + ], + 'expected_status' => 400, + 'assertions' => function ( $response ) { + $this->assertEquals( 'rest_missing_callback_param', $response->get_data()['code'] ); + }, + ], + // Invalid email + [ + 'data' => [ + 'email' => 'invalid-email', + 'first_name' => 'Invalid', + 'username' => 'invalid', + ], + 'expected_status' => 400, + 'assertions' => function ( $response ) { + $this->assertEquals( 'customer_invalid_email', $response->get_data()['code'] ); + }, + ], + ]; + + foreach ( $test_cases as $test_case ) { + $response = $this->post_request( 'customers', $test_case['data'] ); + $this->assertEquals( $test_case['expected_status'], $response->get_status() ); + $test_case['assertions']( $response ); + } + } + + /** + * Test update_item functionality + */ + public function test_update_item() { + wp_set_current_user( $this->seller_id1 ); + + // Create order to establish vendor-customer relationship + $this->factory()->order->set_seller_id( $this->seller_id1 )->create( + [ + 'customer_id' => $this->customers[0], + ] + ); + + $test_cases = [ + // Valid update + [ + 'data' => [ + 'first_name' => 'Updated', + 'last_name' => 'Name', + 'email' => 'updated.email@example.com', + ], + 'expected_status' => 200, + 'assertions' => function ( $response ) { + $data = $response->get_data(); + $this->assertEquals( 'Updated', $data['first_name'] ); + $this->assertEquals( 'updated.email@example.com', $data['email'] ); + }, + ], + // Invalid email update + [ + 'data' => [ + 'email' => 'invalid-email', + ], + 'expected_status' => 400, + 'assertions' => function ( $response ) { + $this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] ); + }, + ], + ]; + + foreach ( $test_cases as $test_case ) { + $response = $this->put_request( "customers/{$this->customers[0]}", $test_case['data'] ); + $this->assertEquals( $test_case['expected_status'], $response->get_status() ); + $test_case['assertions']( $response ); + } + } + + /** + * Test batch operations + */ + public function test_batch_operations() { + wp_set_current_user( $this->seller_id1 ); + + // Create vendor-customer relationships + foreach ( $this->customers as $customer_id ) { + $this->factory()->order->set_seller_id( $this->seller_id1 )->create( + [ + 'customer_id' => $customer_id, + ] + ); + } + + $batch_data = [ + 'create' => [ + [ + 'email' => 'batch.new@example.com', + 'first_name' => 'Batch', + 'last_name' => 'New', + 'username' => 'batchnew', + ], + ], + 'update' => [ + [ + 'id' => $this->customers[0], + 'first_name' => 'Batch', + 'last_name' => 'Updated', + ], + ], + 'delete' => [ + $this->customers[1], + ], + ]; + + $response = $this->post_request( 'customers/batch', $batch_data ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + // Verify creation + $this->assertCount( 1, $data['create'] ); + $this->assertEquals( 'batch.new@example.com', $data['create'][0]['email'] ); + + // Verify update + $this->assertCount( 1, $data['update'] ); + $this->assertEquals( 'Batch', $data['update'][0]['first_name'] ); + + // Verify deletion + $this->assertCount( 1, $data['delete'] ); + $this->assertEquals( $this->customers[1], $data['delete'][0]['id'] ); + + // Verify database state + $this->assertFalse( get_user_by( 'id', $this->customers[1] ) ); + } + + /** + * Test search functionality + */ + public function test_search_functionality() { + wp_set_current_user( $this->seller_id1 ); + + // Create orders for test customers + foreach ( $this->customers as $customer_id ) { + $this->factory()->order->set_seller_id( $this->seller_id1 )->create( + [ 'customer_id' => $customer_id ] + ); + } + + $test_cases = [ + // Search by email + [ + 'params' => [ 'search' => 'john.doe@example.com' ], + 'expected_count' => 1, + 'assertions' => function ( $data ) { + $this->assertEquals( 'john.doe@example.com', $data[0]['email'] ); + }, + ], + // Search by partial email + [ + 'params' => [ 'search' => '@example.com' ], + 'expected_count' => 2, + 'assertions' => function ( $data ) { + $this->assertContains( 'john.doe@example.com', wp_list_pluck( $data, 'email' ) ); + $this->assertContains( 'jane.smith@example.com', wp_list_pluck( $data, 'email' ) ); + }, + ], + // Search by name + [ + 'params' => [ 'search' => 'John' ], + 'expected_count' => 1, + 'assertions' => function ( $data ) { + $this->assertEquals( 'John Doe', $data[0]['name'] ); + }, + ], + // Search with exclude + [ + 'params' => [ + 'search' => '@example.com', + 'exclude' => (string) $this->customers[0], + ], + 'expected_count' => 1, + 'assertions' => function ( $data ) { + $this->assertEquals( 'jane.smith@example.com', $data[0]['email'] ); + }, + ], + ]; + + foreach ( $test_cases as $test_case ) { + $response = $this->get_request( 'customers/search', $test_case['params'] ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertCount( $test_case['expected_count'], $data ); + $test_case['assertions']( $data ); + } + } + + /** + * Test vendor specific customer filtering (continued) + */ + public function test_vendor_customer_filtering() { + // Create customers with orders from different vendors + $customer_id = $this->factory()->customer->create( + [ + 'email' => 'multi.vendor@example.com', + 'first_name' => 'Multi', + 'last_name' => 'Vendor', + ] + ); + + // Create orders for both vendors + $this->factory()->order->set_seller_id( $this->seller_id1 )->create( + [ + 'customer_id' => $customer_id, + ] + ); + $this->factory()->order->set_seller_id( $this->seller_id2 )->create( + [ + 'customer_id' => $customer_id, + ] + ); + + // Test as first vendor + wp_set_current_user( $this->seller_id1 ); + $response = $this->get_request( 'customers' ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertContains( $customer_id, wp_list_pluck( $response->get_data(), 'id' ) ); + + // Test as second vendor + wp_set_current_user( $this->seller_id2 ); + $response = $this->get_request( 'customers' ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertContains( $customer_id, wp_list_pluck( $response->get_data(), 'id' ) ); + + // Verify customer details match for both vendors + $response1 = $this->get_request( "customers/$customer_id" ); + $response2 = $this->get_request( "customers/$customer_id" ); + $this->assertEquals( $response1->get_data(), $response2->get_data() ); + } + + /** + * Test customer meta data handling + */ + public function test_customer_meta_data() { + wp_set_current_user( $this->seller_id1 ); + + // Create customer with meta + $customer_data = [ + 'email' => 'meta.test@example.com', + 'first_name' => 'Meta', + 'last_name' => 'Test', + 'username' => 'metatest', + 'meta_data' => [ + [ + 'key' => 'dokan_test_meta', + 'value' => 'test_value', + ], + ], + ]; + + $response = $this->post_request( 'customers', $customer_data ); + $this->assertEquals( 201, $response->get_status() ); + $customer_id = $response->get_data()['id']; + + // Create order to establish vendor relationship + $this->factory()->order->set_seller_id( $this->seller_id1 )->create( + [ + 'customer_id' => $customer_id, + ] + ); + + // Test meta data retrieval + $response = $this->get_request( "customers/$customer_id" ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + + // WooCommerce api does not support meta data retrieval + $this->assertArrayNotHasKey( 'meta_data', $data ); + } + + /** + * Test customer search validation and edge cases + */ + public function test_search_validation_and_edge_cases() { + wp_set_current_user( $this->seller_id1 ); + + $test_cases = [ + // Empty search term + [ + 'params' => [ 'search' => '' ], + 'expected_status' => 400, + 'expected_code' => 'dokan_rest_empty_search', + ], + // Very short search term + [ + 'params' => [ 'search' => 'a' ], + 'expected_status' => 200, + 'assertions' => function ( $response ) { + $this->assertLessThanOrEqual( 20, count( $response->get_data() ) ); + }, + ], + // Multiple excludes + [ + 'params' => [ + 'search' => 'test', + 'exclude' => implode( ',', $this->customers ), + ], + 'expected_status' => 200, + 'assertions' => function ( $response ) { + $excluded_ids = wp_list_pluck( $response->get_data(), 'id' ); + foreach ( $this->customers as $customer_id ) { + $this->assertNotContains( $customer_id, $excluded_ids ); + } + }, + ], + ]; + + foreach ( $test_cases as $test_case ) { + $response = $this->get_request( 'customers/search', $test_case['params'] ); + + if ( isset( $test_case['expected_status'] ) ) { + $this->assertEquals( $test_case['expected_status'], $response->get_status() ); + } + + if ( isset( $test_case['expected_code'] ) ) { + $this->assertEquals( $test_case['expected_code'], $response->get_data()['code'] ); + } + + if ( isset( $test_case['assertions'] ) ) { + $test_case['assertions']( $response ); + } + } + } + + /** + * Test customer role handling + * @throws Exception + */ + public function test_customer_role_handling() { + wp_set_current_user( $this->seller_id1 ); + + // Test creating customer with additional roles + $customer_data = [ + 'email' => 'role.test@example.com', + 'first_name' => 'Role', + 'last_name' => 'Test', + 'username' => 'roletest', + 'password' => 'password123', + 'roles' => [ 'customer', 'subscriber' ], + ]; + + $response = $this->post_request( 'customers', $customer_data ); + $this->assertEquals( 201, $response->get_status() ); + $customer_id = $response->get_data()['id']; + + // Verify roles + $customer = new WC_Customer( $customer_id ); + $customer_role = $customer->get_role(); + $this->assertEquals( 'customer', $customer_role ); + + // Test updating roles + $update_data = [ + 'roles' => [ 'customer' ], + ]; + + $response = $this->put_request( "customers/$customer_id", $update_data ); + $this->assertEquals( 200, $response->get_status() ); + + // Verify updated roles + $customer = new WC_Customer( $customer_id ); + $this->assertEquals( 'customer', $customer->get_role() ); + } + + /** + * Test error responses format + */ + public function test_error_response_format() { + wp_set_current_user( $this->seller_id1 ); + + $test_cases = [ + // Invalid email format + [ + 'endpoint' => 'customers', + 'method' => 'POST', + 'data' => [ + 'email' => 'invalid-email', + 'username' => 'test', + ], + 'assertions' => function ( $response ) { + $data = $response->get_data(); + $this->assertEquals( 400, $response->get_status() ); + $this->assertArrayHasKey( 'code', $data ); + $this->assertArrayHasKey( 'message', $data ); + $this->assertArrayHasKey( 'data', $data ); + }, + ], + // Duplicate username + [ + 'endpoint' => 'customers', + 'method' => 'POST', + 'data' => [ + 'email' => 'unique@example.com', + 'username' => 'admin', + ], + 'assertions' => function ( $response ) { + $data = $response->get_data(); + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'registration-error-username-exists', $data['code'] ); + }, + ], + ]; + + foreach ( $test_cases as $test_case ) { + $response = $this->request( + $test_case['method'], + $test_case['endpoint'], + $test_case['data'] + ); + $test_case['assertions']( $response ); + } + } + + /** + * Helper method for making requests + */ + protected function request( $method, $endpoint, $data = [] ): WP_REST_Response { + $request = new WP_REST_Request( $method, "/$this->namespace/$endpoint" ); + if ( ! empty( $data ) ) { + $request->set_body_params( $data ); + } + return $this->server->dispatch( $request ); + } +} diff --git a/webpack.config.js b/webpack.config.js index 53c5ed26fa..f603004a21 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,6 +9,7 @@ const entryPoint = { // Dokan tailwind css 'dokan-tailwind': './src/tailwind.css', + 'frontend': './src/Dashboard/index.tsx', 'vue-frontend': './src/frontend/main.js', 'vue-admin': './src/admin/main.js', 'vue-bootstrap': './src/utils/Bootstrap.js', @@ -70,6 +71,7 @@ const updatedConfig = { }, resolve: { + ...defaultConfig.resolve, alias: { 'vue$': 'vue/dist/vue.esm.js', '@': path.resolve('./src/'),