diff --git a/base-tailwind.config.js b/base-tailwind.config.js new file mode 100644 index 0000000000..f2ed36a720 --- /dev/null +++ b/base-tailwind.config.js @@ -0,0 +1,98 @@ +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)', + secondary: { + DEFAULT: + 'var(--dokan-button-secondary-background-color, var(--dokan-button-text-color, #ffffff))', + hover: 'var(--dokan-button-secondary-hover-background-color, var(--dokan-button-background-color, #F05025))', + }, + tertiary: { + DEFAULT: + 'var(--dokan-button-tertiary-background-color, #00000000)', + hover: 'var(--dokan-button-tertiary-hover-background-color, var(--dokan-button-text-color, #ffffff))', + }, + }, + }, + }, + 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)', + secondary: { + DEFAULT: + 'var(--dokan-button-secondary-text-color, var(--dokan-button-background-color, #F05025))', + hover: 'var(--dokan-button-secondary-hover-text-color, var(--dokan-button-text-color, #ffffff))', + }, + tertiary: { + DEFAULT: + 'var(--dokan-button-tertiary-text-color, var(--dokan-button-background-color, #F05025))', + hover: 'var(--dokan-button-tertiary-hover-text-color, var(--dokan-button-background-color, #F05025))', + }, + }, + }, + }, + borderColor: { + dokan: { + btn: { + DEFAULT: 'var(--dokan-button-border-color, #F05025)', + hover: 'var(--dokan-button-hover-border-color, #F05025)', + secondary: { + DEFAULT: + 'var(--dokan-button-secondary-border-color, var(--dokan-button-border-color, #F05025))', + hover: 'var(--dokan-button-secondary-hover-border-color, var(--dokan-button-border-color, #F05025))', + }, + tertiary: { + DEFAULT: + 'var(--dokan-button-tertiary-border-color, #00000000)', + hover: 'var(--dokan-button-tertiary-hover-border-color, var(--dokan-button-border-color, #F05025))', + }, + }, + }, + }, + colors: { + dokan: { + sidebar: 'var(--dokan-button-background-color, #1B233B)', + btn: 'var(--dokan-button-background-color, #F05025)', + primary: 'var(--dokan-button-background-color, #F05025)', + secondary: 'var(--dokan-button-secondary-background-color, var(--dokan-button-text-color, #ffffff))', + tertiary: 'var(--dokan-button-tertiary-background-color, #00000000)', + }, + }, + }, + }, + plugins: [ + scopedPreflightStyles( { + isolationStrategy: isolateInsideOfContainer( rootClass, {} ), + } ), + require( '@tailwindcss/typography' ), + require( '@tailwindcss/forms' ), + ], +}; + +export default baseConfig; diff --git a/docs/feature-override/readme.md b/docs/feature-override/readme.md new file mode 100644 index 0000000000..ac63838bcb --- /dev/null +++ b/docs/feature-override/readme.md @@ -0,0 +1,165 @@ +[//]: # (TODO: Update the document with the correct information and hook name.) +# How to define a menu is available in `React` and its `PHP` override information. + +- [Introduction](#introduction) +- [1. Declare a menu is available in `React`.](#1-declare-a-menu-is-available-in-react) + - [Declare `React` menu in **Dokan Lite.**](#declare-react-menu-in-dokan-lite) + - [Declare `React` menu in **Dokan Pro** or **External Plugin**.](#declare-react-menu-in-dokan-pro-or-external-plugin) +- [2. Declare the Override templates for a React route.](#2-declare-the-override-templates-for-a-react-route) + - [Define the override templates for a React route in Dokan Lite.](#define-the-override-templates-for-a-react-route-in-dokan-lite) + - [Define the override templates for a React route in **Dokan Pro** or **External Plugin**.](#define-the-override-templates-for-a-react-route-in-dokan-pro-or-external-plugin) + - [Define the override templates array structure.](#define-the-override-templates-array-structure) +- [Manual Override from External Plugin](#manual-override-from-external-plugin) + +## Introduction +This document will help you to define a menu is available in `React` and its `PHP` override information. + + +## 1. Declare a menu is available in `React`. +To declare a menu is available in `React`, you need to define `route` property during the menu registration. + +### Declare `React` menu in **Dokan Lite**. +```php +// includes/functions-dashboard-navigation.php#L27-L66 +$menus = [ + 'dashboard' => [ + 'title' => __( 'Dashboard', 'dokan-lite' ), + 'icon' => '', + 'url' => dokan_get_navigation_url(), + 'pos' => 10, + 'permission' => 'dokan_view_overview_menu', + 'react_route' => '/', // <-- Define the route here + ], + 'products' => [ + 'title' => __( 'Products', 'dokan-lite' ), + 'icon' => '', + 'url' => dokan_get_navigation_url( 'products' ), + 'pos' => 30, + 'permission' => 'dokan_view_product_menu', + 'react_route' => 'products', // <-- Define the route here + ], + 'orders' => [ + 'title' => __( 'Orders', 'dokan-lite' ), + 'icon' => '', + 'url' => dokan_get_navigation_url( 'orders' ), + 'pos' => 50, + 'permission' => 'dokan_view_order_menu', + 'react_route' => 'orders', // <-- Define the route here + ], + 'withdraw' => [ + 'title' => __( 'Withdraw', 'dokan-lite' ), + 'icon' => '', + 'url' => dokan_get_navigation_url( 'withdraw' ), + 'pos' => 70, + 'permission' => 'dokan_view_withdraw_menu', + ], + 'settings' => [ + 'title' => __( 'Settings', 'dokan-lite' ), + 'icon' => '', + 'url' => dokan_get_navigation_url( 'settings/store' ), + 'pos' => 200, + ], + ]; +``` +In the above example, the `route` property is defined for each menu which we are indicating that the react route is available. +This will be used to determine if the menu is pointing to the react Application or to the Legacy PHP Route. + +The `route` property should be the same as the route defined in the `React` application in Router Array. + +It is important to note that the `route` property should be defined for the menu which is available in the `React` application. +If the `route` key is not defined for the menu, then the menu will be considered as a legacy menu and will be rendered using the PHP template. + + +### Declare `React` menu in **Dokan Pro** or **External Plugin**. + +```php +add_filter( 'dokan_get_dashboard_nav', function ( $menus ) { + $menus['products'] = [ + 'title' => __( 'Products', 'dokan-lite' ), + 'icon' => '', + 'url' => dokan_get_navigation_url( 'products' ), + 'pos' => 30, + 'permission' => 'dokan_view_product_menu', + 'react_route' => 'products', // <-- Define the route here + ]; + + return $menus; +} ); +``` + + +## 2. Declare the Override templates for a React route. +If you are writing a new feature or modifying an existing feature in the `React` application, you do not need to define the override templates for the `React` route. +But if you are modifying or migrating an existing feature written in PHP to the `React` application and you want that if some of the PHP template is overridden by the existing PHP template then the legacy PHP page will be displayed, then you need to define the override templates for the `React` route. +### Define the override templates for a React route in Dokan Lite. +```php +// VendorNavMenuChecker.php#L13-L26 +protected array $template_dependencies = [ + '' => [ + [ 'slug' => 'dashboard/dashboard' ], + [ 'slug' => 'dashboard/orders-widget' ], + [ 'slug' => 'dashboard/products-widget' ], + ], + 'products' => [ + [ 'slug' => 'products/products' ], + [ + 'slug' => 'products/products', + 'name' => 'listing', + ], + ], + ]; +``` + +In the above example, the `template_dependencies` property is defined for each route which we are indicating that the override templates are available for the route. This will be used to determine if the override templates are available for the route or not. + +### Define the override templates for a React route in **Dokan Pro** or **External Plugin**. +From Dokan Pro, we can add dependencies by using the filter `dokan_get_dashboard_nav_template_dependency`. + +```php +add_filter( 'dokan_get_dashboard_nav_template_dependency', function ( array $dependencies ) { + $dependencies['products'] = [ + [ 'slug' => 'products/products' ], + [ + 'slug' => 'products/products', + 'name' => 'listing', + ], + ]; + + return $dependencies; +} ); +``` +### Define the override templates array structure. +```php +/** +* @var array $template_dependencies Array of template dependencies for the route. + */ + +[ + 'route_name' => [ + [ + 'slug' => 'template-slug', + 'name' => 'template-name' // (Optional), + 'args' = [] // (Optional) + ], + ] +] +``` + +- **Slug:** The slug of the template file which is used to display the php content. +- **Name:** The name of the template file which is used to display the php content. (Optional) +- **Args:** The arguments which are passed to the template file in `dokan_get_template_part()` function. (Optional) + +## Manual Override from External Plugin +If you did not override any of the template file directly but you have override functionality by using `add_action` or `add_filter` then you can forcefully override the php route and template rendering for the route by using the `dokan_is_dashboard_nav_dependency_cleared` filter hook. + +```php + +add_filter( 'dokan_is_dashboard_nav_dependency_resolved', function ( $is_cleared, $route ) { + if ( 'products' === $route ) { + return false; + } + + return $is_cleared; +}, 10, 2 ); + +``` diff --git a/docs/filters-hooks/README.md b/docs/filters-hooks/README.md new file mode 100644 index 0000000000..179e1d36b9 --- /dev/null +++ b/docs/filters-hooks/README.md @@ -0,0 +1,97 @@ +# Guidelines for Creating Filter Documentation + +## File Structure +1. Create markdown file in `/docs/filters-hooks/` directory +2. Name the file based on the feature area (e.g., `layout-filters.md`, `product-filters.md`) + +## Documentation Template +```markdown +# [Feature Name] Component Filters + +## Overview +Brief one-line description of the feature area. + +## Filter Reference + +### [Component Name] (`path/to/component`) +| Filter Name | Arguments | Return | Description | +|------------|-----------|--------|-------------| +| `filter-name` | argument types | return type | Brief description | + +## Basic Implementation +```javascript +wp.hooks.addFilter( + 'filter-name', + 'namespace', + function(value) { + return modifiedValue; + } +); +``` + +## Typescript Interface +```typescript +interface FilterCallback { + (value: ValueType): ReturnType; +} +``` +``` + +## Example Implementation +Here's how your `product-filters.md` might look: + +```markdown +# Product Component Filters + +## Overview +Filter hooks available in product management features. + +## Filter Reference + +### Product Form (`/components/products/Form.tsx`) +| Filter Name | Arguments | Return | Description | +|------------|-----------|--------|-------------| +| `dokan-product-form-fields` | `fields: FormField[]` | `FormField[]` | Modify product form fields | +| `dokan-product-validation-rules` | `rules: ValidationRule[]` | `ValidationRule[]` | Modify validation rules | + +## Basic Implementation +```javascript +wp.hooks.addFilter( + 'dokan-product-form-fields', + 'my-plugin', + function(fields) { + return [...fields, newField]; + } +); +``` + +## Typescript Interface +```typescript +interface FormField { + name: string; + type: string; + // other properties +} +``` +``` + +## Key Points to Remember +1. Keep filter names consistent: `dokan-[feature]-[action]` +2. Document all arguments passed to filter +3. Specify return type clearly +4. Include TypeScript interfaces when needed +5. Add basic implementation example + +## Naming Conventions +- Use kebab-case for filter names +- Keep descriptions concise +- Be specific about arguments and return types +- Use consistent naming across components + +## Documentation Checklist +- [ ] Created file in correct location +- [ ] Used correct naming format for filters +- [ ] Documented all arguments +- [ ] Specified return types +- [ ] Added implementation example +- [ ] Included TypeScript interfaces if needed diff --git a/docs/filters-hooks/layout-filters.md b/docs/filters-hooks/layout-filters.md new file mode 100644 index 0000000000..dd2408ad2d --- /dev/null +++ b/docs/filters-hooks/layout-filters.md @@ -0,0 +1,44 @@ +# Layout Components Filters + +## Overview +Extension points using WordPress Filters in Dokan's layout components. + +## Filter Reference + +### Header (`/src/layout/Header.tsx`) +| Filter Name | Arguments | Return | Description | +|------------|-----------|--------|-------------| +| `dokan-vendor-dashboard-header-title` | `title: string` | `string` | Modify dashboard header title | + +## Basic Implementation + +```javascript +// Add filter in your plugin +wp.hooks.addFilter( + 'dokan-vendor-dashboard-header-title', // Filter name + 'my-plugin-name', // Namespace + function(title) { // Callback + return 'Modified Title'; // Return modified value + } +); +``` + +## Filter Priority +```javascript +// Add filter with priority +wp.hooks.addFilter( + 'dokan-vendor-dashboard-header-title', + 'my-plugin-name', + function(title) { + return 'Modified Title'; + }, + 10 // Priority (default: 10) +); +``` + +## Typescript Interface +```typescript +interface FilterCallback { + (title: string): string; +} +``` diff --git a/docs/frontend/components.md b/docs/frontend/components.md new file mode 100644 index 0000000000..57fdb370af --- /dev/null +++ b/docs/frontend/components.md @@ -0,0 +1,87 @@ +# Dokan Components + +## Overview + +`Dokan` provides a set of reusable `components` that can be used across both `Free` and `Pro` versions. This documentation explains how to properly set up and use these `components` in your project. + +## Important Dependencies + +For both `Dokan Free and Pro` versions, we must register the `dokan-react-components` dependency when using `global` components. + +### Implementation Example + +```php +// Register scripts with dokan-react-components dependency +$script_assets = 'add_your_script_assets_path_here'; + +if (file_exists($script_assets)) { + $vendor_asset = require $script_assets; + $version = $vendor_asset['version'] ?? ''; + + // Add dokan-react-components as a dependency + $component_handle = 'dokan-react-components'; + $dependencies = $vendor_asset['dependencies'] ?? []; + $dependencies[] = $component_handle; + + // Register Script + wp_register_script( + 'handler-name', + 'path_to_your_script_file', + $dependencies, + $version, + true + ); + + // Register Style + wp_register_style( + 'handler-name', + 'path_to_your_style_file', + [ $component_handle ], + $version + ); +} +``` + +## Component Access + +For `dokan free & premium version`, we can import the components via `@dokan/components`: + +```js +import { DataViews } from '@dokan/components'; +``` + +For external `plugins`, we must include the `dokan-react-components` as scripts dependency and the `@dokan/components` should be introduced as an external resource configuration to resolve the path via `webpack`: + +```js +externals: { + '@dokan/components': 'dokan.components', + ... +}, +``` + +## Adding Global Components + +### File Structure + +``` +|____ src/ +| |___ components/ +| | |___ index.tsx # Main export file +| | |___ dataviews/ # Existing component +| | |___ your-component/ # Your new component directory +| | | |___ index.tsx +| | | |___ style.scss +| | | +| | |___ Other Files +| | +| |___ Other Files +| +|____ Other Files +``` + +**Finally,** we need to export the new `component` from the `src/components/index.tsx` file. Then, we can import the new component from `@dokan/components` in `dokan pro` version. + +```ts +export { default as DataViews } from './dataviews/DataViewTable'; +export { default as ComponentName } from './YourComponent'; +``` diff --git a/docs/frontend/dataviews.md b/docs/frontend/dataviews.md new file mode 100644 index 0000000000..781d9e4668 --- /dev/null +++ b/docs/frontend/dataviews.md @@ -0,0 +1,796 @@ +# Dokan DataView Table Documentation + +- [Introduction](#introduction) +- [Quick Overview](#quick-overview) +- [1. Features](#1-role-specifix-controllers) + - [Data Search](#data-search) + - [Data Sort](#data-sort) + - [Data Filter](#data-filter) + - [Data Pagination](#data-pagination) + - [Data Bulk Action](#data-bulk-action) +- [2. Data Properties](#2-data-properties) + - [data](#data) + - [namespace](#namespace) + - [responsive](#responsive) + - [fields](#fields) + - [actions](#actions) + - [view](#view) + - [paginationInfo](#pagination-info) + - [onChangeView](#on-change-view) + - [selection](#selection) + - [onChangeSelection](#on-change-selection) + - [defaultLayouts](#default-layouts) + - [getItemId](#get-item-id) + - [header](#header) + +## Introduction +This documentation describes the usage of the dokan `DataViewTable` components in vendor dashboard. +Also, an example component are designed to interact with WordPress REST APIs and provide a data management interface for vendor dashboard. + +## Quick Overview + +### Step 1: Initiate a route for the DataViewTable component + +To use the `DataViewTable` component, we need to register the necessary assets for handling route in `Dokan`. Make sure to include the necessary dependencies while registering the component. + +**Important:** For both **Dokan Free and Pro** versions, you must add the dependency named **dokan-react-components** to the scripts and styles where global components are utilized. + +**Example:** + +```js +$script_assets = 'add your script assets path here'; + +if ( file_exists( $script_assets ) ) { + $vendor_asset = require $script_assets; + $version = $vendor_asset['version'] ?? ''; + + // Add dokan-react-components as a dependency for the vendor script. + $component_handle = 'dokan-react-components'; + $dependencies = $vendor_asset['dependencies'] ?? []; + $dependencies[] = $component_handle; + + wp_register_script( + 'handler-name', + 'path to your script file', + $dependencies, + $version, + true + ); + + wp_register_style( + 'handler-name', + 'path to your style file', + [ $component_handle ], + $version +); +} +``` + +### Step 2: Initiate a route for the DataViewTable component. + +```js +import domReady from '@wordpress/dom-ready'; + +domReady(() => { + wp.hooks.addFilter( + 'dokan-dashboard-routes', + 'dokan-data-view-table', + ( routes ) => { + routes.push( { + id: 'dokan-data-view-table', + title: __( 'Dokan Data Views', 'dokan' ), + element: WPostsDataView, + path: '/dataviews', + exact: true, + order: 10, + parent: '', + } ); + + return routes; + } + ); +}); +``` + +### Step 3: Create a DataViewTable component. + +We can use the `DataViews` component from the `@dokan/components` package. `(for both free & premium version)` + +All the (free version) global components are available in `@dokan/components` package. + +```js +import { addQueryArgs } from "@wordpress/url"; +import { __, sprintf } from '@wordpress/i18n'; +import { useEffect, useState } from "@wordpress/element"; +import { + __experimentalHStack as HStack, + __experimentalText as Text, + __experimentalVStack as VStack, Button +} from "@wordpress/components"; + +// We will import the `DataViews` component from the @dokan/components package. +import { DataViews } from '@dokan/components'; + +const WPostsDataView = ({ navigate }) => { + const [ data, setData ] = useState([]); + const [ isLoading, setIsLoading ] = useState(true); + const [ totalPosts, setTotalPosts ] = useState(0); + + // Define the available post statuses which will be use for filter. + const POST_STATUSES = [ + { value: 'publish', label: __( 'Published', 'dokan' ) }, + { value: 'draft', label: __( 'Draft', 'dokan' ) }, + { value: 'pending', label: __( 'Pending Review', 'dokan' ) }, + { value: 'private', label: __( 'Private', 'dokan' ) }, + { value: 'trash', label: __( 'Trash', 'dokan' ) } + ]; + + // Define fields for handle the table columns. + const fields = [ + { + id: 'post_id', + label: __( 'Post ID', 'dokan' ), + render: ({ item }) => ( +
+ navigate(`/posts/${item.id}`)} + >{ item.title.rendered } +
+ ), + enableSorting: true, + }, + { + id: 'title', + label: __( 'Title', 'dokan' ), + enableGlobalSearch: true, + enableSorting: true, + render: ({ item }) => item.title.rendered, + }, + { + id: 'post_status', + label: __( 'Status', 'dokan' ), + enableGlobalSearch: true, + getValue: ({ item }) => // modify value if needed when filter or sorting applied + POST_STATUSES.find(({ value }) => value === item.status)?.value ?? + item.status, + elements: POST_STATUSES, + filterBy: { + operators: [ 'isAny', 'isNone', 'isAll', 'isNotAll' ], + }, + render: ({ item }) => capitalizeFirstLetter( item.status ), + }, + { + id: 'author', + label: 'Author', + enableGlobalSearch: true, + render: ({ item }) => item.author_name, + }, + { + id: 'date', + label: 'Date', + enableGlobalSearch: true, + render: ({ item }) => new Date(item.date).toLocaleDateString(), + }, + ]; + + // Define necessary actions for the table rows. + const actions = [ + { + id: 'post-edit', + label: 'Edit', + icon: 'edit', + isPrimary: true, + callback: (posts) => { + const post = posts[0]; + navigate(`/posts/${post.id}/edit`); + }, + }, + { + id: 'post-delete', + label: 'Delete', + icon: 'trash', + isPrimary: true, + supportsBulk: true, + RenderModal: ({ items: [item], closeModal }) => ( + + + { sprintf( __( 'Are you sure you want to delete "%s"?', 'dokan' ), item.title.rendered ) } + + + + + + + ), + }, + ]; + + // Set for handle bulk selection. + const [selection, setSelection] = useState([]); + + // Use for capitalize the first letter of a word, you can user as per feature requirement. + const capitalizeFirstLetter = (word) => { + if (!word) return ''; + return word.charAt(0).toUpperCase() + word.slice(1); + }; + + // Set data view default layout. We can hide priew by not passing the layout prop. + const defaultLayouts = { + table: {}, + grid: {}, + list: {}, + density: 'comfortable', // Use density pre-defined values: comfortable, compact, cozy + }; + + // Set view state for handle the table view, we can take decision based on the view state. + // We can handle the pagination, search, sort, layout, fields etc. + const [view, setView] = useState({ + perPage: 10, + page: 1, + search: '', + type: 'table', + titleField: 'post_id', + status: 'publish,pending,draft', + layout: { ...defaultLayouts }, + fields: fields.map(field => field.id !== 'post_id' ? field.id : ''), // we can ignore the representing title field + }); + + // Handle data fetching from the server. + const fetchPosts = async () => { + setIsLoading(true); + try { + // Set query arguments for the post fetching. + const queryArgs = { + per_page: view?.perPage ?? 10, + page: view?.page ?? 1, + search: view?.search ?? '', + status: view?.status ?? 'publish,pending,draft', + _embed: true, + } + + // Set sorting arguments for the post order by. Like: title, date, author etc. + if ( !! view?.sort?.field ) { + queryArgs.orderby = view?.sort?.field ?? 'title'; + } + + // Set sorting arguments for the post order. Like: asc, desc + if ( !! view?.sort?.direction ) { + queryArgs.order = view?.sort?.direction ?? 'desc'; + } + + // Set filter arguments for the post status. Like: publish, draft, pending etc. + if ( !! view?.filters ) { + view?.filters?.forEach( filter => { + if ( filter.field === 'post_status' && filter.operator === 'isAny' ) { + queryArgs.status = filter?.value?.join(','); + } + } ) + } + + // Fetch data from the REST API using the query arguments. + const response = await fetch( + addQueryArgs('/wp-json/wp/v2/posts', { ...queryArgs }), + { + headers: { + 'X-WP-Nonce': wpApiSettings.nonce, + 'Content-Type': 'application/json' + }, + credentials: 'include' + } + ); + + const posts = await response.json(); + const totalPosts = parseInt(response.headers.get('X-WP-Total')); // Get total posts count from the header. + + const enhancedPosts = posts.map(post => ({ + ...post, + author_name: post._embedded?.author?.[0]?.name ?? 'Unknown' + })); + + setTotalPosts(totalPosts); // Set total posts count. + setData(enhancedPosts); + } catch (error) { + console.error('Error fetching posts:', error); + } finally { + setIsLoading(false); + } + }; + + // Fetch posts when view changes + useEffect(() => { + fetchPosts(); + }, [view]); + + return ( + item.id} + onChangeView={setView} + paginationInfo={{ + // Set pagination information for the table. + totalItems: totalPosts, + totalPages: Math.ceil( totalPosts / view.perPage ), + }} + view={view} + selection={selection} + onChangeSelection={setSelection} + actions={actions} + isLoading={isLoading} + // Set header for the DataViewTable component. + header={ + + } + /> + ); +}; + +export default WPostsDataView; +``` + +## Features of DataViewTable + +### Data Search +To enable search filtering, set the `enableGlobalSearch` property to `true` for the desired fields. +Set an initial `search` property in the `view` state to manage the search query. +In the `data fetching` method, include the `search` property in the query arguments. + +**N:B:-** Need to handle the search query in the server-side REST API. + +#### ~ Set `enableGlobalSearch` property to `true` for the desired fields. +```js +const fields = [ + { + id: 'title', + label: __( 'Title', 'dokan' ), + enableGlobalSearch: true, // Enable search for this field + render: ({ item }) => item.title.rendered, + }, + // Add additional fields as needed +]; +``` + +#### ~ Set search initial state to `view`. +```js +const [ view, setView ] = useState({ + search: '', // Initialize search query + ... // Add additional view properties +}); +``` + +#### ~ Set search to `queryArgs` for fetching searchable data. +```js +// Set query arguments for the post fetching. +const queryArgs = { + search: view.search, + ... // Add additional view properties +}; + +// apifetch data from the REST API using the prepared queryArgs. +``` + +### Data Sort +To enable `sorting` functionality for specific columns in the `DataViewTable`, you need to configure both the field definitions and handle the sorting parameters in your data fetching logic. + +`Enable sorting for fields:` by setting the `enableSorting` property to `true` for the desired fields. +`Handle sort in data fetching:` The DataViewTable component manages sorting state through the `view` object. Include the sorting parameters in your API query. When sorting is triggered, `view` updates the following properties: +`getValue (optional):` It returns the value of a field. It can be used to modify the value when filter or sorting is applied. + +`view.sort.field:` The field ID being sorted +`view.sort.direction:` The sort direction ('asc' or 'desc') + +**N:B:-** Need to handle the sort query in the server-side REST API. + +#### ~ Set `enableSorting` property to `true` for the desired fields. +```js +const fields = [ + { + id: 'title', + label: __( 'Title', 'dokan' ), + enableSorting: true, // Enable sorting for this field. As default sorting is enabled. We can make it false if we don't want to sort. + render: ({ item }) => item.title.rendered, + }, + // Add additional fields as needed +]; +``` + +#### ~ Set sort initial state to `view`. +```js +// We can set the initial sort field and direction in the view state if needed. +const [ view, setView ] = useState({ + sort: { + field: 'title', // Initial sort field + direction: 'desc', // Initial sort direction + }, + ... // Add additional view properties +}); +``` + +#### ~ Set sortable queries to `queryArgs` for fetching sortable data. +```js +// Set query arguments for the post fetching. +const queryArgs = { ...args } + +// Set sorting arguments for the post order by. Like: title, date, author etc. +if ( !! view?.sort?.field ) { + queryArgs.orderby = view?.sort?.field ?? 'title'; +} + +// Set sorting arguments for the post order. Like: asc, desc +if ( !! view?.sort?.direction ) { + queryArgs.order = view?.sort?.direction ?? 'desc'; +} + +// apifetch data from the REST API using the prepared queryArgs. +``` + +### Output: + +Screenshot-2024-12-26-at-11-25-33-AM + +### Data Filter +To enable `filtering` functionality for specific columns in the `DataViewTable`, configure filter options in your field definitions and handle filter parameters in the data fetching logic. + +`Enable filter for fields:` by setting the `elements` & `filterBy` property for the desired fields. + +**elements:** List of valid values for filtering field. If provided, it creates a DataView's filter for the field. +**filterBy:** Configuration of the filters. Setup conditions for filtering the field. Like: `isAny`, `isNone`, `isAll`, `isNotAll` etc. +[Get More Details](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#filterby) +**getValue (optional):** It returns the value of a field. It can be used to modify the value when filter or sorting is applied. + +`Handle filter in data fetching:` The DataViewTable component manages filtering state through the `view` object. Include the filtering parameters in your API query. When filter is triggered, `view` updates the following properties: +`view.filters:` It returns the array of filter object. Each filter object contains the `field` & `operator` property. We can handle the data filtering based on the filter object. + +**N:B:-** Need to handle the filter query in the server-side REST API. + +#### ~ Configure filter options for fields. +```js +// Define the available options which will be use for filter. +const POST_STATUSES = [ + { value: 'publish', label: __( 'Published', 'dokan' ) }, + // ... rest of the option list. +]; + +const fields = [ + { + id: 'post_status', + label: __( 'Status', 'dokan' ), + getValue: ({ item }) => // modify value if needed when filter applied + POST_STATUSES.find(({ value }) => value === item.status )?.value ?? + item.status, + elements: POST_STATUSES, // Define available filter options + filterBy: { + operators: [ 'isAny', 'isNone', 'isAll', 'isNotAll' ], // Define filter operators + }, + render: ({ item }) => item.status, + }, + // Add additional fields as needed +]; +``` + +#### ~ Initiate filtering state to `view`. +```js +// We can set the initial sort field and direction in the view state if needed. +const [ view, setView ] = useState({ + status: 'publish,pending,draft', // Initial filter status + ... // Add additional view properties +}); +``` + +#### ~ Set queries to `queryArgs` for fetching filtered data. +```js +// Set query arguments for the post fetching. +const queryArgs = { ...args } + +// Set filter arguments for the post status. +if ( !! view?.filters ) { + view?.filters?.forEach( filter => { + if ( filter.field === 'post_status' && filter.operator === 'isAny' ) { // here the filter.field value will be the field id what we assigned in the fields element. + queryArgs.status = filter?.value?.join( ',' ); + } + }) +} + +// Fetch data using the prepared queryArgs. +``` + +### Data Pagination +The `DataViewTable` component includes built-in pagination functionality. Configure pagination parameters and handle them in your data fetching logic. + +We need to control `paginationInfo` props for calculate `total pagination item & pages`. Also, we need to set `perPage` & `page` in the `view` state for manage the pagination. + +#### ~ Set pagination state in view. +```js +const [ view, setView ] = useState({ + perPage: 10, // Items per page + page: 1, // Current page + // ... other view properties +}); +``` + +#### ~ Configure pagination info in dataviews +```js + +``` + +#### ~ Set paginated query to `queryArgs` for fetching data. +```js +// Set query arguments for the post fetching. +const queryArgs = { + per_page: view?.perPage ?? 10, + page: view?.page ?? 1, + // ... other query args +} + +// apifetch data from the REST API using the prepared queryArgs. +``` + +### Data Bulk Action +Enable bulk actions on selected items in the `DataViewTable`. We need to configure action definitions and handle bulk operations. + +We need to enable `supportsBulk` props in table `actions` for handle the bulk actions. Also, we need to set `selection` & `onChangeSelection` in the `view` state for manage the bulk selection. We can set `isPrimary` for handling action shortly in `actions` column. + +#### ~ Define actions with bulk support +```js +const actions = [ + { + id: 'post-delete', + label: 'Delete', + icon: 'trash', + isPrimary: true, + supportsBulk: true, // Enable bulk action support + RenderModal: ({ items: [item], closeModal }) => { + // render modal or popup for bulk action confirmation & handle the action + // re-fetch data after action completion for update the table + }, + } +]; +``` + +#### ~ Handle selection state for bulk selections. +```js +// Set up selection state. +const [ selection, setSelection ] = useState( [] ); + +// Pass selection props to DataViews. + +``` + +#### ~ Set paginated query to `queryArgs` for fetching data. +```js +// Set query arguments for the post fetching. +const queryArgs = { + per_page: view?.perPage ?? 10, + page: view?.page ?? 1, + // ... other query args +} + +// apifetch data from the REST API using the prepared queryArgs. +``` + +## Data Properties + +### data + +The data storage of the `DataViewTable` component. It should be `one-dimensional` array of objects. +Each record should have an `id` that identifies them uniquely. If they don’t, the consumer should provide the `getItemId` property to `DataViews`: a function that returns an unique identifier for the record. + +### namespace + +The `namespace` is a unique identifier for the `DataViewTable` table component. We introduce filtering options using that identifier. +If we can want to modify a table from the outside of module or feature, we can use it. + + const applyFiltersToTableElements = (namespace: string, elementName: string, element) => { + return wp.hooks.applyFilters( `${namespace}.${elementName}`, element ); + }; + + const filteredProps = { + ...props, + data: applyFiltersToTableElements( props?.namespace, 'data', props.data ), + view: applyFiltersToTableElements( props?.namespace, 'view', props.view ), + fields: applyFiltersToTableElements( props?.namespace, 'fields', props.fields ), + actions: applyFiltersToTableElements( props?.namespace, 'actions', props.actions ), + }; + +
+ {/* Before dokan data table rendered slot */} + + + {/* After dokan data table rendered slot */} + +
+ +### responsive + +The `responsive` property controls the `responsiveness` of the `DataViewTable` component. When set to `true`, the table will be responsive and will display `List` view from the `768px` devices. In the large devices, it will display the `Table` view. As default, the `responsive` property is set to `true`. If we want to use the default responsive view then don't need to pass the `responsive` property. `Responsive` property is optional. + + + +### fields + +The `fields` describe the visible items for each record in the dataset and how they behave (how to sort them, display them, etc.). + +**id (string) :** The unique identifier of the field. We can use this identifier to handle the field in the `DataViewTable`. + +**label (string) :** The field’s name. This will be used across the UI. + +**enableGlobalSearch (bool) :** Enable searching for each field. Defaults to `false`. + +**enableSorting (bool) :** Enable sorting (`asc/desc`) options for each field. Defaults to `true`. + +**elements (array) :** Enable filtering for current field. The value should be an array. There have another property `filterBy` for handle the filter condition. + +**filterBy (bool) :** We can configure the filter condition for the field. It should be an array. The available operators are: `isAny`, `isNone`, `isAll`, `isNotAll`. [More Details](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#filterby) + +**getValue (ReactNode):** It returns the value of a field. This property can be used to modify the value when sorting or filtering is applied. + +**render (ReactNode):** The function that renders the field. It receives the item as a parameter and should return a ReactNode. If need to handle the field value before render, we can use this property. + +### actions + +The `actions` define the available operations that can be performed on records in the dataset. Each action can be configured for individual or bulk operations. + +**id (string) :** The unique identifier for the action. Used to handle and identify specific `actions` in the `DataViewTable`. + +**label (string) :** The action's display name. This text appears in the UI, typically in the tooltip of action button. + +**icon (string) :** The icon identifier to be displayed alongside the action. We can use `dashicon` classes for icons. Also, we can use ReactNode. + +**isPrimary (bool) :** When set to `true`, the action appears directly in the actions column of the table. If false, the action is placed in a dropdown menu. Defaults to `false`. + +**supportsBulk (bool) :** Enable bulk operation support for this action. When `true`, the action can be performed on multiple items. Defaults to `false`. + +**callback (function) :** The function to be executed when the action is triggered. Receives the selected item(s) as a parameter. +When we need to click & redirect action to another page, we can use this property. When we need to click \& redirect action to another page, we can use this property. However, if we use the `RenderModal` property, then the `callback` property will not work. + +**RenderModal (ReactNode) :** A React component that renders a confirmation modal or custom UI for the action. Receives props: + + items: Array of selected items + closeModal: Function to close the modal + +### view + +The `view` object controls the current state of the `DataViewTable` component. It manages various aspects like `pagination`, `search`, `sorting`, `filtering`, `layout` settings & more. + +**perPage (number) :** Number of items to display per page. Used for pagination control. + +**page (number) :** Current active page number. Used in conjunction with `perPage` for `pagination`. + +**search (string) :** Current search query string for global search functionality. + +**type (string) :** The current `view` type. Available options: `table` | `grid` | `list`. Defaults to `table`. + + 'table': Display data in tabular format + 'grid': Display data in grid layout + 'list': Display data in list format + +**titleField (string) :** Specifies which field should be used as the main `title` in different view types. + +**descriptionField (string) :** Specifies which field should be used as the main `description` in different view types. + +**showMedia (string) :** Specifies which field should be used as the main `description` in different view types. + +**layout (object) :** Configuration for the layout options: + + 'table': Display data in tabular format + 'grid': Display data in grid layout + 'list': Display data in list format + +**fields (array) :** List of visible field IDs. Can be used to control which columns are displayed. +We can take decision before the field rendered. Specially, we can ignore the representing field/fields. + +**sort (object) :** Handle sorting configuration: + + sort: { + field: 'title', // Field ID to sort by + direction: 'desc' // Sort direction ('asc' or 'desc') + } + +**filters (array) :** Array of active filters: + + filters: [ + { + field: 'post_status', // Field ID to filter by + operator: 'isAny', // Filter operator + value: ['publish'] // Filter value + } + ] + +### paginationInfo + +The `paginationInfo` object provides essential `pagination metadata` for the `DataViewTable` component. It helps manage and display `pagination controls` by defining the total number of items and pages. + +**totalItems (number) :** The total number of items in the dataset across all pages. Used to: + + 1. Calculate total number of pages + 2. Display item count information (e.g., "Showing 1-10 of 100") + 3. Enable/disable pagination controls + +**totalPages (number) :** Total number of available pages based on `totalItems` and items per page. Used to: + + 1. Control pagination navigation limits + 2. Enable/disable next/previous buttons + 3. Display page number controls + +**Example Usage :** + + + + +### onChangeView + +The `onChangeView` is a `callback function` that handles changes to the `view` state of the `DataViewTable` component. This function is triggered whenever there's a change in `view` settings like `pagination`, `sorting`, `filtering`, `layout` changes, etc. + +### selection + +The selection property maintains the state of `selected items` in the `DataViewTable`. It's an array, which is containing the bulk selected items. + +### onChangeSelection + +A `callback function` triggered when `item selection changes` in the table. Receives an array of `selected item IDs`. + +### defaultLayouts + +Configures the default layout options for the `DataViewTable`. Defines available view types and their settings. Properties: `table`, `grid`, `list`. Also, we can set the `density` for the table view. + +### getItemId + +A `callback function` that returns the unique identifier for a record. It's used when the record doesn't have an `id` field. Receives the record as a parameter and should return a unique identifier. + +### header + +The `header` property allows you to add a custom header to the `DataViewTable` component. It can be a ReactNode or a custom component. Used to display additional content or actions above the table. diff --git a/docs/frontend/dokan-modal.md b/docs/frontend/dokan-modal.md new file mode 100644 index 0000000000..59c07645ae --- /dev/null +++ b/docs/frontend/dokan-modal.md @@ -0,0 +1,88 @@ +# DokanModal Component + +- [Introduction](#introduction) +- [Component Dependency](#component-dependency) +- [Quick Overview](#quick-overview) +- [Features of DokanModal Component](#features-of-DokanModal-component) + - [Dynamic Content Elements](#dynamic-content-elements) + - [Custom Styling Support](#custom-styling-support) + - [Unique Namespace](#unique-namespace) +- [Properties](#properties) + +## Introduction +The `DokanModal` component provides a flexible modal dialog system for `Dokan`. It supports `confirmation dialogs`, `custom content`, and `customizable headers and footers`. +Each modal instance requires a unique `namespace` for proper identification and styling. + +## Component Dependency +For both `Dokan Free and Pro` versions, we must register the `dokan-react-components` dependency when using `global` components. + +## Quick Overview + +```tsx +import { __ } from '@wordpress/i18n'; +import { DokanModal } from '@dokan/components'; + +const QuickConfirm = () => { + const [ isOpen, setIsOpen ] = useState( false ); + + const handleConfirm = () => { + // Handle confirm action + setIsOpen( false ); + }; + + return ( + handleConfirm() } + onClose={ () => setIsOpen( false ) } + confirmationTitle={ __( 'Quick Confirmation', 'dokan-lite' ) } + confirmationDescription={ __( 'Are you sure?', 'dokan-lite' ) } + /> + ); +} + +export default QuickConfirm; +``` + +## Features of DokanModal Component +The `DokanModal` component offers several features for customization and flexibility: + +### Dynamic Content Elements + +#### 1. Dynamic Content Elements + - Flexible content structure through dynamic props: + - `dialogTitle`: Custom title for the modal dialog. + - `dialogIcon`: Custom icon element besides the modal contents. + - `dialogHeader`: Customizable header for the modal component. + - `dialogContent`: Custom content for the modal component. + - `dialogFooter`: Custom button/footer contents for the modal component. + + - Custom Styling Support: + - UI customizable modal using className props. + + - Unique Namespace: + - Required unique namespace for each modal instance + - Namespace is used for proper identification of the modal component. + +#### 2. All elements can be passed dynamically during component declaration + +## Component Properties + +| Property | Type | Required | Default | Description | +|---------------------------|---------------|-----------|------------------------|-----------------------------------------------------------------| +| `isOpen` | `boolean` | Yes | - | Controls modal visibility | +| `onClose` | `() => void` | Yes | - | Callback function when modal closes | +| `namespace` | `string` | Yes | - | Unique identifier for the modal (used for modal identification) | +| `className` | `string` | No | - | Additional CSS classes for modal customization | +| `onConfirm` | `() => void` | Yes | - | Callback function when confirm button is clicked | +| `dialogTitle` | `string` | No | `Confirmation Dialog` | Title text for the modal | +| `cancelButtonText` | `string` | No | `Cancel` | Text for the cancel button | +| `confirmButtonText` | `string` | No | `Confirm` | Text for the confirm button | +| `confirmationTitle` | `string` | No | `Delete Confirmation` | Title for confirmation modals | +| `confirmationDescription` | `string` | No | - | Description text for confirmation modals | +| `dialogIcon` | `JSX.Element` | No | - | Custom icon element for the modal header | +| `dialogHeader` | `JSX.Element` | No | - | Custom header component | +| `dialogContent` | `JSX.Element` | No | - | Custom content component | +| `dialogFooter` | `JSX.Element` | No | - | Custom footer component | +| `loading` | `boolean` | No | false | Controls loading state of the modal | diff --git a/docs/frontend/filter.md b/docs/frontend/filter.md new file mode 100644 index 0000000000..3e1b677450 --- /dev/null +++ b/docs/frontend/filter.md @@ -0,0 +1,196 @@ +# Filter Component + +The Filter component provides a standardized way to implement filtering functionality across your application. It creates a consistent user experience by managing filter fields, filter triggers, and reset functionality. + +## Features + +- Unified interface for multiple filter fields +- Configurable filter and reset buttons +- Namespace support for unique identification +- Flexible field composition + +## Installation + +For `dokan free & premium version`, we can import the `Filter` components from `@dokan/components` package: + +```jsx +import { Filter } from '@dokan/components'; +``` + +## Usage + +```jsx +, + + ]} + onFilter={handleFilter} + onReset={clearFilter} + showFilter={true} + showReset={true} + namespace="vendor_subscription" +/> +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `namespace` | `string` | Yes | Unique identifier for the filter group | +| `fields` | `ReactNode[]` | No | Array of filter field components to be rendered | +| `onFilter` | `() => void` | No | Handler function called when the filter button is clicked | +| `onReset` | `() => void` | No | Handler function called when the reset button is clicked | +| `showFilter` | `boolean` | No | Controls visibility of the filter button (default: true) | +| `showReset` | `boolean` | No | Controls visibility of the reset button (default: true) | + +## Example Implementation + +```jsx +import { Filter, CustomerFilter, DateFilter } from '@your-package/components'; + +const MyTableComponent = () => { + const [filterArgs, setFilterArgs] = useState({}); + const [searchedCustomer, setSearchedCustomer] = useState(null); + + const handleFilter = () => { + // Process request payload through applyFilters + const requestPayload = applyFilters('dokan_subscription_filter_request_param', { + ...filterArgs, + per_page: perPage, + page: currentPage, + } ); + + // Make server request with processed payload + fetchFilteredData(requestPayload); + }; + + const clearFilter = () => { + setFilterArgs({}); + setSearchedCustomer(null); + }; + + const handleCustomerSearch = (customer) => { + setSearchedCustomer(customer); + }; + + return ( +
+ , + + ]} + onFilter={handleFilter} + onReset={clearFilter} + showFilter={true} + showReset={true} + namespace="my_table_filters" + /> + {/* Table component */} +
+ ); +}; +``` + +## Creating Custom Filter Fields + +When creating custom filter field components to use with the Filter component: + +1. Each field component should manage its own state +2. Field components should update the parent's filterArgs through props +3. Include a unique key prop for each field +4. Handle reset functionality through props + +Example custom filter field: + +```jsx +const CustomFilter = ({ filterArgs, setFilterArgs }) => { + const handleChange = (value) => { + setFilterArgs({ + ...filterArgs, + customField: value + }); + }; + + return ( + handleChange(e.target.value)} + /> + ); +}; +``` + +## Important Implementation Notes + +### Server Request Handling +Before making requests to the server in your `onFilter` handler, you must process the request payload through an `applyFilters` function. This is a critical step for maintaining consistent filter behavior across the application. + +#### Filter Hook Naming Convention +The filter hook name should follow this pattern: +- Start with "dokan" +- Follow with the feature or task name +- End with "_filter_request_param" +- Must be in snake_case format + +You can use the `snakeCase` utility to ensure proper formatting: + +```jsx +import { snakeCase } from "@dokan/utilities"; +``` + +Example usage: +```jsx +const handleFilter = () => { + // Convert hook name to snake_case + const hookName = snakeCase('dokan_subscription_filter_request_param'); + + // Apply filters to your request payload before sending to server + const requestPayload = applyFilters(hookName, { + ...filterArgs, + per_page: perPage, + page: currentPage, + } ); + + // Now make your server request with the processed payload + fetchData(requestPayload); +}; +``` + +Parameters for `applyFilters`: +- First parameter: Hook name (string, must follow naming convention) +- Second parameter: Request payload (object) +- Third parameter: Namespace from Filter component props (string) + +This step ensures: +- Consistent filter processing across the application +- Proper parameter formatting +- Extensibility for future filter modifications +- Standardized hook naming across the application + +## Best Practices + +1. Always provide unique keys for field components +2. Implement proper type checking for filter arguments +3. Handle edge cases in reset functionality +4. Use consistent naming conventions for filter arguments +5. Include error handling in filter and reset handlers + +## Notes + +- The Filter component is designed to work with table components but can be used in other contexts +- All filter fields should be controlled components +- The namespace prop is used internally for generating unique identifiers +- Filter and reset functionality should be implemented in the parent component diff --git a/docs/frontend/hooks.md b/docs/frontend/hooks.md new file mode 100644 index 0000000000..a593cde6aa --- /dev/null +++ b/docs/frontend/hooks.md @@ -0,0 +1,84 @@ +# Dokan Hooks + +## Overview + +`Dokan` provides a set of reusable `hooks` that can be used across both `Free` and `Premium` versions. This documentation explains how to properly set up and use `hooks` in your project. + +## Important Dependencies + +For both `Dokan Free and Pro` versions, we must register the `dokan-react-components` dependency when using `global` components. + +### Implementation Example + +```php +// Register scripts with `dokan-react-components` dependency +$script_assets = 'add_your_script_assets_path_here'; + +if (file_exists($script_assets)) { + $vendor_asset = require $script_assets; + $version = $vendor_asset['version'] ?? ''; + + // Add dokan-react-components as a dependency + $component_handle = 'dokan-react-components'; + $dependencies = $vendor_asset['dependencies'] ?? []; + $dependencies[] = $component_handle; + + // Register Script + wp_register_script( + 'handler-name', + 'path_to_your_script_file', + $dependencies, + $version, + true + ); + + // Register Style + wp_register_style( + 'handler-name', + 'path_to_your_style_file', + [ $component_handle ], + $version + ); +} +``` + +## Component Access + +For `Dokan free & premium version`, we can import the components via `@dokan/hooks`: + +```js +import { useWindowDimensions } from '@dokan/hooks'; +``` + +For external `plugins`, we must include the `dokan-react-components` as scripts dependency and the `@dokan/hooks` should be introduced as an external resource configuration to resolve the path via `webpack`: + +```js +externals: { + '@dokan/hooks': 'dokan.hooks', + ... +}, +``` + +## Adding Global Components + +### File Structure + +``` +|____ src/ +| |___ hooks/ +| | |___ index.tsx # Main export file +| | |___ ViewportDimensions.tsx # Existing hook +| | |___ YourHook # Your new hook +| | | +| | |___ Other Files +| | +| |___ Other Files +| +|____ Other Files +``` + +**Finally,** we need to export the new `hook` from the `src/hooks/index.tsx` file. Then, we can import the new component via `@dokan/hooks`. + +```tsx +export { default as useWindowDimensions } from '@/hooks/ViewportDimensions'; +``` diff --git a/docs/frontend/sortable-list.md b/docs/frontend/sortable-list.md new file mode 100644 index 0000000000..70d993cd90 --- /dev/null +++ b/docs/frontend/sortable-list.md @@ -0,0 +1,192 @@ +# Dokan Sortable List Component Documentation + +- [Introduction](#introduction) +- [Data Structures](#data-structures) +- [Important Dependencies](#important-dependencies) +- [Quick Overview](#quick-overview) +- [Key Features](#key-features) +- [Component Properties](#component-properties) + +## Introduction +The Dokan `SortableList` component provide a `flexible` and `reusable` `drag-and-drop` interface for managing `sortable lists`, `grids`, and `horizontal` layouts. Built on top of `@dnd-kit`, these components offer seamless integration with `Dokan's` existing component ecosystem. + +## Data Structures +The `SortableList` component accommodates an `array` data type for its `items` property, which can follow `three primary array data structure patterns`: + +### 1. Simple Array +Basic array of `primitive` values without additional `properties`. + +```tsx +const simpleItems = [ + __( 'Item 1', 'dokan-lite' ), + __( 'Item 2', 'dokan-lite' ), + __( 'Item 3', 'dokan-lite' ) +]; + +const handleOrderUpdate = ( updatedItems ) => { + console.log( updatedItems ); // Get updated items array. + // Handle any additional logic after order update +}; + + ( +
+ { item } +
+ )} + ... +/> +``` + +### 2. Array of Objects (Without Order) +`Array of objects` with basic properties but `no explicit order tracking`. + +```tsx +const objectItems = [ + { id: 1, name: __( 'Item 1', 'dokan-lite' ) }, + { id: 2, name: __( 'Item 2', 'dokan-lite' ) }, + { id: 3, name: __( 'Item 3', 'dokan-lite' ) } +]; + +const handleOrderUpdate = ( updatedItems ) => { + console.log( updatedItems ); // Get updated items array. + // Handle any additional logic after order update +}; + + ( +
+ { item.name } +
+ )} + ... +/> +``` + +### 3. Array of Objects (With Order) +`Array of objects` that include an order property for `explicit order tracking`. + +```tsx +const orderedItems = [ + { id: 1, title: 'First Task', content: __( 'Do something', 'dokan-lite' ), sort_order: 1 }, + { id: 2, title: 'Second Task', content: __( 'Do something else', 'dokan-lite' ), sort_order: 2 }, + { id: 3, title: 'Third Task', content: __( 'Another task', 'dokan-lite' ), sort_order: 3 } +]; + +const handleOrderUpdate = ( updatedItems ) => { + console.log( updatedItems ); // Get updated items array. + // Handle any additional logic after order update +}; + + ( +
+

{ item.title }

+

{ item.content }

+ { item.sort_order } +
+ )} + ... +/> +``` + +## Important Dependencies +For both `Dokan Free and Pro` versions, we must register the `dokan-react-components` dependency when using `global` components. + +## Quick Overview + +#### Step 1: Import the Required Components + +```tsx +import { useState } from '@wordpress/element'; +import SortableList from '@dokan/components/sortable-list'; +``` + +#### Step 2: Set Up Your State Management + +```tsx +// DS-1: Example for single array +// const [ items, setItems ] = useState( [ 1, 2, 3, 4, 5 ] ); // Example for single array + +// DS-2: Example for single array of objects without ordering. +// const [ items, setItems ] = useState([ +// { id: 1, name: 'Item 1' }, +// { id: 2, name: 'Item 2' }, +// { id: 3, name: 'Item 3' }, +// ]); + +// DS-3: Example for single array of objects with ordering. +const [ items, setItems ] = useState([ + { id: 1, title: 'First Task', content: 'Do something', sort_order: 1 }, + { id: 2, title: 'Second Task', content: 'Do something else', sort_order: 2 }, +]); + +const handleOrderUpdate = ( updatedItems ) => { + setItems( updatedItems ); + // Handle any additional logic after order update +}; +``` + +#### Step 3: Implement the Render Function + +```tsx +const renderItem = ( item ) => ( +
+

{ item.title }

+

{ item.content }

+
+); +``` + +#### Step 4: Use the SortableList Component + +```tsx + +``` + +### Key Features + +- **Drag and Drop Interface** +- **Multiple Layouts (Vertical List, Horizontal List, Grid Layout)** +- **Sorting Order Management** +- **Customizable Items** + +### Component Properties + +#### SortableList Props + +**items (array):** The data array to be `rendered` in the `sortable` list. Can be an array of `single items`, array of `objects` with or without `ordering`. + +**namespace (string):** Unique identifier for the `sortable container`. Used for filtering and slots. + +**onChange (function):** Callback function triggered when `item order changes`. Receives the `updated items array` as an argument. + +**renderItem (function):** Function to `render individual items`. Receives the `item` as an argument. + +**orderProperty (string):** Property name used to track item `order` in `array of objects`. + +**strategy (string):** `Layout strategy` for the `list`. Options are `vertical`, `horizontal`, and `grid`. + +**className (string):** `Additional CSS classes` for the `container`. + +**gridColumns (string):** `Number of columns` for `grid layout`. Default is `4`. + +**id (string | number):** `Unique identifier` for the `sortable item`. Optional property. diff --git a/docs/frontend/utilities.md b/docs/frontend/utilities.md new file mode 100644 index 0000000000..a56a076810 --- /dev/null +++ b/docs/frontend/utilities.md @@ -0,0 +1,90 @@ +# Dokan Utilities + +## Dependencies + +### Script Dependencies + +When using `utility functions` in `scripts`, we must add `dokan-utilities` as a dependency. + +**Exception:** If we're already using `dokan-react-components` as a dependency, then we don't need to add `dokan-utilities` separately, as it's already included in the `free version`. + +## Adding New Utility Functions + +### File Structure + +``` +|____ src/ +| |___ utilities/ +| | |___ index.ts # Main export file +| | |___ ChangeCase.ts # Existing utilities +| | |___ YourUtility.ts # Your new utility file +| | +| |___ Other Files +| +|____ Other Files +``` + +**Finally,** export the new `utility function` from the `src/utilities/index.ts` file. + +```ts +export * from './ChangeCase'; +export * from './YourUtility'; +``` + +## Change Case Utilities + +In `Dokan` and `Dokan Pro`, we recommend using the `change-case` utility methods from the `Dokan utilities package`. These utilities are accessible through the `@dokan/utilities` package. + +## Why Use Change Case from Dokan Utilities? + +We strongly `discourage` adding the `change-case` package directly to your projects for several reasons: + +**Compatibility:** The latest versions of change-case (`v5+`) are not compatible with `WordPress`. In `Dokan utilities`, we maintain a compatible version that works seamlessly with `WordPress`. + +**Consistency:** Using the `utilities` from `@dokan/utilities` ensures consistent string transformations across the entire `Dokan ecosystem`. + +**Maintenance:** We handle version compatibility and updates, reducing the maintenance burden on external end. + +### Available Case Transformations +The following case transformation utilities are available: + +**camelCase:** Transforms **foo-bar** → **fooBar** +**capitalCase:** Transforms **foo-bar** → **Foo Bar** +**constantCase:** Transforms **foo-bar** → **FOO_BAR** +**dotCase:** Transforms **foo-bar** → **foo.bar** +**headerCase:** Transforms **foo-bar** → **Foo-Bar** +**noCase:** Transforms **foo-bar** → **foo-bar** +**kebabCase:** Transforms **fooBar** → **foo-bar** (alias for paramCase) +**pascalCase:** Transforms **foo-bar** → **FooBar** +**pathCase:** Transforms **foo-bar** → **foo/bar** +**sentenceCase:** Transforms **foo-bar** → **Foo bar** +**snakeCase:** Transforms **foo-bar** → **foo_bar** + +## Utilities Access + +For `Dokan free & premium version`, we can import the `utilities` via `@dokan/utilities`: + +```js +import { snakeCase, camelCase, kebabCase } from '@dokan/utilities'; +``` + +For external `plugins`, we must include the `dokan-utilities` or `dokan-react-components` as scripts dependency and the `@dokan/utilities` should be introduced as an external resource configuration to resolve the path via `webpack`: + +```js +externals: { + '@dokan/utilities': 'dokan.utilities', + ... +}, +``` + +## Usage Example + +```js +import { snakeCase, camelCase, kebabCase } from '@dokan/utilities'; + +// Examples +snakeCase( 'fooBar' ); // → "foo_bar" +camelCase( 'foo-bar' ); // → "fooBar" +kebabCase( 'fooBar' ); // → "foo-bar" +... +``` diff --git a/docs/slots/README.md b/docs/slots/README.md new file mode 100644 index 0000000000..3d1d9a34c9 --- /dev/null +++ b/docs/slots/README.md @@ -0,0 +1,97 @@ +# Guidelines for Creating Slot Documentation + +## File Structure +1. Create a markdown file in `/docs/slots/` directory +2. Name the file based on the feature area (e.g., `product-slots.md`, `order-slots.md`) + +## Documentation Template +```markdown +# [Feature Name] Component Slots + +## Overview +Brief one-line description of the feature area. + +## Slot Reference + +### [Component Name] (`path/to/component`) +| Slot Name | Position | Props | Description | +|-----------|----------|-------|-------------| +| `dokan-component-position` | Location | Props object or None | Brief description | + +[Add tables for each component in your feature] + +## Basic Implementation +```jsx +// Only if special implementation is needed +import { Fill } from '@wordpress/components'; + +const ExampleUsage = () => ( + + {(props) => ( + // Example implementation + )} + +); +``` + +## Props Interface +```typescript +// Only if component has props ( If there are no props, you can skip this section but keep the heading and mention "None" ) +interface CustomProps { + property: type; +} +``` +``` + +## Example Implementation +Here's how your `product-slots.md` might look: + +```markdown +# Product Component Slots + +## Overview +Extension points for product listing and management features. + +## Slot Reference + +### Product List (`/components/products/List.tsx`) +| Slot Name | Position | Props | Description | +|-----------|----------|-------|-------------| +| `dokan-product-list-before` | Before list | None | Content before product list | +| `dokan-product-list-actions` | Action area | `{ selection }` | Bulk actions for products | +| `dokan-product-list-after` | After list | None | Content after product list | + +### Product Form (`/components/products/Form.tsx`) +| Slot Name | Position | Props | Description | +|-----------|----------|-------|-------------| +| `dokan-product-form-before` | Before form | `{ product }` | Content before product form | +| `dokan-product-form-fields` | Form fields | `{ product }` | Additional form fields | + +## Props Interface ( If there are no props, you can skip this section but keep the heading and mention "None" ) +```typescript +interface ProductProps { + product: Product; + selection?: number[]; +} +``` +``` + +## Key Points to Remember +1. Keep slot names consistent: `dokan-[feature]-[component]-[position]` +2. Use clear, brief descriptions +3. Document all props passed to slots +4. Include TypeScript interfaces when props are used +5. Add basic implementation only if needed + +## Naming Conventions +- Use kebab-case for slot names +- Keep descriptions concise +- Be specific about positions +- Use consistent prop naming + +## Documentation Checklist +- [ ] Created file in correct location +- [ ] Used correct naming format for slots +- [ ] Included all slots in feature area +- [ ] Documented all props +- [ ] Added TypeScript interfaces if needed diff --git a/docs/slots/dashboard-layout-slots.md b/docs/slots/dashboard-layout-slots.md new file mode 100644 index 0000000000..ae1ffa7dd7 --- /dev/null +++ b/docs/slots/dashboard-layout-slots.md @@ -0,0 +1,53 @@ +# Layout Components Slots + +## Overview +Extension points available in Dokan's layout components: Header, Footer, Sidebar, and Content Area. + +## Slot Reference + +### Header (`/src/layout/Header.tsx`) +| Slot Name | Position | Props | Description | +|-----------|----------|-------|-------------| +| `dokan-before-header` | Before header | None | Content before header section | +| `dokan-header-actions` | Header right | `{ navigate }` | Actions in header right area | +| `dokan-after-header` | After header | None | Content after header section | + +### Content Area (`/src/layout/ContentArea.tsx`) +| Slot Name | Position | Props | Description | +|-----------|----------|-------|-------------| +| `dokan-layout-content-area-before` | Before content | None | Content before main area | +| `dokan-layout-content-area-after` | After content | None | Content after main area | + +### Footer (`/src/layout/Footer.tsx`) +| Slot Name | Position | Props | Description | +|-----------|----------|-------|-------------| +| `dokan-footer-area` | Footer content | None | Content in footer area | + +## Basic Implementation + +```jsx +import { Fill } from '@wordpress/components'; + +// Header action example +const HeaderAction = () => ( + + {({ navigate }) => ( + + )} + +); + +// Content area example +const ContentExtension = () => ( + +
Content before main area
+
+); +``` + +## Props Interface +```typescript +interface HeaderActionProps { + navigate: (path: string) => void; +} +``` diff --git a/includes/Abstracts/StatusElement.php b/includes/Abstracts/StatusElement.php new file mode 100644 index 0000000000..96393ab376 --- /dev/null +++ b/includes/Abstracts/StatusElement.php @@ -0,0 +1,269 @@ +id = $id; + } + + /** + * @return bool + */ + public function is_support_children(): bool { + return $this->support_children; + } + + /** + * @param bool $support_children + * + * @return StatusElement + */ + public function set_support_children( bool $support_children ): StatusElement { + $this->support_children = $support_children; + + return $this; + } + + /** + * @return string + */ + public function get_id(): string { + return $this->id; + } + + /** + * @param string $id + * + * @return StatusElement + */ + public function set_id( string $id ): StatusElement { + $this->id = $id; + + return $this; + } + + /** + * @return string + */ + public function get_hook_key(): string { + return $this->hook_key; + } + + /** + * @param string $hook_key + * + * @return StatusElement + */ + public function set_hook_key( string $hook_key ): StatusElement { + $this->hook_key = $hook_key; + + return $this; + } + + /** + * @return string + */ + public function get_title(): string { + return $this->title; + } + + /** + * @param string $title + * + * @return StatusElement + */ + public function set_title( string $title ): StatusElement { + $this->title = $title; + + return $this; + } + + /** + * @return string + */ + public function get_description(): string { + return $this->description; + } + + /** + * @param string $description + * + * @return StatusElement + */ + public function set_description( string $description ): StatusElement { + $this->description = $description; + + return $this; + } + + /** + * @return string + */ + public function get_icon(): string { + return $this->icon; + } + + /** + * @param string $icon + * + * @return StatusElement + */ + public function set_icon( string $icon ): StatusElement { + $this->icon = $icon; + + return $this; + } + + /** + * @return array + */ + public function get_children(): array { + $children = array(); + $filtered_children = apply_filters( $this->get_hook_key() . '_children', $this->children, $this ); // phpcs:ignore. + + foreach ( $filtered_children as $child ) { + $child->set_hook_key( $this->get_hook_key() . '_' . $child->get_id() ); + $children[ $child->get_id() ] = $child; + } + + return $children; + } + + /** + * Set children. + * + * @since DOKAN_SINCE + * + * @param array $children + * + * @return StatusElement + * @throws Exception + */ + public function set_children( array $children ): StatusElement { + if ( ! $this->is_support_children() ) { + throw new Exception( esc_html__( 'This element does not support child element.', 'dokan-lite' ) ); + } + $this->children = $children; + + return $this; + } + + /** + * @return string + */ + public function get_type(): string { + return $this->type; + } + + /** + * @param string $type + * + * @return StatusElement + */ + public function set_type( string $type ): StatusElement { + $this->type = $type; + + return $this; + } + + /** + * @return string + */ + public function get_data(): string { + return $this->data; + } + + /** + * @param string $data + * + * @return StatusElement + */ + public function set_data( string $data ): StatusElement { + $this->data = $data; + + return $this; + } + + /** + * @throws Exception + */ + public function add( StatusElement $child ): StatusElement { + if ( ! $this->is_support_children() ) { + throw new Exception( esc_html__( 'This element does not support child element.', 'dokan-lite' ) ); + } + $this->children[] = $child; + + return $this; + } + + /** + * @throws Exception + */ + public function remove( StatusElement $element ): StatusElement { + if ( ! $this->is_support_children() ) { + // translators: %s is Status element type. + throw new Exception( esc_html( sprintf( esc_html__( 'Status %s Does not support adding any children.', 'dokan-lite' ), $this->get_type() ) ) ); + } + + $children = array_filter( + $this->get_children(), + function ( $child ) use ( $element ) { + return $child !== $element; + } + ); + $this->set_children( $children ); + + return $this; + } + + + /** + * @return array + */ + public function render(): array { + $children = array(); + if ( $this->is_support_children() ) { + foreach ( $this->get_children() as $child ) { + $children[] = $child->render(); + } + } + + $data = [ + 'id' => $this->get_id(), + 'title' => $this->get_title(), + 'description' => $this->get_description(), + 'icon' => $this->get_icon(), + 'type' => $this->get_type(), + 'data' => $this->escape_data( $this->get_data() ), + 'hook_key' => $this->get_hook_key(), + 'children' => $children, + ]; + + return apply_filters( 'dokan_status_element_render_' . $this->get_hook_key(), $data, $this ); + } + + /** + * @param string $data + * + * @return string + */ + abstract public function escape_data( string $data ): string; +} diff --git a/includes/Admin/Dashboard/Dashboard.php b/includes/Admin/Dashboard/Dashboard.php new file mode 100644 index 0000000000..647053c555 --- /dev/null +++ b/includes/Admin/Dashboard/Dashboard.php @@ -0,0 +1,220 @@ + + */ + protected array $pages = []; + + /** + * @var string + */ + protected string $script_key = 'dokan-admin-dashboard'; + + /** + * Register hooks. + */ + public function register_hooks(): void { + add_action( 'dokan_admin_menu', [ $this, 'register_menu' ], 99, 2 ); + add_action( 'dokan_register_scripts', [ $this, 'register_scripts' ] ); + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); + } + + /** + * Get all pages. + * + * @since DOKAN_SINCE + * + * @return array< Pageable > + * + * @throws \InvalidArgumentException If the page is not an instance of Pageable. + */ + public function get_pages(): array { + $pages = apply_filters( 'dokan_admin_dashboard_pages', $this->pages ); + + if ( ! is_array( $pages ) ) { + return $this->pages; + } + + return array_filter( + $pages, function ( $page ) { + if ( ! $page instanceof Pageable ) { + throw new \InvalidArgumentException( esc_html__( 'The page must be an instance of Pageable.', 'dokan-lite' ) ); + } + return true; + } + ); + } + + /** + * Register the submenu menu. + * + * @since DOKAN_SINCE + * + * @param string $capability Menu capability. + * @param string $position Menu position. + * + * @return void + */ + public function register_menu( string $capability, string $position ) { + global $submenu; + + $parent_slug = 'dokan'; + + // TODO: Remove and rewrite this code for registering `dokan-dashboard`. + $menu_slug = 'dokan-dashboard'; + add_submenu_page( + 'dokan', + '', + '', + $capability, + $menu_slug, + [ $this, 'render_dashboard_page' ], + 1 + ); + + foreach ( $this->get_pages() as $page ) { + $menu_args = $page->menu( $capability, $position ); + + if ( ! $menu_args ) { + continue; + } + + $route = $menu_args['route'] ?? $page->get_id(); + $route = trim( $route, ' /' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $submenu[ $parent_slug ][] = [ $menu_args['menu_title'], $capability, 'admin.php?page=' . $menu_slug . '#/' . $route ]; + } + } + + /** + * Render the dashboard page. + * + * @since DOKAN_SINCE + * + * @return void + */ + public function render_dashboard_page(): void { + $settings = $this->settings(); + + ob_start(); + echo '
' . esc_html__( 'Loading...', 'dokan-lite' ) . '
'; + echo ob_get_clean(); + } + + /** + * Get all settings. + * + * @since DOKAN_SINCE + * + * @return array + */ + public function settings(): array { + $settings = [ + 'nonce' => wp_create_nonce( 'dokan_admin_dashboard' ), + ]; + + foreach ( $this->get_pages() as $page ) { + + /** + * Filter the settings for a specific page. + * + * @since DOKAN_SINCE + * + * @param array $settings The settings. + * @param string $page_id The page ID. + * @param Pageable $page The page. + */ + $settings[ $page->get_id() ] = apply_filters( 'dokan_admin_dashboard_page_settings', $page->settings(), $page->get_id(), $page ); + } + + /** + * Filter the settings. + * + * @since DOKAN_SINCE + * + * @param array $settings The settings. + */ + return apply_filters( 'dokan_admin_dashboard_pages_settings', $settings ); + } + + /** + * Get all scripts ids. + * + * @since DOKAN_SINCE + * + * @return array + */ + public function scripts(): array { + return array_reduce( + $this->get_pages(), fn( $carry, $page ) => array_merge( $carry, $page->scripts() ), [ $this->script_key ] + ); + } + + /** + * Get all styles ids. + * + * @since DOKAN_SINCE + * + * @return array + */ + public function styles(): array { + return array_reduce( + $this->get_pages(), fn( $carry, $page ) => array_merge( $carry, $page->styles() ), [ $this->script_key ] + ); + } + + /** + * Register dashboard scripts. + * + * @since DOKAN_SINCE + * + * @return void + */ + public function register_scripts() { + $script = require DOKAN_DIR . '/assets/js/dokan-admin-dashboard.asset.php'; + + // Register the main script. + wp_register_script( $this->script_key, DOKAN_PLUGIN_ASSEST . '/js/dokan-admin-dashboard.js', $script['dependencies'], $script['version'], true ); + wp_register_style( $this->script_key, DOKAN_PLUGIN_ASSEST . '/css/dokan-admin-dashboard.css', [], $script['version'] ); + + // Register all other scripts. + foreach ( $this->get_pages() as $page ) { + $page->register(); + } + } + + /** + * Enqueue dashboard scripts. + * + * @since DOKAN_SINCE + * + * @return void + */ + public function enqueue_scripts() { + $screen = get_current_screen(); + + if ( $screen->id !== 'toplevel_page_dokan' && $screen->id !== 'dokan_page_dokan-dashboard' ) { + return; + } + + foreach ( $this->scripts() as $handle ) { + wp_enqueue_script( $handle ); + } + + foreach ( $this->styles() as $handle ) { + wp_enqueue_style( $handle ); + } + } +} diff --git a/includes/Admin/Dashboard/Pageable.php b/includes/Admin/Dashboard/Pageable.php new file mode 100644 index 0000000000..5de14dc841 --- /dev/null +++ b/includes/Admin/Dashboard/Pageable.php @@ -0,0 +1,70 @@ + An array of associative arrays with keys 'route', 'page_title', 'menu_title', 'capability', 'position'. + */ + public function menu( string $capability, string $position ): array; + + /** + * Get the settings values. + * + * @since DOKAN_SINCE + * + * @return array An array of settings values. + */ + public function settings(): array; + + /** + * Get the scripts. + * + * @since DOKAN_SINCE + * + * @return array An array of script handles. + */ + public function scripts(): array; + + /** + * Get the styles. + * + * @since DOKAN_SINCE + * + * @return array An array of style handles. + */ + public function styles(): array; + + /** + * Register the page scripts and styles. + * + * @since DOKAN_SINCE + * + * @return void + */ + public function register(): void; +} diff --git a/includes/Admin/Dashboard/Pages/AbstractPage.php b/includes/Admin/Dashboard/Pages/AbstractPage.php new file mode 100644 index 0000000000..61ceba3963 --- /dev/null +++ b/includes/Admin/Dashboard/Pages/AbstractPage.php @@ -0,0 +1,59 @@ + __( 'Dokan Status', 'dokan-lite' ), + 'menu_title' => __( 'Status', 'dokan-lite' ), + 'route' => 'status', + 'capability' => $capability, + 'position' => 99, + ]; + } + + /** + * @inheritDoc + */ + public function settings(): array { + return []; + } + + /** + * @inheritDoc + */ + public function scripts(): array { + return [ 'dokan-status' ]; + } + + /** + * Get the styles. + * + * @since DOKAN_SINCE + * + * @return array An array of style handles. + */ + public function styles(): array { + return [ 'dokan-status' ]; + } + + /** + * Register the page scripts and styles. + * + * @since DOKAN_SINCE + * + * @return void + */ + public function register(): void { + $asset_file = include DOKAN_DIR . '/assets/js/dokan-status.asset.php'; + + wp_register_script( + 'dokan-status', + DOKAN_PLUGIN_ASSEST . '/js/dokan-status.js', + $asset_file['dependencies'], + $asset_file['version'], + [ + 'strategy' => 'defer', + 'in_footer' => true, + ] + ); + + wp_register_style( 'dokan-status', DOKAN_PLUGIN_ASSEST . '/css/dokan-status.css', [], $asset_file['version'] ); + } +} diff --git a/includes/Admin/Status/Button.php b/includes/Admin/Status/Button.php new file mode 100644 index 0000000000..ae26023f3c --- /dev/null +++ b/includes/Admin/Status/Button.php @@ -0,0 +1,100 @@ +request; + } + + /** + * @param string $request + * + * @return Button + */ + public function set_request( string $request ): Button { + $this->request = $request; + + return $this; + } + + /** + * @return string + */ + public function get_endpoint(): string { + return $this->endpoint; + } + + /** + * @param string $endpoint + * + * @return Button + */ + public function set_endpoint( string $endpoint ): Button { + $this->endpoint = $endpoint; + + return $this; + } + + /** + * @return array + */ + public function get_payload(): array { + return $this->payload; + } + + /** + * @param array $payload + * + * @return Button + */ + public function set_payload( array $payload ): Button { + $this->payload = $payload; + + return $this; + } + + /** + * @inheritDoc + */ + public function render(): array { + $data = parent::render(); + $data['request'] = $this->get_request(); + $data['endpoint'] = trim( $this->get_endpoint(), '/' ); + $data['payload'] = $this->get_payload(); + return $data; + } + + /** + * @inheritDoc + */ + public function escape_data( string $data ): string { + return esc_html( $data ); + } +} diff --git a/includes/Admin/Status/Heading.php b/includes/Admin/Status/Heading.php new file mode 100644 index 0000000000..8bfa44092a --- /dev/null +++ b/includes/Admin/Status/Heading.php @@ -0,0 +1,22 @@ +url; + } + + /** + * @param string $url + * + * @return Link + */ + public function set_url( string $url ): Link { + $this->url = $url; + + return $this; + } + + /** + * @return string + */ + public function get_title_text(): string { + return $this->title_text; + } + + /** + * @param string $title_text + * + * @return Link + */ + public function set_title_text( string $title_text ): Link { + $this->title_text = $title_text; + + return $this; + } + + /** + * @inheritDoc + */ + public function render(): array { + $data = parent::render(); + $data['url'] = $this->get_url(); + $data['title_text'] = $this->get_title_text(); + return $data; + } + + /** + * @inheritDoc + */ + public function escape_data( string $data ): string { + return esc_html( $data ); + } +} diff --git a/includes/Admin/Status/Page.php b/includes/Admin/Status/Page.php new file mode 100644 index 0000000000..5df3e1fc73 --- /dev/null +++ b/includes/Admin/Status/Page.php @@ -0,0 +1,24 @@ + [ + 'href' => [], + 'title' => [], + ], + 'br' => [], + 'em' => [], + 'strong' => [], + 'span' => [], + 'code' => [], + ]; + return wp_kses( $data, $allowed_tags ); + } +} diff --git a/includes/Admin/Status/Section.php b/includes/Admin/Status/Section.php new file mode 100644 index 0000000000..8aa417252c --- /dev/null +++ b/includes/Admin/Status/Section.php @@ -0,0 +1,23 @@ +describe(); + } catch ( Exception $e ) { + dokan_log( $e->getMessage() ); + } + return parent::render()['children']; + } + + /** + * Describe the settings options. + * + * @return void + * @throws Exception + */ + public function describe() { + // $this->add( + // StatusElementFactory::heading( 'main_heading' ) + // ->set_title( __( 'Dokan Status', 'dokan-lite' ) ) + // ->set_description( __( 'Check the status of your Dokan installation.', 'dokan-lite' ) ) + // ); + + // $this->add( + // StatusElementFactory::section( 'overridden_features' ) + // ->set_title( __( 'Overridden Templates', 'dokan-lite' ) ) + // ->set_description( __( 'The templates currently overridden that is preventing enabling new features.', 'dokan-lite' ) ) + // ->add( + // StatusElementFactory::table( 'override_table' ) + // ->set_title( __( 'General Heading', 'dokan-lite' ) ) + // ->set_headers( + // [ + // __( 'Template', 'dokan-lite' ), + // __( 'Feature', 'dokan-lite' ), + // 'Action', + // ] + // ) + // ->add( + // StatusElementFactory::table_row( 'override_row' ) + // ->add( + // StatusElementFactory::table_column( 'template' ) + // ->add( + // StatusElementFactory::paragraph( 'file' ) + // ->set_title( __( 'FileA.php', 'dokan-lite' ) ) + // ) + // ) + // ->add( + // StatusElementFactory::table_column( 'action' ) + // ->add( + // StatusElementFactory::button( 'action' ) + // ->set_title( __( 'Remove', 'dokan-lite' ) ) + // ) + // ) + // ) + // ) + // ); + + do_action( 'dokan_status_after_describing_elements', $this ); + } +} diff --git a/includes/Admin/Status/StatusElementFactory.php b/includes/Admin/Status/StatusElementFactory.php new file mode 100644 index 0000000000..292469781e --- /dev/null +++ b/includes/Admin/Status/StatusElementFactory.php @@ -0,0 +1,86 @@ +headers; + } + + /** + * @param array $headers + * + * @return Table + */ + public function set_headers( array $headers ): Table { + $this->headers = $headers; + + return $this; + } + + public function render(): array { + $data = parent::render(); + $data['headers'] = $this->get_headers(); + + return $data; + } + + /** + * @inheritDoc + */ + public function escape_data( string $data ): string { + // No escaping needed for table data. + return $data; + } +} diff --git a/includes/Admin/Status/TableColumn.php b/includes/Admin/Status/TableColumn.php new file mode 100644 index 0000000000..c2f1248fc0 --- /dev/null +++ b/includes/Admin/Status/TableColumn.php @@ -0,0 +1,23 @@ + filemtime( DOKAN_DIR . '/assets/css/dokan-admin-product-style.css' ), ], 'dokan-tailwind' => [ - 'src' => DOKAN_PLUGIN_ASSEST . '/css/dokan-tailwind.css', - 'version' => filemtime( DOKAN_DIR . '/assets/css/dokan-tailwind.css' ), + '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' ), + ], + 'dokan-react-components' => [ + 'deps' => [ 'wp-components' ], + 'src' => DOKAN_PLUGIN_ASSEST . '/css/components.css', + 'version' => filemtime( DOKAN_DIR . '/assets/css/components.css' ), ], ]; @@ -373,6 +382,8 @@ 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/'; @@ -514,61 +525,90 @@ public function get_scripts() { 'deps' => [ 'jquery', 'wp-i18n', 'dokan-vue-vendor', 'dokan-vue-bootstrap' ], 'version' => filemtime( $asset_path . 'js/vue-frontend.js' ), ], - - 'dokan-login-form-popup' => [ + 'dokan-login-form-popup' => [ 'src' => $asset_url . '/js/login-form-popup.js', 'deps' => [ 'dokan-modal', 'wp-i18n' ], 'version' => filemtime( $asset_path . 'js/login-form-popup.js' ), ], - 'dokan-sweetalert2' => [ + 'dokan-sweetalert2' => [ 'src' => $asset_url . '/vendors/sweetalert2/sweetalert2.all.min.js', 'deps' => [ 'dokan-modal', 'wp-i18n' ], 'version' => filemtime( $asset_path . 'vendors/sweetalert2/sweetalert2.all.min.js' ), ], - 'dokan-util-helper' => [ + 'dokan-util-helper' => [ 'src' => $asset_url . '/js/helper.js', 'deps' => [ 'jquery', 'dokan-sweetalert2', 'moment' ], 'version' => filemtime( $asset_path . 'js/helper.js' ), 'in_footer' => false, ], - 'dokan-promo-notice-js' => [ + 'dokan-promo-notice-js' => [ 'src' => $asset_url . '/js/dokan-promo-notice.js', 'deps' => [ 'jquery', 'dokan-vue-vendor' ], 'version' => filemtime( $asset_path . 'js/dokan-promo-notice.js' ), ], - 'dokan-admin-notice-js' => [ + 'dokan-admin-notice-js' => [ 'src' => $asset_url . '/js/dokan-admin-notice.js', 'deps' => [ 'jquery', 'dokan-vue-vendor' ], 'version' => filemtime( $asset_path . 'js/dokan-admin-notice.js' ), ], - 'dokan-reverse-withdrawal' => [ + 'dokan-reverse-withdrawal' => [ 'src' => $asset_url . '/js/reverse-withdrawal.js', 'deps' => [ 'jquery', 'dokan-util-helper', 'dokan-vue-vendor', 'dokan-date-range-picker' ], 'version' => filemtime( $asset_path . 'js/reverse-withdrawal.js' ), ], - 'product-category-ui' => [ + 'product-category-ui' => [ 'src' => $asset_url . '/js/product-category-ui.js', 'deps' => [ 'jquery', 'dokan-vue-vendor' ], 'version' => filemtime( $asset_path . 'js/product-category-ui.js' ), ], - 'dokan-vendor-address' => [ + 'dokan-vendor-address' => [ 'src' => $asset_url . '/js/vendor-address.js', 'deps' => [ 'jquery', 'wc-address-i18n' ], 'version' => filemtime( $asset_path . 'js/vendor-address.js' ), ], - 'dokan-admin-product' => [ + 'dokan-admin-product' => [ 'src' => $asset_url . '/js/dokan-admin-product.js', 'deps' => [ 'jquery', 'dokan-vue-vendor', 'selectWoo' ], 'version' => filemtime( $asset_path . 'js/dokan-admin-product.js' ), 'in_footer' => false, ], - 'dokan-frontend' => [ + 'dokan-frontend' => [ 'src' => $asset_url . '/js/dokan-frontend.js', 'deps' => [ 'jquery' ], 'version' => filemtime( $asset_path . 'js/dokan-frontend.js' ), ], + 'dokan-react-frontend' => [ + 'src' => $asset_url . '/js/frontend.js', + 'deps' => array_merge( $frontend_shipping_asset['dependencies'], [ 'wp-core-data', 'dokan-react-components' ] ), + 'version' => $frontend_shipping_asset['version'], + ], + 'dokan-utilities' => [ + 'deps' => [], + 'src' => $asset_url . '/js/utilities.js', + 'version' => filemtime( $asset_path . 'js/utilities.js' ), + ], + 'dokan-hooks' => [ + 'deps' => [], + 'src' => $asset_url . '/js/hooks.js', + 'version' => filemtime( $asset_path . 'js/hooks.js' ), + ], ]; + $components_asset_file = DOKAN_DIR . '/assets/js/components.asset.php'; + if ( file_exists( $components_asset_file ) ) { + $components_asset = require $components_asset_file; + + // Register React components. + $scripts['dokan-react-components'] = [ + 'version' => $components_asset['version'], + 'src' => $asset_url . '/js/components.js', + 'deps' => array_merge( + $components_asset['dependencies'], + [ 'dokan-utilities', 'dokan-hooks' ] + ), + ]; + } + return $scripts; } @@ -645,7 +685,8 @@ public function enqueue_front_scripts() { 'routeComponents' => [ 'default' => null ], 'routes' => $this->get_vue_frontend_routes(), 'urls' => [ - 'assetsUrl' => DOKAN_PLUGIN_ASSEST, + 'assetsUrl' => DOKAN_PLUGIN_ASSEST, + 'dashboardUrl' => dokan_get_navigation_url(), ], ] ); diff --git a/includes/Dashboard/Templates/Manager.php b/includes/Dashboard/Templates/Manager.php index ca75447fa7..38bb0dc97a 100644 --- a/includes/Dashboard/Templates/Manager.php +++ b/includes/Dashboard/Templates/Manager.php @@ -31,5 +31,6 @@ public function __construct() { $this->container['withdraw'] = new Withdraw(); $this->container['product_category'] = new MultiStepCategories(); $this->container['reverse_withdrawal'] = new ReverseWithdrawal(); + $this->container['new_dashboard'] = new NewDashboard(); } } diff --git a/includes/Dashboard/Templates/NewDashboard.php b/includes/Dashboard/Templates/NewDashboard.php new file mode 100644 index 0000000000..8a30ee7533 --- /dev/null +++ b/includes/Dashboard/Templates/NewDashboard.php @@ -0,0 +1,93 @@ +query_vars['new'] ) ) { + return; + } + + $wc_instance = WCAdminAssets::get_instance(); + $wc_instance->register_scripts(); + + $dokan_frontend = [ + 'currency' => dokan_get_container()->get( 'scripts' )->get_localized_price(), + ]; + + wp_enqueue_script( 'dokan-react-frontend' ); + wp_enqueue_style( 'dokan-react-frontend' ); + wp_localize_script( + 'dokan-react-frontend', + 'dokanFrontend', + apply_filters( 'dokan_react_frontend_localized_args', $dokan_frontend ), + ); + } +} diff --git a/includes/DependencyManagement/Providers/AdminDashboardServiceProvider.php b/includes/DependencyManagement/Providers/AdminDashboardServiceProvider.php new file mode 100644 index 0000000000..daad3fcc02 --- /dev/null +++ b/includes/DependencyManagement/Providers/AdminDashboardServiceProvider.php @@ -0,0 +1,29 @@ +services as $service ) { + $definition = $this->share_with_implements_tags( $service ); + $this->add_tags( $definition, $this->tags ); + } + } +} diff --git a/includes/DependencyManagement/Providers/AdminServiceProvider.php b/includes/DependencyManagement/Providers/AdminServiceProvider.php index 0b81f74577..0781c85a70 100644 --- a/includes/DependencyManagement/Providers/AdminServiceProvider.php +++ b/includes/DependencyManagement/Providers/AdminServiceProvider.php @@ -2,6 +2,7 @@ namespace WeDevs\Dokan\DependencyManagement\Providers; +use WeDevs\Dokan\Admin\Status\Status; use WeDevs\Dokan\DependencyManagement\BaseServiceProvider; class AdminServiceProvider extends BaseServiceProvider { @@ -12,6 +13,7 @@ class AdminServiceProvider extends BaseServiceProvider { protected $services = [ self::TAG, + Status::class, ]; /** @@ -57,5 +59,8 @@ public function register(): void { $this->getContainer() ->addShared( \WeDevs\Dokan\Admin\SetupWizard::class, \WeDevs\Dokan\Admin\SetupWizard::class ) ->addTag( self::TAG ); + $this->getContainer() + ->addShared( \WeDevs\Dokan\Admin\Status\Status::class, \WeDevs\Dokan\Admin\Status\Status::class ) + ->addTag( self::TAG ); } } diff --git a/includes/DependencyManagement/Providers/CommonServiceProvider.php b/includes/DependencyManagement/Providers/CommonServiceProvider.php index 42711e1413..d0e10635a2 100644 --- a/includes/DependencyManagement/Providers/CommonServiceProvider.php +++ b/includes/DependencyManagement/Providers/CommonServiceProvider.php @@ -20,6 +20,7 @@ class CommonServiceProvider extends BaseServiceProvider { \WeDevs\Dokan\CacheInvalidate::class, \WeDevs\Dokan\Shipping\Hooks::class, \WeDevs\Dokan\Privacy::class, + \WeDevs\Dokan\VendorNavMenuChecker::class, ]; /** diff --git a/includes/DependencyManagement/Providers/ServiceProvider.php b/includes/DependencyManagement/Providers/ServiceProvider.php index 9ae5b8e70c..515cb24964 100644 --- a/includes/DependencyManagement/Providers/ServiceProvider.php +++ b/includes/DependencyManagement/Providers/ServiceProvider.php @@ -62,6 +62,7 @@ public function boot(): void { $this->getContainer()->addServiceProvider( new FrontendServiceProvider() ); $this->getContainer()->addServiceProvider( new AjaxServiceProvider() ); $this->getContainer()->addServiceProvider( new AnalyticsServiceProvider() ); + $this->getContainer()->addServiceProvider( new AdminDashboardServiceProvider() ); } /** diff --git a/includes/REST/AdminDashboardController.php b/includes/REST/AdminDashboardController.php index bb8c09212f..464af658b3 100644 --- a/includes/REST/AdminDashboardController.php +++ b/includes/REST/AdminDashboardController.php @@ -2,6 +2,7 @@ namespace WeDevs\Dokan\REST; +use WeDevs\Dokan\Admin\Status\Status; use WP_Error; use WP_REST_Response; use WP_REST_Server; @@ -64,6 +65,16 @@ public function register_routes() { ), ) ); + register_rest_route( + $this->namespace, '/' . $this->base . '/status', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_status' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array(), + ), + ) + ); } /** @@ -164,6 +175,16 @@ public function get_feeds( $request ) { return rest_ensure_response( $feeds ); } + public function get_status( $request ) { + /** + * @var Status $status + */ + $status = dokan_get_container()->get( Status::class ); + $content = $status->render(); + + return rest_ensure_response( $content ); + } + /** * Support SimplePie class in WP 5.5+ * 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..437c6bf7b8 --- /dev/null +++ b/includes/REST/DokanDataContinentsController.php @@ -0,0 +1,70 @@ + rest_authorization_required_code(), + ] + ); + } + + /** + * 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 ) { + return $this->check_dokan_permission( $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 ) { + return $this->check_dokan_permission( $request ); + } +} diff --git a/includes/REST/DokanDataCountriesController.php b/includes/REST/DokanDataCountriesController.php new file mode 100644 index 0000000000..3a42a6114e --- /dev/null +++ b/includes/REST/DokanDataCountriesController.php @@ -0,0 +1,70 @@ + rest_authorization_required_code(), + ] + ); + } + + /** + * 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 ) { + return $this->check_dokan_permission( $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 ) { + return $this->check_dokan_permission( $request ); + } +} 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/OrderControllerV2.php b/includes/REST/OrderControllerV2.php index ea8491d82e..35026c2436 100644 --- a/includes/REST/OrderControllerV2.php +++ b/includes/REST/OrderControllerV2.php @@ -118,69 +118,104 @@ public function register_routes() { * * @since 3.7.10 * - * @param \WP_REST_Request $requests Request object. + * @param \WP_REST_Request $request Request object. * * @return WP_Error|\WP_HTTP_Response|\WP_REST_Response */ - public function get_order_downloads( $requests ) { + public function get_order_downloads( $request ) { global $wpdb; $user_id = dokan_get_current_user_id(); $data = []; $downloads = []; - // TODO: Need to move this into a separate function. $download_permissions = $wpdb->get_results( $wpdb->prepare( " SELECT * FROM {$wpdb->prefix}woocommerce_downloadable_product_permissions WHERE order_id = %d ORDER BY product_id ASC - ", $requests->get_param( 'id' ) + ", $request->get_param( 'id' ) ) ); - foreach ( $download_permissions as $download ) { - $product = wc_get_product( absint( $download->product_id ) ); + $product_ids = wp_list_pluck( $download_permissions, 'product_id' ); + $product_ids = array_unique( $product_ids ); - // don't show permissions to files that have since been removed - if ( ! $product || ! $product->exists() || ! $product->has_file( $download->download_id ) ) { - continue; + $products = wc_get_products( + [ + 'include' => $product_ids, + ] + ); + + $existing_product_ids = wp_list_pluck( $products, 'id' ); + + $downloads = array_filter( + $download_permissions, + function ( $download ) use ( $existing_product_ids ) { + return in_array( $download->product_id, $existing_product_ids ); } + ); - $downloads[] = $download; - } + $downloads = array_map( + function ( $download ) use ( $products, $request ) { + $filter_items = array_filter( + $products, + function ( $product ) use ( $download ) { + return $product->get_id() === intval( $download->product_id ); + } + ); + $download->product = reset( $filter_items ); + return $this->prepare_data_for_response( $download, $request ); + }, + $downloads + ); + + $data = $this->format_downloads_data( $downloads, $products ); + + return rest_ensure_response( $data ); + } + + /** + * Format downloads data. + * + * @since DOKAN_SINCE + * + * @param \stdClass[] $downloads + * @param \WC_Product[] $products + * + * @return array + */ + protected function format_downloads_data( $downloads, $products ) { + $data = []; $data['downloads'] = $downloads; - $orders_items = wc_get_order( $requests->get_param( 'id' ) )->get_items(); - $orders_items_ids = []; + $data['products'] = array_reduce( + $products, function ( $acc, $product ) { - foreach ( $orders_items as $item ) { - $orders_items_ids[] = $item->get_product_id(); - } + $acc[ $product->get_id() ] = $product->get_formatted_name(); - $orders_items_ids = implode( ',', $orders_items_ids ); - // @codingStandardsIgnoreStart - $products = $wpdb->get_results( - $wpdb->prepare( - "SELECT $wpdb->posts.* FROM $wpdb->posts - INNER JOIN $wpdb->postmeta - ON ( $wpdb->posts.ID = $wpdb->postmeta.post_id ) - WHERE $wpdb->posts.post_author=%d - AND ( $wpdb->postmeta.meta_key = '_downloadable' AND $wpdb->postmeta.meta_value = 'yes' ) - AND $wpdb->posts.post_type IN ( 'product', 'product_variation' ) - AND $wpdb->posts.post_status = 'publish' - AND $wpdb->posts.ID IN ( {$orders_items_ids} ) - GROUP BY $wpdb->posts.ID - ORDER BY $wpdb->posts.post_parent ASC, $wpdb->posts.post_title ASC", $user_id - ) + return $acc; + }, [] ); - // @codingStandardsIgnoreEnd - foreach ( $products as $product ) { - $data['products'][ $product->ID ] = esc_html( wc_get_product( $product->ID )->get_formatted_name() ); - } + return apply_filters( 'dokan_rest_prepare_format_downloads_data', $data, $downloads, $products ); + } - return rest_ensure_response( $data ); + /** + * Prepare data for response. + * + * @since DOKAN_SINCE + * + * @param \stdClass $download + * @param \WP_REST_Request $request + * + * @return \stdClass + */ + public function prepare_data_for_response( $download, $request ) { + $product = $download->product; + unset( $download->product ); + + return apply_filters( 'dokan_rest_prepare_order_download_response', $download, $product ); } /** @@ -211,7 +246,7 @@ public function grant_order_downloads( $requests ) { $inserted_id = wc_downloadable_file_permission( $download_id, $product_id, $order ); if ( $inserted_id ) { - $file_counter ++; + ++$file_counter; if ( $file->get_name() ) { $file_count = $file->get_name(); } else { diff --git a/includes/REST/OrderControllerV3.php b/includes/REST/OrderControllerV3.php new file mode 100644 index 0000000000..3353e063d2 --- /dev/null +++ b/includes/REST/OrderControllerV3.php @@ -0,0 +1,60 @@ +get_id() ) && ! empty( $download->product_id ) && absint( $product_item->get_id() ) === absint( $download->product_id ); + } + ); + $product = reset( $product ); + + $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 ) ); + + return $download; + }, + $downloads + ); + + return $updated_response; + } +} diff --git a/includes/REST/ProductController.php b/includes/REST/ProductController.php index ee78d383e0..80e5c23a06 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-lite' ), + '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/includes/REST/WithdrawController.php b/includes/REST/WithdrawController.php index 4c26300038..81a62abb26 100644 --- a/includes/REST/WithdrawController.php +++ b/includes/REST/WithdrawController.php @@ -4,6 +4,7 @@ use Cassandra\Date; use Exception; +use stdClass; use WeDevs\Dokan\Cache; use WeDevs\Dokan\Withdraw\Withdraw; use WP_Error; @@ -411,15 +412,24 @@ public function get_items( $request ) { public function get_balance() { $data = []; - $data['current_balance'] = dokan_get_seller_balance( dokan_get_current_user_id(), false ); - $data['withdraw_limit'] = dokan_get_option( 'withdraw_limit', 'dokan_withdraw', 0 ); - $data['withdraw_threshold'] = dokan_get_withdraw_threshold( dokan_get_current_user_id() ); - $data['withdraw_methods'] = array_filter( dokan_get_seller_active_withdraw_methods( dokan_get_current_user_id() ) ); - $data['last_withdraw'] = dokan()->withdraw->get_withdraw_requests( + $last_withdraw = dokan()->withdraw->get_withdraw_requests( dokan_get_current_user_id(), dokan()->withdraw->get_status_code( 'approved' ), 1 ); + $last_withdraw = reset( $last_withdraw ); + + if ( is_a( $last_withdraw, \WeDevs\Dokan\Withdraw\Withdraw::class ) ) { + $last_withdraw = $last_withdraw->get_withdraw(); + $last_withdraw['details'] = isset( $last_withdraw['details'] ) ? maybe_unserialize( $last_withdraw['details'] ) : []; + $last_withdraw['method_title'] = isset( $last_withdraw['method'] ) ? dokan_withdraw_get_method_title( $last_withdraw['method'] ) : ''; + } + + $data['current_balance'] = dokan_get_seller_balance( dokan_get_current_user_id(), false ); + $data['withdraw_limit'] = dokan_get_option( 'withdraw_limit', 'dokan_withdraw', 0 ); + $data['withdraw_threshold'] = dokan_get_withdraw_threshold( dokan_get_current_user_id() ); + $data['withdraw_methods'] = array_filter( dokan_get_seller_active_withdraw_methods( dokan_get_current_user_id() ) ); + $data['last_withdraw'] = is_array( $last_withdraw ) ? $last_withdraw : new stdClass(); return rest_ensure_response( $data ); } @@ -464,7 +474,7 @@ public function create_item( $request ) { 'user_id' => $user_id, 'amount' => $request['amount'], 'method' => $request['method'], - ] + ] ); if ( is_wp_error( $validate_request ) ) { @@ -964,7 +974,7 @@ public function get_item_schema() { 'amount' => [ 'required' => true, 'description' => __( 'The amount of discount. Should always be numeric, even if setting a percentage.', 'dokan-lite' ), - 'type' => 'string', + 'type' => 'number', 'context' => [ 'view', 'edit' ], ], 'created_date' => [ diff --git a/includes/REST/WithdrawControllerV2.php b/includes/REST/WithdrawControllerV2.php index 28d1231630..3ccf0c9edb 100644 --- a/includes/REST/WithdrawControllerV2.php +++ b/includes/REST/WithdrawControllerV2.php @@ -2,6 +2,7 @@ namespace WeDevs\Dokan\REST; +use WP_REST_Request; use WP_REST_Server; use WP_Error; use WP_REST_Response; @@ -44,6 +45,24 @@ public function register_routes() { ], ] ); + + $methods = array_keys( dokan_withdraw_get_methods() ); + + register_rest_route( + $this->namespace, '/' . $this->rest_base . '/make-default-method', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'handle_make_default_method' ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + 'args' => [ + 'method' => [ + 'description' => __( 'Withdraw method key', 'dokan-lite' ), + 'type' => 'string', + 'required' => true, + 'enum' => $methods, + ], + ], + ] + ); } /** @@ -54,11 +73,13 @@ public function register_routes() { * @return WP_REST_Response|WP_Error */ public function get_withdraw_settings() { + $active_methods = dokan_withdraw_get_withdrawable_active_methods(); $payment_methods = array_intersect( dokan_get_seller_active_withdraw_methods(), dokan_withdraw_get_active_methods() ); $default_withdraw_method = dokan_withdraw_get_default_method( dokan_get_current_user_id() ); + $setup_url = dokan_get_navigation_url( 'settings/payment' ); $payment_methods = array_map( - function( $payment_method ) { + function ( $payment_method ) { return [ 'label' => dokan_withdraw_get_method_title( $payment_method ), 'value' => $payment_method, @@ -66,10 +87,24 @@ function( $payment_method ) { }, $payment_methods ); + $active_methods = array_map( + function ( $active_method ) { + return [ + 'label' => dokan_withdraw_get_method_title( $active_method ), + 'value' => $active_method, + 'icon' => dokan_withdraw_get_method_icon( $active_method ), + 'info' => dokan_withdraw_get_method_additional_info( $active_method ), + 'has_information' => in_array( $active_method, dokan_get_seller_active_withdraw_methods(), true ), + ]; + }, $active_methods + ); + return rest_ensure_response( [ 'withdraw_method' => $default_withdraw_method, 'payment_methods' => $payment_methods, + 'active_methods' => $active_methods, + 'setup_url' => $setup_url, ] ); } @@ -85,4 +120,30 @@ public function get_withdraw_summary() { $summary = dokan()->withdraw->get_user_withdraw_summary(); return rest_ensure_response( $summary ); } + + /**` + * Make a withdraw method default for a vendor. + * + * @since DOKAN_SINCE + * + * @param WP_REST_Request $request + * + * @return WP_REST_Response|WP_Error + */ + public function handle_make_default_method( WP_REST_Request $request ) { + $method = $request->get_param( 'method' ); + + if ( empty( $method ) ) { + return new WP_Error( 'no_method', __( 'Please provide Withdraw method.', 'dokan-lite' ), [ 'status' => 400 ] ); + } + + if ( ! in_array( $method, dokan_withdraw_get_active_methods(), true ) ) { + return new WP_Error( 'method_not_active', __( 'Method not active.', 'dokan-lite' ), [ 'status' => 400 ] ); + } + + $user_id = dokan_get_current_user_id(); + update_user_meta( $user_id, 'dokan_withdraw_default_method', $method ); + + return new WP_REST_Response( __( 'Default method update successful.', 'dokan-lite' ), 200 ); + } } diff --git a/includes/VendorNavMenuChecker.php b/includes/VendorNavMenuChecker.php new file mode 100644 index 0000000000..f2bb440a22 --- /dev/null +++ b/includes/VendorNavMenuChecker.php @@ -0,0 +1,359 @@ + [ ['slug' => 'template-slug', 'name' => 'template-name' (Optional), 'args' = [] (Optional) ] ] ] + */ + protected array $template_dependencies = [ + 'withdraw' => [ + [ 'slug' => 'withdraw/withdraw-dashboard' ], + [ 'slug' => 'withdraw/withdraw' ], + [ 'slug' => 'withdraw/header' ], + [ 'slug' => 'withdraw/status-listing' ], + [ 'slug' => 'withdraw/pending-request-listing' ], + [ 'slug' => 'withdraw/approved-request-listing' ], + [ 'slug' => 'withdraw/cancelled-request-listing' ], + [ 'slug' => 'withdraw/tmpl-withdraw-request-popup' ], + [ 'slug' => 'withdraw/request-form' ], + [ 'slug' => 'withdraw/pending-request-listing-dashboard' ], + ], + ]; + + + /** + * Forcefully resolved dependencies. + * + * Using `dokan_is_dashboard_nav_dependency_resolved` filter hook. + * + * @since DOKAN_SINCE + * + * @var array $forcefully_resolved_dependencies List of forcefully resolved dependencies. + */ + protected array $forcefully_resolved_dependencies = []; + + /** + * Constructor. + */ + + public function __construct() { + add_filter( 'dokan_get_dashboard_nav', [ $this, 'convert_to_react_menu' ], 999 ); + add_filter( 'dokan_admin_notices', [ $this, 'display_notice' ] ); + add_action( 'dokan_status_after_describing_elements', [ $this, 'add_status_section' ] ); + } + + /** + * Get template dependencies. + * + * @since DOKAN_SINCE + * + * @return array + */ + public function get_template_dependencies(): array { + return apply_filters( 'dokan_get_dashboard_nav_template_dependency', $this->template_dependencies ); + } + + /** + * Convert menu items to react menu items + * + * @since DOKAN_SINCE + * + * @param array $menu_items Menu items. + * + * @return array + */ + + public function convert_to_react_menu( array $menu_items ): array { + return array_map( + function ( $item ) { + if ( ! empty( $item['react_route'] ) && $this->is_dependency_resolved( $item['react_route'] ) ) { + $item['url'] = $this->get_url_for_route( $item['react_route'] ); + } + if ( isset( $item['submenu'] ) ) { + $item['submenu'] = $this->convert_to_react_menu( $item['submenu'] ); + } + + return $item; + }, $menu_items + ); + } + + /** + * Check if the dependency is cleared or not. + * + * @since DOKAN_SINCE + * + * @param string $route Route. + * + * @return bool + */ + protected function is_dependency_resolved( string $route ): bool { + $clear = true; + $dependencies = $this->get_template_dependencies_resolutions(); + + if ( ! empty( $dependencies[ trim( $route, '/' ) ] ) ) { + $clear = false; + } + + $filtered_clear = apply_filters( 'dokan_is_dashboard_nav_dependency_resolved', $clear, $route ); + + if ( $clear !== $filtered_clear ) { + $this->forcefully_resolved_dependencies[ $route ] = $filtered_clear; + } + + return $filtered_clear; + } + + /** + * List forcefully resolved dependencies. + * + * @since DOKAN_SINCE + * + * @return array + */ + public function list_force_dependency_resolved_alteration(): array { + // Forcefully rebuild dependencies resolutions. + dokan_get_dashboard_nav(); + + return $this->forcefully_resolved_dependencies; + } + + /** + * Get URL for the route. + * + * @since DOKAN_SINCE + * + * @param string $route Route. + * + * @return string + */ + protected function get_url_for_route( string $route ): string { + $route = apply_filters( 'dokan_get_url_for_react_route', $route ); + + return dokan_get_navigation_url( 'new' ) . '#' . trim( $route, '/' ); + } + + /** + * Get template dependencies resolutions. + * + * @since DOKAN_SINCE + * + * @return array + */ + protected function get_template_dependencies_resolutions(): array { + $dependencies = $this->get_template_dependencies(); + + $resolved_dependencies = array_map( + fn( $dependency_array ): array => array_filter( + array_map( + fn( $dependency ) => $this->get_overridden_template( + $dependency['slug'], + $dependency['name'] ?? '', + $dependency['args'] ?? [] + ), + $dependency_array + ) + ), + $dependencies + ); + + return apply_filters( 'dokan_get_dashboard_nav_template_dependency_resolutions', $resolved_dependencies ); + } + + /** + * Get overridden template part path. + * + * @since DOKAN_SINCE + * + * @param string $slug Template slug. + * @param string $name Template name. + * @param array $args Arguments. + * + * @return false|string Returns the template file if found otherwise false. + */ + protected function get_overridden_template( string $slug, string $name = '', array $args = [] ) { + $defaults = [ 'pro' => false ]; + $args = wp_parse_args( $args, $defaults ); + $template = ''; + $default_template = ''; + + // Look in yourtheme/dokan/slug-name.php and yourtheme/dokan/slug.php + $template_path = ! empty( $name ) ? "{$slug}-{$name}.php" : "{$slug}.php"; + $template = locate_template( [ dokan()->template_path() . $template_path ] ); + + /** + * Change template directory path filter + * + * @since 2.5.3 + */ + $template_path = apply_filters( 'dokan_set_template_path', dokan()->plugin_path() . '/templates', $template, $args ); + + // Get default slug-name.php + if ( ! $template && $name && file_exists( $template_path . "/{$slug}-{$name}.php" ) ) { + $template = $template_path . "/{$slug}-{$name}.php"; + $default_template = $template; + } + + if ( ! $template && ! $name && file_exists( $template_path . "/{$slug}.php" ) ) { + $template = $template_path . "/{$slug}.php"; + $default_template = $template; + } + + // Allow 3rd party plugin filter template file from their plugin + $template = apply_filters( 'dokan_get_template_part', $template, $slug, $name ); + + return $template && $default_template !== $template ? $template : false; + } + + /** + * List overridden templates. + * + * @since DOKAN_SINCE + * + * @return array + */ + public function list_overridden_templates(): array { + $dependencies = $this->get_template_dependencies_resolutions(); + $overridden_templates = []; + foreach ( $dependencies as $dependency ) { + $overridden_templates = array_merge( $overridden_templates, $dependency ); + } + + return $overridden_templates; + } + + /** + * Display notice if templates are overridden. + * + * @since DOKAN_SINCE + * + * @param array $notices Notices. + * + * @return array + */ + public function display_notice( array $notices ): array { + $overridden_templates = $this->list_overridden_templates(); + $overridden_routes = $this->list_force_dependency_resolved_alteration(); + + if ( empty( $overridden_templates ) && empty( $overridden_routes ) ) { + return $notices; + } + + $notices[] = [ + 'type' => 'alert', + 'scope' => 'global', + 'title' => esc_html__( 'Some of Dokan Templates or functionalities are overridden which limit new features.', 'dokan-lite' ), + 'description' => esc_html__( 'Some of the Dokan templates or routes are overridden, which can prevent new features and intended functionalities from working correctly.', 'dokan-lite' ), + 'actions' => [ + [ + 'type' => 'primary', + 'text' => esc_html__( 'Learn More', 'dokan-lite' ), + 'action' => admin_url( 'admin.php?page=dokan-status' ), + 'target' => '_blank', + ], + ], + ]; + + return $notices; + } + + /** + * Add template dependencies to status page. + * + * @since DOKAN_SINCE + * + * @return void + * @throws Exception + */ + public function add_status_section( Status $status ) { + $overridden_templates = $this->list_overridden_templates(); + $overridden_routes = $this->list_force_dependency_resolved_alteration(); + + if ( empty( $overridden_templates ) && empty( $overridden_routes ) ) { + return; + } + + if ( ! empty( $overridden_templates ) ) { + $template_table = StatusElementFactory::table( 'override_templates_table' ) + ->set_title( __( 'Overridden Template Table', 'dokan-lite' ) ) + ->set_headers( + [ + __( 'Template', 'dokan-lite' ), + ] + ); + + foreach ( $overridden_templates as $id => $template ) { + $template_table->add( + StatusElementFactory::table_row( 'override_row_' . $id ) + ->add( + StatusElementFactory::table_column( 'template_' . $id ) + ->add( + StatusElementFactory::paragraph( 'file_location_' . $id ) + ->set_title( '' . $template . '' ) + ) + ->add( + StatusElementFactory::paragraph( 'file_location_' . $id . '_instruction' ) + ->set_title( __( 'Please Remove the above file to enable new features.', 'dokan-lite' ) ) + ) + ) + ); + } + } + + if ( ! empty( $overridden_routes ) ) { + $route_table = StatusElementFactory::table( 'override_features_table' ) + ->set_title( __( 'Overridden Template Table', 'dokan-lite' ) ) + ->set_headers( + [ + __( 'Route', 'dokan-lite' ), + __( 'Override Status', 'dokan-lite' ), + ] + ); + + foreach ( $overridden_routes as $route => $clearance ) { + $route_table->add( + StatusElementFactory::table_row( 'override_feature_row_' . $route ) + ->add( + StatusElementFactory::table_column( 'route_coll_' . $route ) + ->add( + StatusElementFactory::paragraph( 'route_' . $route ) + ->set_title( '' . $route . '' ) + ) + ) + ->add( + StatusElementFactory::table_column( 'status_coll_' . $route ) + ->add( + StatusElementFactory::paragraph( 'status_' . $route ) + ->set_title( $clearance ? __( 'Forcefully enabled new feature.', 'dokan-lite' ) : __( 'Forcefully disabled new feature.', 'dokan-lite' ) ) + ) + ) + ); + } + } + + $section = StatusElementFactory::section( 'overridden_features' ) + ->set_title( __( 'Overridden Templates or Routes', 'dokan-lite' ) ) + ->set_description( __( 'The listed templates or vendor dashboard routes are currently overridden, which are preventing enabling new features.', 'dokan-lite' ) ); + + if ( ! empty( $overridden_templates ) ) { + $section->add( $template_table ); + } + + if ( ! empty( $overridden_routes ) ) { + $section->add( $route_table ); + } + + $status->add( + $section + ); + } +} diff --git a/includes/Withdraw/Hooks.php b/includes/Withdraw/Hooks.php index 23acb51a9c..e609f83da3 100644 --- a/includes/Withdraw/Hooks.php +++ b/includes/Withdraw/Hooks.php @@ -15,6 +15,7 @@ class Hooks { */ public function __construct() { add_action( 'init', [ $this, 'download_withdraw_log_export_file' ] ); + add_action( 'dokan_react_frontend_localized_args', [ $this, 'localize_withdraw_scripts' ] ); add_action( 'dokan_withdraw_request_approved', [ $this, 'update_vendor_balance' ], 11 ); // change custom withdraw method title add_filter( 'dokan_get_withdraw_method_title', [ $this, 'dokan_withdraw_dokan_custom_method_title' ], 10, 3 ); @@ -49,6 +50,21 @@ public function download_withdraw_log_export_file() { $exporter->export(); } + /** + * Dokan withdraw localize scripts. + * + * @since DOKAN_SINCE + * + * @param array $localized_args + * + * @return array + */ + public function localize_withdraw_scripts( $localized_args ) { + $localized_args['withdraw'] = [ 'paymentSettingUrl' => dokan_get_navigation_url( 'settings/payment' ) ]; + + return $localized_args; + } + /** * Dokan Custom Withdraw Method Title * diff --git a/includes/functions-dashboard-navigation.php b/includes/functions-dashboard-navigation.php index 97301b5af5..ee5c8de5d1 100644 --- a/includes/functions-dashboard-navigation.php +++ b/includes/functions-dashboard-navigation.php @@ -48,11 +48,12 @@ function dokan_get_dashboard_nav(): array { 'permission' => 'dokan_view_order_menu', ], 'withdraw' => [ - 'title' => __( 'Withdraw', 'dokan-lite' ), - 'icon' => '', - 'url' => dokan_get_navigation_url( 'withdraw' ), - 'pos' => 70, - 'permission' => 'dokan_view_withdraw_menu', + 'title' => __( 'Withdraw', 'dokan-lite' ), + 'icon' => '', + 'url' => dokan_get_navigation_url( 'withdraw' ), + 'pos' => 70, + 'permission' => 'dokan_view_withdraw_menu', + 'react_route' => 'withdraw', ], 'settings' => [ 'title' => __( 'Settings', 'dokan-lite' ), @@ -259,10 +260,12 @@ function dokan_dashboard_nav( $active_menu = '' ) { } $submenu .= sprintf( - '', + /* translators: 1) submenu class, 2) submenu route, 3) submenu icon, 4) submenu title */ + '', $submenu_class, - isset( $sub['url'] ) ? $sub['url'] : dokan_get_navigation_url( "{$key}/{$sub_key}" ), - isset( $sub['icon'] ) ? $sub['icon'] : '', + $sub['react_route'] ?? '', + $sub['url'] ?? dokan_get_navigation_url( "{$key}/{$sub_key}" ), + $sub['icon'] ?? '', apply_filters( 'dokan_vendor_dashboard_menu_title', $submenu_title, $sub ) ); @@ -278,11 +281,13 @@ function dokan_dashboard_nav( $active_menu = '' ) { } $menu .= sprintf( - '
  • %s %s%s
  • ', + /* translators: 1) menu class, 2) menu route, 3) menu url, 4) menu target, 5) menu icon, 6) menu title, 7) submenu */ + '
  • %5$s %6$s%7$s
  • ', $class, - isset( $item['url'] ) ? $item['url'] : dokan_get_navigation_url( $menu_slug ), - isset( $item['target'] ) ? $item['target'] : '_self', - isset( $item['icon'] ) ? $item['icon'] : '', + $item['react_route'] ?? '', + $item['url'] ?? dokan_get_navigation_url( $menu_slug ), + $item['target'] ?? '_self', + $item['icon'] ?? '', apply_filters( 'dokan_vendor_dashboard_menu_title', $title, $item ), $submenu ); diff --git a/package.json b/package.json index d6ea093325..9b3f90c6a1 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "release:dev": "npm install && npm run build && npm run clean-files && npm run makepot && npm run zip" }, "devDependencies": { - "@wordpress/scripts": "^27.9.0", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@wordpress/scripts": "^30.7.0", "chartjs-adapter-moment": "^1.0.1", "debounce": "^1.2.1", "fs-extra": "^10.1.0", @@ -33,7 +35,9 @@ "mini-css-extract-plugin": "^2.7.6", "papaparse": "^5.4.1", "replace-in-file": "^6.3.5", + "tailwind-merge": "^2.6.0", "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 +54,22 @@ "wp-readme-to-markdown": "^1.0.1" }, "dependencies": { - "@wordpress/i18n": "^5.8.0" + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@getdokan/dokan-ui": "github:getdokan/dokan-ui#dokan-plugin", + "@headlessui/react": "^2.2.0", + "@wordpress/api-fetch": "^7.14.0", + "@wordpress/components": "^28.9.0", + "@wordpress/data": "^10.9.0", + "@wordpress/dataviews": "^4.10.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", + "@wordpress/url": "^4.15.0", + "react-router-dom": "^6.27.0", + "tailwind-merge": "^2.5.5", + "usehooks-ts": "^3.1.0" } } diff --git a/src/Status/Elements/Button.tsx b/src/Status/Elements/Button.tsx new file mode 100644 index 0000000000..ded2908299 --- /dev/null +++ b/src/Status/Elements/Button.tsx @@ -0,0 +1,38 @@ +import { StatusElement } from '../Status'; +import { RawHTML, useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; + +const Button = ( { element }: { element: StatusElement } ) => { + const [ isClicked, setIsClicked ] = useState( false ); + const onClick = () => { + setIsClicked( true ); + + const path = + 'GET' === element.request + ? addQueryArgs( element.endpoint, element.payload ) + : element.endpoint; + + const args = { + path: element.endpoint, + method: element.request, + data: 'GET' !== element.request ? element.payload : {}, + }; + + apiFetch( args ).then( ( response ) => { + setIsClicked( false ); + } ); + }; + return ( + + ); +}; +export default Button; diff --git a/src/Status/Elements/Heading.tsx b/src/Status/Elements/Heading.tsx new file mode 100644 index 0000000000..23ab623a3b --- /dev/null +++ b/src/Status/Elements/Heading.tsx @@ -0,0 +1,31 @@ +import { StatusElement } from '../Status'; +import SettingsParser from '../SettingsParser'; +import { RawHTML } from '@wordpress/element'; + +const Heading = ( { element }: { element: StatusElement } ) => { + return ( +
    +
    +

    + { element.title } +

    +
    +
    + { ( element?.children || [] ).map( ( child ) => { + return ( + + ); + } ) } +
    +
    + ); +}; +export default Heading; diff --git a/src/Status/Elements/Link.tsx b/src/Status/Elements/Link.tsx new file mode 100644 index 0000000000..c92515668c --- /dev/null +++ b/src/Status/Elements/Link.tsx @@ -0,0 +1,17 @@ +import { StatusElement } from '../Status'; +import SettingsParser from '../SettingsParser'; +import { RawHTML } from '@wordpress/element'; + +const Link = ( { element }: { element: StatusElement } ) => { + return ( + + { element.title } + + ); +}; +export default Link; diff --git a/src/Status/Elements/Paragraph.tsx b/src/Status/Elements/Paragraph.tsx new file mode 100644 index 0000000000..29017f2b7e --- /dev/null +++ b/src/Status/Elements/Paragraph.tsx @@ -0,0 +1,14 @@ +import { StatusElement } from '../Status'; +import { RawHTML } from '@wordpress/element'; + +const Paragraph = ( { element }: { element: StatusElement } ) => { + return ( +

    + { element.title } +

    + ); +}; +export default Paragraph; diff --git a/src/Status/Elements/Section.tsx b/src/Status/Elements/Section.tsx new file mode 100644 index 0000000000..d6b0cf868c --- /dev/null +++ b/src/Status/Elements/Section.tsx @@ -0,0 +1,32 @@ +import { StatusElement } from '../Status'; +import SettingsParser from '../SettingsParser'; + +const Section = ( { element }: { element: StatusElement } ) => { + return ( +
    +
    +

    + { element.title } +

    + { element.description && ( +

    + { element.description } +

    + ) } +
    +
    + { ( element?.children || [] ).map( ( child ) => { + return ( + + ); + } ) } +
    +
    + ); +}; +export default Section; diff --git a/src/Status/Elements/SubSection.tsx b/src/Status/Elements/SubSection.tsx new file mode 100644 index 0000000000..86ccd46fd8 --- /dev/null +++ b/src/Status/Elements/SubSection.tsx @@ -0,0 +1,32 @@ +import { StatusElement } from '../Status'; +import SettingsParser from '../SettingsParser'; + +const SubSection = ( { element }: { element: StatusElement } ) => { + return ( + <> +
    +

    + { element.title } +

    + { element.description && ( +

    + { element.description } +

    + ) } +
    +
    + { ( element?.children || [] ).map( ( child ) => { + return ( + + ); + } ) } +
    + + ); +}; +export default SubSection; diff --git a/src/Status/Elements/Table.tsx b/src/Status/Elements/Table.tsx new file mode 100644 index 0000000000..3c3b3bf1ef --- /dev/null +++ b/src/Status/Elements/Table.tsx @@ -0,0 +1,51 @@ +import { StatusElement } from '../Status'; +import SettingsParser from '../SettingsParser'; + +const Table = ( { element }: { element: StatusElement } ) => { + return ( +
    + + { element.headers.length > 0 && ( + + + { element.headers.map( ( header: string ) => { + return ( + + ); + } ) } + + + ) } + + { ( element?.children || [] ).map( ( child ) => { + return ( + + ); + } ) } + +
    + { header } +
    +
    + ); +}; +export default Table; diff --git a/src/Status/Elements/TableColumn.tsx b/src/Status/Elements/TableColumn.tsx new file mode 100644 index 0000000000..4e155997d2 --- /dev/null +++ b/src/Status/Elements/TableColumn.tsx @@ -0,0 +1,21 @@ +import { StatusElement } from '../Status'; +import SettingsParser from '../SettingsParser'; + +const TableColumn = ( { element }: { element: StatusElement } ) => { + return ( + + { ( element?.children || [] ).map( ( child ) => { + return ( + + ); + } ) } + + ); +}; +export default TableColumn; diff --git a/src/Status/Elements/TableRow.tsx b/src/Status/Elements/TableRow.tsx new file mode 100644 index 0000000000..e43e7a9ec4 --- /dev/null +++ b/src/Status/Elements/TableRow.tsx @@ -0,0 +1,18 @@ +import { StatusElement } from '../Status'; +import SettingsParser from '../SettingsParser'; + +const TableRow = ( { element }: { element: StatusElement } ) => { + return ( + + { ( element?.children || [] ).map( ( child ) => { + return ( + + ); + } ) } + + ); +}; +export default TableRow; diff --git a/src/Status/Menu.tsx b/src/Status/Menu.tsx new file mode 100644 index 0000000000..e76e211235 --- /dev/null +++ b/src/Status/Menu.tsx @@ -0,0 +1,51 @@ +import { StatusElement } from './Status'; + +function classNames( ...classes ) { + return classes.filter( Boolean ).join( ' ' ); +} + +const Menu = ( { + pages, + loading, + activePage, + onMenuClick, +}: { + pages: StatusElement[]; + loading: boolean; + activePage: string; + onMenuClick: ( page: string ) => void; +} ): JSX.Element => { + return ( + + ); +}; + +export default Menu; diff --git a/src/Status/SettingsParser.tsx b/src/Status/SettingsParser.tsx new file mode 100644 index 0000000000..25ac92309c --- /dev/null +++ b/src/Status/SettingsParser.tsx @@ -0,0 +1,43 @@ +import { StatusElement } from './Status'; +import Section from './Elements/Section'; +import SubSection from './Elements/SubSection'; +import Heading from './Elements/Heading'; +import Table from './Elements/Table'; +import TableRow from './Elements/TableRow'; +import TableColumn from './Elements/TableColumn'; +import Paragraph from './Elements/Paragraph'; +import Link from './Elements/Link'; +import Button from './Elements/Button'; + +const SettingsParser = ( { element }: { element: StatusElement } ) => { + switch ( element.type ) { + case 'section': + return
    ; + case 'sub-section': + return ; + case 'table': + return ; + case 'table-row': + return ; + case 'table-column': + return ; + case 'heading': + return ; + case 'paragraph': + return ; + case 'link': + return ; + case 'button': + return + + +
    + + +
    +
    + +
    + + +
    +
    + + + + + + ); +}; + +export default Header; diff --git a/src/admin/dashboard/components/Layout.tsx b/src/admin/dashboard/components/Layout.tsx new file mode 100644 index 0000000000..d3dc29b8c0 --- /dev/null +++ b/src/admin/dashboard/components/Layout.tsx @@ -0,0 +1,17 @@ +import { SlotFillProvider } from '@wordpress/components'; +import { PluginArea } from '@wordpress/plugins'; +import { DokanToaster } from '@getdokan/dokan-ui'; +import Header from './Header'; + +const Layout = ( { children, route } ) => { + return ( + +
    + { children } + + + + ); +}; + +export default Layout; diff --git a/src/admin/dashboard/index.tsx b/src/admin/dashboard/index.tsx new file mode 100644 index 0000000000..1238b4db6f --- /dev/null +++ b/src/admin/dashboard/index.tsx @@ -0,0 +1,12 @@ +import { createRoot } from '@wordpress/element'; +import domReady from '@wordpress/dom-ready'; +import Dashboard from './components/Dashboard'; + +const dashboardDomNode = document.getElementById( 'dokan-admin-dashboard' ); +const dashboardRoot = createRoot( dashboardDomNode! ); + +domReady( () => { + if ( dashboardDomNode ) { + dashboardRoot.render( ); + } +} ); diff --git a/src/admin/dashboard/style.scss b/src/admin/dashboard/style.scss new file mode 100644 index 0000000000..83d4a12d7f --- /dev/null +++ b/src/admin/dashboard/style.scss @@ -0,0 +1,2 @@ +@config './admin-dashboard-tailwind.config.js'; +@use '../../base-tailwind'; diff --git a/src/base-tailwind.scss b/src/base-tailwind.scss new file mode 100644 index 0000000000..d386df7384 --- /dev/null +++ b/src/base-tailwind.scss @@ -0,0 +1,50 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +// Reset unwanted table styles but keep structure intact +@layer base { + :root { + --colors-primary-500: var(--dokan-button-border-color, #F05025); + --wp-components-color-accent: var(--dokan-button-background-color, #F05025); + } + // This is a hack to fix headless ui flickering during .dokan-layout class addition with MutationObserver. + // This is a temporary fix until headless ui provides a better solution. + #headlessui-portal-root { + display: none; + } + .bg-primary-500 { + @apply bg-dokan-btn focus:outline-dokan-btn; + } + .dokan-layout { + table:not(.dataviews-view-table), + table:not(.dataviews-view-table) th, + table:not(.dataviews-view-table) 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; + } + + a:focus, button:focus, .button.alt:focus, input:focus, textarea:focus, input[type="button"]:focus, input[type="reset"]:focus, input[type="submit"]:focus, input[type="email"]:focus, input[type="tel"]:focus, input[type="url"]:focus, input[type="password"]:focus, input[type="search"]:focus { + outline-color: var(--dokan-button-border-color, #F05025); + } + } + + button[data-headlessui-state="checked"] { + &:hover, &:focus { + background-color: var(--dokan-button-background-color, #F05025) !important; + } + } + div[data-headlessui-state="open"][role="dialog"] { + // we are using z-index: 999 to make sure the modal is on top of everything except dokan ui toast + // When changing this value, make sure to check if toast is still on top of the modal + z-index: 999; + } +} diff --git a/src/components/DateTimeHtml.tsx b/src/components/DateTimeHtml.tsx new file mode 100644 index 0000000000..002d1d8c05 --- /dev/null +++ b/src/components/DateTimeHtml.tsx @@ -0,0 +1,67 @@ +import { RawHTML } from '@wordpress/element'; +import '../Definitions/window-types'; +import { dateI18n, getSettings } from '@wordpress/date'; + +function DateTimeHtml( { + date, + defaultDate = '-', +}: { + date: string; + defaultDate?: any; +} ) { + if ( ! date ) { + return defaultDate; + } + return ( + + { dateI18n( + getSettings().formats.datetime, + date, + getSettings().timezone.string + ) } + + ); +} + +DateTimeHtml.Date = ( { + date, + defaultDate = '-', +}: { + date: string; + defaultDate?: any; +} ) => { + if ( ! date ) { + return defaultDate; + } + return ( + + { dateI18n( + getSettings().formats.date, + date, + getSettings().timezone.string + ) } + + ); +}; +DateTimeHtml.Time = ( { + time, + defaultTime = '-', +}: { + time: string; + defaultTime?: any; +} ) => { + if ( ! time ) { + return defaultTime; + } + return ( + + { dateI18n( + getSettings().formats.time, + time, + getSettings().timezone.string + ) } + + ); +}; + +export default DateTimeHtml; diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx new file mode 100644 index 0000000000..955c661e1f --- /dev/null +++ b/src/components/Filter.tsx @@ -0,0 +1,78 @@ +import { Button } from '@getdokan/dokan-ui'; +import { __ } from '@wordpress/i18n'; +import { twMerge } from 'tailwind-merge'; +// @ts-ignore +// eslint-disable-next-line import/no-unresolved +import { snakeCase, kebabCase } from '../utilities'; + +interface FilterProps { + /** Namespace for the filter, used to generate unique IDs */ + namespace: string; + /** Array of React nodes representing the filter fields */ + fields?: React.ReactNode[]; + /** Whether to show the reset button */ + showReset?: boolean; + /** Whether to show the filter button */ + showFilter?: boolean; + /** Callback function to handle filter action */ + onFilter?: () => void; + /** Callback function to handle reset action */ + onReset?: () => void; + /** Additional class names for the filter container */ + className?: string; +} + +const Filter = ( { + namespace = '', + fields = [], + showReset = true, + showFilter = true, + onFilter = () => {}, + onReset = () => {}, + className = '', +}: FilterProps ) => { + const snakeCaseNamespace = snakeCase( namespace ); + const filterId = `dokan_${ snakeCaseNamespace }_filters`; + + // @ts-ignore + const filteredFields = wp.hooks.applyFilters( filterId, fields ); + + return ( +
    + { filteredFields.map( ( fieldNode: React.ReactNode, index ) => { + return ( +
    + { fieldNode } +
    + ); + } ) } + + { showFilter && ( +
    + ); +}; + +export default Filter; diff --git a/src/components/PriceHtml.tsx b/src/components/PriceHtml.tsx new file mode 100644 index 0000000000..de489dcf5f --- /dev/null +++ b/src/components/PriceHtml.tsx @@ -0,0 +1,37 @@ +import { RawHTML } from '@wordpress/element'; +import { formatPrice } from '@dokan/utilities'; + +type PriceHtmlProps = { + price: string | number; + currencySymbol?: string; + precision?: number; + thousand?: string; + decimal?: string; + format?: PriceFormat; +}; + +type PriceFormat = '%v%s' | '%s%v' | '%v %s' | '%s %v' | string; + +const PriceHtml = ( { + price = 0, + currencySymbol = '', + precision = null, + thousand = '', + decimal = '', + format = '', +}: PriceHtmlProps ) => { + return ( + + { formatPrice( + price, + currencySymbol, + precision, + thousand, + decimal, + format + ) } + + ); +}; + +export default PriceHtml; diff --git a/src/components/dataviews/DataViewTable.tsx b/src/components/dataviews/DataViewTable.tsx new file mode 100644 index 0000000000..65de28ccb9 --- /dev/null +++ b/src/components/dataviews/DataViewTable.tsx @@ -0,0 +1,87 @@ +import { DataViews } from '@wordpress/dataviews/wp'; +import { Slot } from "@wordpress/components"; +import { ViewportDimensions } from '@dokan/hooks/ViewportDimensions'; +import type { Action, Field, SupportedLayouts, View } from "@wordpress/dataviews/src/types"; +import { kebabCase, snakeCase } from "@dokan/utilities"; +import { useEffect } from "@wordpress/element"; +import { useWindowDimensions } from "@dokan/hooks"; +import './style.scss'; + +type ItemWithId = { id: string }; + +type DataViewsProps< Item > = { + view: View; + namespace: string; + responsive?: boolean; + onChangeView: ( view: View ) => void; + fields: Field< Item >[]; + search?: boolean; + searchLabel?: string; + actions?: Action< Item >[]; + data: Item[]; + isLoading?: boolean; + paginationInfo: { + totalItems: number; + totalPages: number; + }; + ViewportDimensions?: typeof ViewportDimensions; + defaultLayouts: SupportedLayouts; + selection?: string[]; + onChangeSelection?: ( items: string[] ) => void; + onClickItem?: ( item: Item ) => void; + isItemClickable?: ( item: Item ) => boolean; + header?: JSX.Element; +} & ( Item extends ItemWithId + ? { getItemId?: ( item: Item ) => string } + : { getItemId: ( item: Item ) => string } ); + +const applyFiltersToTableElements = (namespace: string, elementName: string, element, props: DataViewsProps) => { + const snakeCaseNamespace = snakeCase(namespace); + return wp.hooks.applyFilters( `dokan_${snakeCaseNamespace}_dataviews_${elementName}`, element, { ...props } ); +}; + +const DataViewTable = ( props: DataViewsProps< Item > ) => { + if ( ! props.namespace ) { + throw new Error( 'Namespace is required for the DataViewTable component' ); + } + + const { width: windowWidth } = useWindowDimensions(); + const { + responsive = true, + onChangeView, + namespace, + actions, + fields, + view, + data, + } = props; + + const filteredProps = { + ...props, + data: applyFiltersToTableElements( namespace, 'data', data, props ), + view: applyFiltersToTableElements( namespace, 'view', view, props ), + fields: applyFiltersToTableElements( namespace, 'fields', fields, props ), + actions: applyFiltersToTableElements( namespace, 'actions', actions, props ), + }; + + const tableNameSpace = kebabCase( namespace ); + if ( responsive ) { // Set view type `list` for mobile device. + useEffect(() => onChangeView({ + ...view, + type: windowWidth <= 768 ? 'list' : 'table', + }), [ windowWidth ]); + } + + return ( +
    + {/* Before dokan data table rendered slot */} + + + {/* After dokan data table rendered slot */} + +
    + ); +}; + +export default DataViewTable; diff --git a/src/components/dataviews/style.scss b/src/components/dataviews/style.scss new file mode 100644 index 0000000000..5a0c8b8d0c --- /dev/null +++ b/src/components/dataviews/style.scss @@ -0,0 +1 @@ +@import "@wordpress/dataviews/build-style/style.css"; diff --git a/src/components/index.tsx b/src/components/index.tsx new file mode 100644 index 0000000000..970a59354e --- /dev/null +++ b/src/components/index.tsx @@ -0,0 +1,13 @@ +export { default as DokanModal } from './modals/DokanModal'; +export { default as DataViews } from './dataviews/DataViewTable'; +export { default as SortableList } from './sortable-list'; + +export { + DataForm, + VIEW_LAYOUTS, + filterSortAndPaginate, + isItemValid +} from '@wordpress/dataviews/wp'; +export { default as PriceHtml } from './PriceHtml'; +export { default as DateTimeHtml } from './DateTimeHtml'; +export { default as Filter } from './Filter'; diff --git a/src/components/modals/DialogIcon.tsx b/src/components/modals/DialogIcon.tsx new file mode 100644 index 0000000000..4658dc2a90 --- /dev/null +++ b/src/components/modals/DialogIcon.tsx @@ -0,0 +1,10 @@ +const DialogIcon = ( { className = '' }: { className?: string } ) => { + return ( + + + + ); +} + +export default DialogIcon; diff --git a/src/components/modals/DokanModal.tsx b/src/components/modals/DokanModal.tsx new file mode 100644 index 0000000000..4f5d58d335 --- /dev/null +++ b/src/components/modals/DokanModal.tsx @@ -0,0 +1,157 @@ +import { __ } from '@wordpress/i18n'; +import { Slot } from '@wordpress/components'; +import { kebabCase } from '@dokan/utilities'; +import { debounce } from '@wordpress/compose'; +import { Modal, Button } from '@getdokan/dokan-ui'; +import { useCallback, useState } from '@wordpress/element'; +import DialogIcon from "./DialogIcon"; + +interface DokanModalProps { + isOpen: boolean; + namespace: string; + className?: string; + onClose: () => void; + dialogTitle?: string; + onConfirm: () => void; + cancelButtonText?: string; + confirmButtonText?: string; + confirmationTitle?: string; + confirmationDescription?: string; + dialogIcon?: JSX.Element; + dialogHeader?: JSX.Element; + dialogContent?: JSX.Element; + dialogFooter?: JSX.Element; + loading?: boolean; +} + +const DokanModal = ({ + isOpen, + onClose, + className, + onConfirm, + namespace, + dialogTitle, + cancelButtonText, + confirmButtonText, + confirmationTitle, + confirmationDescription, + dialogIcon, + dialogHeader, + dialogFooter, + dialogContent, + loading = false, +}: DokanModalProps) => { + if ( ! namespace ) { + throw new Error( 'Namespace is required for the Confirmation Modal component' ); + } + + const dialogNamespace = kebabCase( namespace ); + const [ isSubmitting, setIsSubmitting ] = useState( false ); + + const handleConfirm = useCallback( + debounce( async () => { + setIsSubmitting( true ); + try { + await onConfirm(); + onClose(); + } catch ( error ) { + console.error( 'Confirmation action failed:', error ); + } finally { + setIsSubmitting( false ); + } + }, 300 ), + [ onConfirm, onClose ] + ); + + return ( + +
    + + + + { dialogHeader || ( +
    + { dialogTitle || __( 'Confirmation Dialog', 'dokan-lite' ) } +
    + )} + + +
    + + + + { dialogContent || ( +
    + {dialogIcon || ( +
    + +
    + )} +
    +

    +
    ', + '' + ) + } + } + >
    +
    +
    + )} + + +
    + + { dialogFooter || ( +
    + + +
    + )} +
    +
    +
    + ); +}; + +export default DokanModal; diff --git a/src/components/sortable-list/SortableItem.tsx b/src/components/sortable-list/SortableItem.tsx new file mode 100644 index 0000000000..2e5abba64a --- /dev/null +++ b/src/components/sortable-list/SortableItem.tsx @@ -0,0 +1,32 @@ +import { useSortable } from '@dnd-kit/sortable'; + +interface SortableItemProps { + id: string | number; + renderItem: () => JSX.Element; +} + +const SortableItem = ( { id, renderItem }: SortableItemProps ) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable( { id } ); + + const style = { + transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined, + opacity: isDragging ? 0.6 : 1, + touchAction: 'none', + transition, + }; + + return ( +
    + { renderItem ? renderItem( id ) : id } +
    + ); +}; + +export default SortableItem; diff --git a/src/components/sortable-list/index.tsx b/src/components/sortable-list/index.tsx new file mode 100644 index 0000000000..5f022f3482 --- /dev/null +++ b/src/components/sortable-list/index.tsx @@ -0,0 +1,201 @@ +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type UniqueIdentifier, + type CollisionDetection, + type PointerActivationConstraint, + type DragStartEvent, + type DragCancelEvent, + type DragMoveEvent, + type DragOverEvent, + type DragPendingEvent, + type DragAbortEvent, +} from '@dnd-kit/core'; + +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + horizontalListSortingStrategy, + rectSortingStrategy, + type SortingStrategy, +} from '@dnd-kit/sortable'; + +import { Slot } from "@wordpress/components"; +import { snakeCase, kebabCase } from "@dokan/utilities"; +import SortableItem from './SortableItem'; +import type { Announcements, ScreenReaderInstructions } from "@dnd-kit/core"; +import type { AutoScrollOptions } from "@dnd-kit/core"; +import type { MeasuringConfiguration } from "@dnd-kit/core"; +import { Modifiers } from "@dnd-kit/core"; +import type { SensorDescriptor } from "@dnd-kit/core"; +import type { CancelDrop } from "@dnd-kit/core"; +import type { Disabled } from "@dnd-kit/sortable"; + +type StrategyType = 'horizontal' | 'vertical' | 'grid'; + +interface SortableListProps { + items: T[]; + namespace: string; + onChange?: (items: T[]) => void; + renderItem?: (item: T) => JSX.Element; + orderProperty?: keyof T; + className?: string; + activationConstraint?: PointerActivationConstraint; + strategy?: StrategyType; + keyExtractor?: (item: T) => UniqueIdentifier; + gridColumns?: number; + id?: string; + disabled?: boolean | Disabled; + sortableId?: string; + accessibility?: { + announcements?: Announcements; + container?: Element; + restoreFocus?: boolean; + screenReaderInstructions?: ScreenReaderInstructions; + }; + autoScroll?: boolean | AutoScrollOptions; + cancelDrop?: CancelDrop; + collisionDetection?: CollisionDetection; + measuring?: MeasuringConfiguration; + modifiers?: Modifiers; + sensors?: SensorDescriptor[]; + onDragAbort?: (event: DragAbortEvent) => void; + onDragPending?: (event: DragPendingEvent) => void; + onDragStart?: (event: DragStartEvent) => void; + onDragMove?: (event: DragMoveEvent) => void; + onDragOver?: (event: DragOverEvent) => void; + onDragEnd?: (event: DragEndEvent) => void; + onDragCancel?: (event: DragCancelEvent) => void; +} + +const SortableList = ( props: SortableListProps ): JSX.Element => { + const { + items, + namespace, + onChange, + renderItem, + orderProperty, + className = '', + activationConstraint, + strategy = 'vertical', + keyExtractor = item => (item as any)?.id || item, + gridColumns = 4, + disabled, + sortableId, + } = props; + + const sensors = useSensors( + useSensor( PointerSensor, { + activationConstraint: activationConstraint || { + delay : 5, + tolerance : 5, + }, + }), + useSensor( KeyboardSensor, { + coordinateGetter : sortableKeyboardCoordinates, + }) + ); + + const getSortingStrategy = (): SortingStrategy => { + switch ( strategy ) { + case 'horizontal': + return horizontalListSortingStrategy; + case 'grid': + return rectSortingStrategy; + case 'vertical': + default: + return verticalListSortingStrategy; + } + }; + + const handleDragEnd = ( event: DragEndEvent ) => { + const { active, over } = event; + + if ( active?.id !== over?.id ) { + const oldIndex = items.findIndex( + item => keyExtractor( item ) === active.id + ); + const newIndex = items.findIndex( + item => keyExtractor( item ) === over.id + ); + + const newItems = arrayMove( items, oldIndex, newIndex ); + if ( orderProperty ) { + newItems.forEach( ( item, index ) => { + ( item[ orderProperty ] as number ) = index + 1; + }); + } + + onChange?.( newItems ); + } + + // Call the original onDragEnd if provided + props.onDragEnd?.( event ); + }; + + const itemIds = items.map( keyExtractor ); + const containerNamespace = kebabCase( namespace ); + + const getLayoutClasses = () => { + switch ( strategy ) { + case 'horizontal': + return 'flex flex-row items-center'; + case 'grid': + return `grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-${ gridColumns }`; + case 'vertical': + default: + return 'flex flex-col'; + } + }; + + return ( +
    + + + + +
    + { items.map( ( item ) => ( + renderItem ? renderItem( item ) : null } + /> + )) } +
    +
    +
    + + +
    + ); +}; + +export default SortableList; diff --git a/src/dashboard/Withdraw/Balance.tsx b/src/dashboard/Withdraw/Balance.tsx new file mode 100644 index 0000000000..c2f4269b98 --- /dev/null +++ b/src/dashboard/Withdraw/Balance.tsx @@ -0,0 +1,101 @@ +import { Card } from '@getdokan/dokan-ui'; +import { __ } from '@wordpress/i18n'; +import PriceHtml from '../../Components/PriceHtml'; +import { UseBalanceReturn } from './Hooks/useBalance'; +import '../../Definitions/window-types'; +import { UseWithdrawSettingsReturn } from './Hooks/useWithdrawSettings'; +import { UseWithdrawRequestsReturn } from './Hooks/useWithdrawRequests'; +import RequestWithdrawBtn from './RequestWithdrawBtn'; + +const Loader = () => { + return ( + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; +function Balance( { + bodyData, + settings, + masterLoading, + withdrawRequests, +}: { + bodyData: UseBalanceReturn; + settings: UseWithdrawSettingsReturn; + masterLoading: boolean; + withdrawRequests: UseWithdrawRequestsReturn; +} ) { + if ( + ! bodyData || + ! bodyData.hasOwnProperty( 'isLoading' ) || + bodyData.isLoading || + masterLoading + ) { + return ; + } + return ( + <> + + + + { __( 'Balance', 'dokan' ) } + + + +
    +
    +
    + { __( 'Your Balance:', 'dokan' ) } +   + + + +
    +
    + + { __( + 'Minimum Withdraw Amount: ', + 'dokan' + ) } + +   + + + +
    +
    + +
    +
    +
    + + ); +} + +export default Balance; diff --git a/src/dashboard/Withdraw/Hooks/useBalance.ts b/src/dashboard/Withdraw/Hooks/useBalance.ts new file mode 100644 index 0000000000..a4f1725eba --- /dev/null +++ b/src/dashboard/Withdraw/Hooks/useBalance.ts @@ -0,0 +1,77 @@ +import { useState, useEffect, useCallback } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +interface ChargeData { + fixed: string; + percentage: string; +} + +interface LastWithdraw { + id: number; + user_id: number; + amount: string; + date: string; + status: number; + method: string; + method_title: string; + note: string; + details: Record< any, any >; + ip: string; + charge: number; + receivable: number; + charge_data: ChargeData; +} + +export interface BalanceData { + current_balance: number; + withdraw_limit: string; + withdraw_threshold: number; + withdraw_methods: string[]; + last_withdraw: LastWithdraw; +} + +export interface UseBalanceReturn { + data: BalanceData | null; + isLoading: boolean; + error: Error | null; + refresh: () => void; +} + +export const useBalance = (): UseBalanceReturn => { + const [ data, setData ] = useState< BalanceData | null >( null ); + const [ isLoading, setIsLoading ] = useState< boolean >( true ); + const [ error, setError ] = useState< Error | null >( null ); + + const fetchBalance = useCallback( async () => { + try { + setIsLoading( true ); + setError( null ); + + const response = await apiFetch< BalanceData >( { + path: '/dokan/v1/withdraw/balance', + method: 'GET', + } ); + + setData( response ); + } catch ( err ) { + setError( + err instanceof Error + ? err + : new Error( 'Failed to fetch balance' ) + ); + console.error( 'Error fetching balance:', err ); + } finally { + setIsLoading( false ); + } + }, [] ); + + useEffect( () => { + fetchBalance(); + }, [ fetchBalance ] ); + + const refresh = useCallback( () => { + fetchBalance(); + }, [ fetchBalance ] ); + + return { data, isLoading, error, refresh }; +}; diff --git a/src/dashboard/Withdraw/Hooks/useCharge.ts b/src/dashboard/Withdraw/Hooks/useCharge.ts new file mode 100644 index 0000000000..de6bb70bac --- /dev/null +++ b/src/dashboard/Withdraw/Hooks/useCharge.ts @@ -0,0 +1,54 @@ +import { useState, useCallback } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +interface ChargeData { + fixed: string; + percentage: string; +} + +export interface ChargeResponse { + charge: number; + receivable: number; + charge_data: ChargeData; +} + +export interface UseChargeReturn { + data: ChargeResponse | null; + isLoading: boolean; + error: Error | null; + fetchCharge: ( method: string, amount: string ) => void; +} + +export const useCharge = (): UseChargeReturn => { + const [ data, setData ] = useState< ChargeResponse | null >( null ); + const [ isLoading, setIsLoading ] = useState< boolean >( false ); + const [ error, setError ] = useState< Error | null >( null ); + + const fetchCharge = useCallback( + async ( method: string, amount: string ) => { + try { + setIsLoading( true ); + setError( null ); + + const response = await apiFetch< ChargeResponse >( { + path: `/dokan/v1/withdraw/charge?method=${ method }&amount=${ amount }`, + method: 'GET', + } ); + + setData( response ); + } catch ( err ) { + setError( + err instanceof Error + ? err + : new Error( 'Failed to fetch charge' ) + ); + console.error( 'Error fetching charge:', err ); + } finally { + setIsLoading( false ); + } + }, + [] + ); + + return { data, isLoading, error, fetchCharge }; +}; diff --git a/src/dashboard/Withdraw/Hooks/useMakeDefaultMethod.ts b/src/dashboard/Withdraw/Hooks/useMakeDefaultMethod.ts new file mode 100644 index 0000000000..e8a8c4dcde --- /dev/null +++ b/src/dashboard/Withdraw/Hooks/useMakeDefaultMethod.ts @@ -0,0 +1,44 @@ +import { useState, useCallback } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +interface UseMakeDefaultMethodReturn { + isLoading: boolean; + error: Error | null; + makeDefaultMethod: ( method: string ) => Promise< void >; + makingDefault: string; +} + +export const useMakeDefaultMethod = (): UseMakeDefaultMethodReturn => { + const [ isLoading, setIsLoading ] = useState< boolean >( false ); + const [ error, setError ] = useState< Error | null >( null ); + const [ makingDefault, setMakingDefault ] = useState( '' ); + + const makeDefaultMethod = useCallback( + async ( method: string ): Promise< void > => { + try { + setIsLoading( true ); + setError( null ); + setMakingDefault( method ); + + await apiFetch( { + path: '/dokan/v2/withdraw/make-default-method', + method: 'POST', + data: { method }, + } ); + } catch ( err ) { + setError( + err instanceof Error + ? err + : new Error( 'Failed to make default method' ) + ); + console.error( 'Error making default method:', err ); + } finally { + setIsLoading( false ); + setMakingDefault( '' ); + } + }, + [] + ); + + return { isLoading, error, makeDefaultMethod, makingDefault }; +}; diff --git a/src/dashboard/Withdraw/Hooks/useWithdraw.ts b/src/dashboard/Withdraw/Hooks/useWithdraw.ts new file mode 100644 index 0000000000..c3198acfdc --- /dev/null +++ b/src/dashboard/Withdraw/Hooks/useWithdraw.ts @@ -0,0 +1,81 @@ +import { useState, useCallback } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +interface WithdrawPayload { + method: string; + amount: number; +} + +type UpdateWithdrawPayload = Record< any, any >; + +export interface UseWithdrawReturn { + isLoading: boolean; + error: Error | null; + createWithdraw: ( payload: WithdrawPayload ) => Promise< void >; + updateWithdraw: ( + id: number, + payload: UpdateWithdrawPayload + ) => Promise< void >; +} + +export const useWithdraw = (): UseWithdrawReturn => { + const [ isLoading, setIsLoading ] = useState< boolean >( false ); + const [ error, setError ] = useState< Error | null >( null ); + + const createWithdraw = useCallback( + async ( payload: WithdrawPayload ): Promise< void > => { + try { + setIsLoading( true ); + setError( null ); + + await apiFetch( { + path: '/dokan/v1/withdraw', + method: 'POST', + data: payload, + } ); + } catch ( err ) { + setError( + err instanceof Error + ? err + : new Error( 'Failed to create withdraw' ) + ); + console.error( 'Error creating withdraw:', err ); + throw err; + } finally { + setIsLoading( false ); + } + }, + [] + ); + + const updateWithdraw = useCallback( + async ( + id: number, + payload: UpdateWithdrawPayload + ): Promise< void > => { + try { + setIsLoading( true ); + setError( null ); + + await apiFetch( { + path: `/dokan/v1/withdraw/${ id }`, + method: 'PUT', + data: payload, + } ); + } catch ( err ) { + setError( + err instanceof Error + ? err + : new Error( 'Failed to update withdraw' ) + ); + console.error( 'Error updating withdraw:', err ); + throw err; + } finally { + setIsLoading( false ); + } + }, + [] + ); + + return { isLoading, error, createWithdraw, updateWithdraw }; +}; diff --git a/src/dashboard/Withdraw/Hooks/useWithdrawRequests.ts b/src/dashboard/Withdraw/Hooks/useWithdrawRequests.ts new file mode 100644 index 0000000000..4e1d56a251 --- /dev/null +++ b/src/dashboard/Withdraw/Hooks/useWithdrawRequests.ts @@ -0,0 +1,127 @@ +import { useState, useCallback, useRef } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; + +interface WithdrawRequestPayload { + per_page: number; + page: number; + status: string; + user_id: number; +} + +interface WithdrawRequest { + id: number; + user_id: number; + amount: number; + status: string; + method: string; + created_date: string; +} + +export interface WithdrawTableView { + perPage: number; + page: number; + search: any; + type: string; + titleField: string; +} + +export interface UseWithdrawRequestsReturn { + data: WithdrawRequest[] | null; + isLoading: boolean; + error: Error | null; + fetchWithdrawRequests: ( payload: WithdrawRequestPayload ) => void; + refresh: () => void; + totalItems: number; + totalPages: number; + lastPayload?: WithdrawRequestPayload | null; + view: WithdrawTableView; + setView: ( view: any ) => void; + setData: ( data: any ) => void; +} + +export const useWithdrawRequests = ( + defaultLoader: boolean = false +): UseWithdrawRequestsReturn => { + const [ data, setData ] = useState< WithdrawRequest[] | null >( null ); + const [ totalItems, setTotalItems ] = useState< number >( 0 ); + const [ totalPages, setTotalPages ] = useState< number >( 0 ); + const [ view, setView] = useState< WithdrawTableView >({ + perPage: 10, + page: 1, + search: '', + type: 'table', + titleField: 'amount', + }); + + const [ isLoading, setIsLoading ] = useState< boolean >( defaultLoader ); + const [ error, setError ] = useState< Error | null >( null ); + const lastPayload = useRef< WithdrawRequestPayload | null >( null ); + + const fetchWithdrawRequests = useCallback( + async ( payload: WithdrawRequestPayload ) => { + try { + setIsLoading( true ); + setError( null ); + + if ( lastPayload.current ) { + lastPayload.current = Object.assign( + {}, + lastPayload.current, + payload + ); + } else { + lastPayload.current = payload; + } + + const newURL = addQueryArgs( `/dokan/v1/withdraw`, payload ); + + const response = await apiFetch< WithdrawRequest[] >( { + path: newURL, + parse: false, + } ); + + // @ts-ignore + const responseData: WithdrawRequest[] = await response.json(); + // @ts-ignore + const headers = response.headers; + const responseTotalItems = headers.get( 'X-WP-Total' ); + const responseTotalPages = headers.get( 'X-WP-TotalPages' ); + + setTotalItems( Number( responseTotalItems ) ); + setTotalPages( Number( responseTotalPages ) ); + setData( responseData ); + } catch ( err ) { + setError( + err instanceof Error + ? err + : new Error( 'Failed to fetch withdraw requests' ) + ); + console.error( 'Error fetching withdraw requests:', err ); + } finally { + setIsLoading( false ); + } + }, + [] + ); + + const refresh = useCallback( () => { + if ( lastPayload.current ) { + fetchWithdrawRequests( lastPayload.current ); + } + }, [ fetchWithdrawRequests ] ); + + return { + data, + isLoading, + error, + fetchWithdrawRequests, + refresh, + totalItems, + totalPages, + lastPayload: lastPayload.current, + view, + setView, + setData, + }; +}; diff --git a/src/dashboard/Withdraw/Hooks/useWithdrawSettings.ts b/src/dashboard/Withdraw/Hooks/useWithdrawSettings.ts new file mode 100644 index 0000000000..51760be143 --- /dev/null +++ b/src/dashboard/Withdraw/Hooks/useWithdrawSettings.ts @@ -0,0 +1,68 @@ +import { useState, useEffect, useCallback } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +interface PaymentMethod { + label: string; + value: string; +} + +export interface WithdrawMethod { + label: string; + value: string; + icon: string; + info: string; + has_information: boolean; +} + +export interface WithdrawSettings { + withdraw_method: string; + payment_methods: PaymentMethod[]; + active_methods: Record< string, WithdrawMethod >; + setup_url: string; +} + +export interface UseWithdrawSettingsReturn { + data: WithdrawSettings | null; + isLoading: boolean; + error: Error | null; + refresh: () => void; +} + +export const useWithdrawSettings = (): UseWithdrawSettingsReturn => { + const [ data, setData ] = useState< WithdrawSettings | null >( null ); + const [ isLoading, setIsLoading ] = useState< boolean >( true ); + const [ error, setError ] = useState< Error | null >( null ); + + const fetchSettings = useCallback( async () => { + try { + setIsLoading( true ); + setError( null ); + + const response = await apiFetch< WithdrawSettings >( { + path: '/dokan/v2/withdraw/settings', + method: 'GET', + } ); + + setData( response ); + } catch ( err ) { + setError( + err instanceof Error + ? err + : new Error( 'Failed to fetch withdraw settings' ) + ); + console.error( 'Error fetching withdraw settings:', err ); + } finally { + setIsLoading( false ); + } + }, [] ); + + useEffect( () => { + fetchSettings(); + }, [ fetchSettings ] ); + + const refresh = useCallback( () => { + fetchSettings(); + }, [ fetchSettings ] ); + + return { data, isLoading, error, refresh }; +}; diff --git a/src/dashboard/Withdraw/PaymentDetails.tsx b/src/dashboard/Withdraw/PaymentDetails.tsx new file mode 100644 index 0000000000..68c75fdd17 --- /dev/null +++ b/src/dashboard/Withdraw/PaymentDetails.tsx @@ -0,0 +1,154 @@ +import { Button, Card } from '@getdokan/dokan-ui'; +import { __ } from '@wordpress/i18n'; +import PriceHtml from '../../Components/PriceHtml'; +import DateTimeHtml from '../../Components/DateTimeHtml'; +import { UseBalanceReturn } from './Hooks/useBalance'; +import { UseWithdrawRequestsReturn } from './Hooks/useWithdrawRequests'; +import RequestList from './RequestList'; +import { useNavigate } from 'react-router-dom'; +import { Slot, SlotFillProvider } from '@wordpress/components'; +import { PluginArea } from '@wordpress/plugins'; +import { UseWithdrawSettingsReturn } from './Hooks/useWithdrawSettings'; + +const Loader = () => { + return ( + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; +function PaymentDetails( { + bodyData, + masterLoading, + withdrawRequests, + settings, +}: { + bodyData: UseBalanceReturn; + masterLoading: boolean; + withdrawRequests: UseWithdrawRequestsReturn; + settings: UseWithdrawSettingsReturn; +} ) { + const navigate = useNavigate(); + + if ( + ! bodyData || + ! bodyData.hasOwnProperty( 'isLoading' ) || + bodyData.isLoading || + masterLoading + ) { + return ; + } + + return ( + + + + Payment Details + + +
    +
    +
    +

    + { __( 'Last Payment', 'dokan' ) } +

    + { bodyData?.data?.last_withdraw?.id ? ( +

    + + + +  { __( 'on', 'dokan' ) }  + + + + + +  { __( 'to', 'dokan' ) }  + + { bodyData?.data?.last_withdraw + ?.method_title ?? '' } + +

    + ) : ( +

    + { __( + 'You do not have any approved withdraw yet.', + 'dokan' + ) } +

    + ) } +
    + +
    + + + + { ! withdrawRequests?.isLoading && + withdrawRequests?.data && + Array.isArray( withdrawRequests?.data ) && + withdrawRequests?.data.length > 0 && ( +
    +

    + { __( 'Pending Requests', 'dokan' ) } +

    + + +
    + ) } +
    +
    +
    + + +
    + ); +} + +export default PaymentDetails; diff --git a/src/dashboard/Withdraw/PaymentMethods.tsx b/src/dashboard/Withdraw/PaymentMethods.tsx new file mode 100644 index 0000000000..7eecf2c59c --- /dev/null +++ b/src/dashboard/Withdraw/PaymentMethods.tsx @@ -0,0 +1,168 @@ +import { Button, Card, useToast } from '@getdokan/dokan-ui'; +import { twMerge } from 'tailwind-merge'; +import { + UseWithdrawSettingsReturn, + WithdrawMethod, +} from './Hooks/useWithdrawSettings'; +import { UseBalanceReturn } from './Hooks/useBalance'; +import { __ } from '@wordpress/i18n'; +import { useMakeDefaultMethod } from './Hooks/useMakeDefaultMethod'; + +const Loader = () => { + return ( + + +
    +
    + +
    + { /* PayPal Method */ } +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + { /* Bank Transfer Method */ } +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; +function PaymentMethods( { + bodyData, + masterLoading, +}: { + bodyData: UseWithdrawSettingsReturn; + masterLoading: boolean; +} ) { + const makeDefaultMethodHook = useMakeDefaultMethod(); + const toast = useToast(); + const actionButton = ( activemethod: WithdrawMethod ) => { + if ( + activemethod.has_information && + activemethod?.value === bodyData?.data?.withdraw_method + ) { + return ( + + ) } + + ); + }, + }, + ] + : [] ), + ]; + const [ isOpen, setIsOpen ] = useState( false ); + + const [ cancelRequestId, setCancelRequestId ] = useState( '' ); + const withdrawHook = useWithdraw(); + + const actions = []; + + const toast = useToast(); + + const fallbackData = []; + const canclePendingRequest = () => { + withdrawHook + .updateWithdraw( Number( cancelRequestId ), { + status: 'cancelled', + } ) + .then( () => { + toast( { + type: 'success', + title: __( 'Request cancelled successfully', 'dokan' ), + } ); + withdrawRequests.refresh(); + } ) + .catch( () => { + toast( { + type: 'error', + title: __( 'Failed to cancel request', 'dokan' ), + } ); + } ) + .finally( () => { + setIsOpen( false ); + } ); + }; + + const fetchWithdraw = ( newView ) => { + if ( ! isEqual( newView, withdrawRequests?.view ) ) { + withdrawRequests.setView( newView ); + + withdrawRequests.fetchWithdrawRequests( { + ...withdrawRequests.lastPayload, + page: newView.page, + status, + per_page: newView.perPage, + } ); + } + }; + + return ( + <> + item.id } + onChangeView={ fetchWithdraw } + search={ false } + paginationInfo={ { + totalItems: withdrawRequests?.totalItems, + totalPages: withdrawRequests?.totalPages, + } } + view={ { + ...withdrawRequests?.view, + layout: { ...defaultLayouts }, + fields: fields.map( ( field ) => + field.id !== withdrawRequests?.view?.titleField + ? field.id + : '' + ), + } } + actions={ actions } + isLoading={ loading } + /> + + setIsOpen( false ) } + showXButton={ false } + > + + { __( 'Confirm', 'dokan' ) } + + +

    + { __( + 'Are you sure, you want to cancel this request ?', + 'dokan' + ) } +

    +
    + +
    +
    +
    +
    + + ); +} + +export default RequestList; diff --git a/src/dashboard/Withdraw/RequestWithdrawBtn.tsx b/src/dashboard/Withdraw/RequestWithdrawBtn.tsx new file mode 100644 index 0000000000..e173e4ddba --- /dev/null +++ b/src/dashboard/Withdraw/RequestWithdrawBtn.tsx @@ -0,0 +1,350 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { + Button, + MaskedInput, + Modal, + SimpleAlert, + SimpleInput, + SimpleSelect, + useToast, +} from '@getdokan/dokan-ui'; +import { RawHTML, useEffect, useState } from '@wordpress/element'; +import '../../Definitions/window-types'; +import { useWithdraw } from './Hooks/useWithdraw'; +import { useDebounceCallback } from 'usehooks-ts'; +import { useCharge } from './Hooks/useCharge'; +import { UseWithdrawSettingsReturn } from './Hooks/useWithdrawSettings'; +import { UseWithdrawRequestsReturn } from './Hooks/useWithdrawRequests'; +import { formatPrice } from '@dokan/utilities'; +import { UseBalanceReturn } from './Hooks/useBalance'; + +function RequestWithdrawBtn( { + settings, + withdrawRequests, + balanceData, +}: { + settings: UseWithdrawSettingsReturn; + withdrawRequests: UseWithdrawRequestsReturn; + balanceData: UseBalanceReturn; +} ) { + const [ isOpen, setIsOpen ] = useState( false ); + const [ withdrawAmount, setWithdrawAmount ] = useState( '' ); + const currencySymbol = window?.dokanFrontend?.currency?.symbol ?? ''; + const withdrawHook = useWithdraw(); + const toast = useToast(); + const [ withdrawMethod, setWithdrawMethod ] = useState( '' ); + const { fetchCharge, isLoading, data } = useCharge(); + const hasWithdrawRequests = + withdrawRequests?.data && + Array.isArray( withdrawRequests?.data ) && + withdrawRequests?.data?.length > 0; + const hasPaymentMethods = + settings?.data?.payment_methods && + Array.isArray( settings?.data?.payment_methods ) && + settings?.data?.payment_methods.length > 0; + const hasSuffcientBalance = + Number( balanceData?.data?.current_balance ) >= + Number( balanceData?.data?.withdraw_limit ); + + const unformatNumber = ( value ) => { + if ( value === '' ) { + return value; + } + return window.accounting.unformat( + value, + window?.dokanFrontend?.currency.decimal + ); + }; + + function calculateWithdrawCharge( method, value ) { + fetchCharge( method, value ); + } + + const getRecivableFormated = () => { + if ( ! withdrawAmount ) { + return formatPrice( '', '' ); + } + + return formatPrice( data?.receivable ?? '', '' ); + }; + const getChargeFormated = () => { + let chargeText = ''; + if ( ! withdrawAmount ) { + return formatPrice( '', '' ); + } + + const fixed = data?.charge_data?.fixed + ? Number( data?.charge_data?.fixed ) + : ''; + const percentage = data?.charge_data?.percentage + ? Number( data?.charge_data?.percentage ) + : ''; + + if ( fixed ) { + chargeText += formatPrice( fixed, '' ); + } + + if ( percentage ) { + chargeText += chargeText ? ' + ' : ''; + chargeText += `${ percentage }%`; + chargeText += ` = ${ formatPrice( data?.charge, '' ) }`; + } + + if ( ! chargeText ) { + chargeText = formatPrice( data?.charge, '' ); + } + + return chargeText; + }; + const handleCreateWithdraw = () => { + const payload = { + method: withdrawMethod, + amount: unformatNumber( withdrawAmount ), + }; + + if ( ! payload.amount ) { + toast( { + title: __( 'Withdraw amount is required', 'dokan' ), + type: 'error', + } ); + return; + } + + // Call the createWithdraw function here + withdrawHook + .createWithdraw( payload ) + .then( () => { + setIsOpen( false ); + toast( { + title: __( 'Withdraw request created.', 'dokan' ), + type: 'success', + } ); + + withdrawRequests.refresh(); + } ) + .catch( ( err ) => { + let message = __( 'Failed to create withdraw.', 'dokan' ); + + if ( err?.message ) { + // @ts-ignore + message = { err?.message }; + } + + toast( { + title: message, + type: 'error', + } ); + console.error( 'Error creating withdraw:', err ); + } ); + }; + + function handleWithdrawAmount( value ) { + if ( ! value ) { + value = 0; + } + setWithdrawAmount( value ); + calculateWithdrawCharge( withdrawMethod, unformatNumber( value ) ); + } + + const debouncedWithdrawAmount = useDebounceCallback( + handleWithdrawAmount, + 500 + ); + + const WithdrawRequestForm = () => { + return ( + <> + { hasPaymentMethods ? ( + <> +
    + { + setWithdrawMethod( e.target.value ); + calculateWithdrawCharge( + e.target.value, + unformatNumber( withdrawAmount ) + ); + } } + options={ settings?.data?.payment_methods } + /> +
    +
    + { + debouncedWithdrawAmount( e.target.value ); + } } + maskRule={ { + numeral: true, + numeralDecimalMark: + window?.dokanFrontend?.currency + ?.decimal ?? '.', + delimiter: + window?.dokanFrontend?.currency + ?.thousand ?? ',', + numeralDecimalScale: + window?.dokanFrontend?.currency + ?.precision ?? 2, + } } + input={ { + id: 'withdraw-amount', + name: 'withdraw-amount', + type: 'text', + placeholder: __( 'Enter amount', 'dokan' ), + required: true, + disabled: false, + } } + /> +
    +
    + +
    +
    + +
    + + ) : ( + + + { sprintf( + /* translators: %s: opening and closing anchor tags for "payment methods" link */ + __( + 'No payment methods found to submit a withdrawal request. Please set up your %1$spayment methods%2$s first.', + 'dokan-lite' + ), + ``, + '' + ) } + + + ) } + + ); + }; + + useEffect( () => { + if ( settings?.data?.payment_methods.length > 0 ) { + setWithdrawMethod( settings?.data?.payment_methods[ 0 ].value ); + } + }, [ settings ] ); + + const ModalContect = () => { + if ( hasWithdrawRequests ) { + return ( + + ); + } else if ( ! hasSuffcientBalance ) { + return ( + + ); + } + return ; + }; + + return ( + <> + + + + + + + ); + }; + + return ( + <> +
    + +
    + + ); +} + +export default WithdrawRequests; diff --git a/src/dashboard/Withdraw/index.tsx b/src/dashboard/Withdraw/index.tsx new file mode 100644 index 0000000000..e1169ba391 --- /dev/null +++ b/src/dashboard/Withdraw/index.tsx @@ -0,0 +1,61 @@ +import './tailwind.scss'; +import { useBalance } from './Hooks/useBalance'; +import { useWithdrawSettings } from './Hooks/useWithdrawSettings'; +import { useWithdrawRequests } from './Hooks/useWithdrawRequests'; +import Balance from './Balance'; +import PaymentDetails from './PaymentDetails'; +import PaymentMethods from './PaymentMethods'; +import { useEffect } from '@wordpress/element'; +import { useCurrentUser } from "@dokan/hooks"; + +const Index = () => { + const useWithdrawRequestHook = useWithdrawRequests( true ); + const withdrawSettings = useWithdrawSettings(); + const currentUser = useCurrentUser(); + const balance = useBalance(); + + useEffect( () => { + if ( currentUser?.data ) { + useWithdrawRequestHook.fetchWithdrawRequests( { + per_page: 10, + page: 1, + status: 'pending', + user_id: currentUser?.data?.id ?? 0, + } ); + } + }, [ currentUser?.data ] ); + + return ( + <> +
    + + + +
    + + ); +}; + +export default Index; diff --git a/src/dashboard/Withdraw/tailwind.scss b/src/dashboard/Withdraw/tailwind.scss new file mode 100644 index 0000000000..528679458c --- /dev/null +++ b/src/dashboard/Withdraw/tailwind.scss @@ -0,0 +1,72 @@ +@mixin windraw-reset { + button:focus, + .menu-toggle:hover, + button:hover, + .button:hover, + .ast-custom-button:hover, + input[type=reset]:hover, + input[type=reset]:focus, + input#submit:hover, + input#submit:focus, + input[type="button"]:hover, + input[type="button"]:focus, + input[type="submit"]:hover, + input[type="submit"]:focus { + background-color: var(--dokan-button-hover-color, #F05025); + border-color: var(--dokan-button-hover-background-color, #F05025); + } + + button { + box-shadow: none; + border: none; + } + + input { + border: none; + } + + 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; + } + + input[type="text"][role=combobox] { + height: fit-content; + } + + .border { + border-style: solid; + border-width: 1px; + + &-b { + border-bottom-width: 1px; + border-bottom-style: solid; + } + } +} + +.dokan-withdraw-style-reset { + @include windraw-reset; +} + +#dokan-withdraw-request-data-view { + &.dokan-dashboard-datatable { + .dataviews-wrapper { + .dataviews__view-actions { + display: none; + } + } + } +} + +@config './withdraw-tailwind.config.js'; +@import '../../base-tailwind'; +@import "@getdokan/dokan-ui/dist/dokan-ui.css"; diff --git a/src/dashboard/Withdraw/withdraw-tailwind.config.js b/src/dashboard/Withdraw/withdraw-tailwind.config.js new file mode 100644 index 0000000000..31a9431282 --- /dev/null +++ b/src/dashboard/Withdraw/withdraw-tailwind.config.js @@ -0,0 +1,12 @@ +import baseConfig from '../../../base-tailwind.config'; + +/** @type {import('tailwindcss').Config} */ +const updatedConfig = { + ...baseConfig, + content: [ + ...baseConfig.content, + './src/dashboard/Withdraw/**/*.{js,jsx,ts,tsx}', + ], +}; + +export default updatedConfig; diff --git a/src/dashboard/index.tsx b/src/dashboard/index.tsx new file mode 100644 index 0000000000..b78a5a0879 --- /dev/null +++ b/src/dashboard/index.tsx @@ -0,0 +1,66 @@ +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'; +import { useMutationObserver } from '../hooks'; + +const App = () => { + const routes = getRoutes(); + + const mapedRoutes = routes.map( ( route ) => { + const WithRouterComponent = withRouter( route.element ); + + return { + path: route.path, + element: ( + + + + ), + }; + } ); + + const router = createHashRouter( mapedRoutes ); + + useMutationObserver( + document.body, + ( mutations ) => { + for ( const mutation of mutations ) { + if ( mutation.type !== 'childList' ) { + continue; + } + // @ts-ignore + for ( const node of mutation.addedNodes ) { + if ( node.id !== 'headlessui-portal-root' ) { + continue; + } + + node.classList.add( 'dokan-layout' ); + node.style.display = 'block'; + } + } + }, + { childList: true } + ); + + return ( + <> + + + ); +}; + +domReady( function () { + const rootElement = document.querySelector( + '#dokan-vendor-dashboard-root' + ); + const root = createRoot( rootElement! ); + root.render( ); +} ); diff --git a/src/dashboard/tailwind.scss b/src/dashboard/tailwind.scss new file mode 100644 index 0000000000..09237f265a --- /dev/null +++ b/src/dashboard/tailwind.scss @@ -0,0 +1,8 @@ +@config './../../base-tailwind.config.js'; +@import '../base-tailwind'; + +#headlessui-portal-root { + [role-dialog] { + z-index: 9999; + } +} diff --git a/src/definitions/RouterProps.ts b/src/definitions/RouterProps.ts new file mode 100644 index 0000000000..da59f04939 --- /dev/null +++ b/src/definitions/RouterProps.ts @@ -0,0 +1,18 @@ +import { + Location, + NavigateFunction, + Navigation, + Params, + RedirectFunction, + UIMatch, +} from 'react-router-dom'; + +export interface RouterProps { + navigate: NavigateFunction; + params: Readonly< Params< string > >; + location: Location< any >; + redirect: RedirectFunction; + replace: RedirectFunction; + matches: UIMatch< unknown, unknown >[]; + navigation: Navigation; +} diff --git a/src/definitions/window-types.ts b/src/definitions/window-types.ts new file mode 100644 index 0000000000..d8da91d138 --- /dev/null +++ b/src/definitions/window-types.ts @@ -0,0 +1,39 @@ +import { Hooks } from '@wordpress/hooks'; +import WooCommerceAccounting from './woocommerce-accounting.d'; + +/** + * To get dokan supports just import like a normal js file + * Ex: import '../path.../src/Definitions/window-types'; + */ + +interface Currency { + precision: number; + symbol: string; + decimal: string; + thousand: string; + format: string; +} + +interface Withdraw { + paymentSettingUrl?: string; +} + +interface DokanFrontend { + currency?: Currency; + withdraw?: Withdraw; +} + +declare global { + interface Window extends Window { + wp: { + hooks: Hooks; + }; + dokan_get_daterange_picker_format: () => string; + moment: ( date: string ) => any; + accounting: WooCommerceAccounting.AccountingStatic; + dokanFrontend?: DokanFrontend; + } +} + +// This empty export is necessary to make this a module +export {}; diff --git a/src/definitions/woocommerce-accounting.d.ts b/src/definitions/woocommerce-accounting.d.ts new file mode 100644 index 0000000000..5ef94d7f05 --- /dev/null +++ b/src/definitions/woocommerce-accounting.d.ts @@ -0,0 +1,113 @@ +declare namespace WooCommerceAccounting { + interface Settings { + currency: { + symbol: string; + format: string; + decimal: string; + thousand: string; + precision: number; + grouping: number; + }; + number: { + precision: number; + grouping: number; + thousand: string; + decimal: string; + }; + } + + interface UnformatOptions { + precision?: number|string; + decimal?: string; + thousand?: string; + } + + interface FormatNumberOptions { + precision?: number; + thousand?: string; + decimal?: string; + format?: string; + } + + interface AccountingStatic { + settings: Settings; + + // Formatting methods + formatMoney( + number: number | string, + symbol?: string, + precision?: number, + thousand?: string, + decimal?: string, + format?: string + ): string; + + formatNumber( + number: number | string, + precision?: number, + thousand?: string, + decimal?: string + ): string; + + formatColumn( + list: Array, + symbol?: string, + precision?: number, + thousand?: string, + decimal?: string, + format?: string + ): Array; + + // Parsing methods + unformat( + value: string, + decimal?: string | UnformatOptions + ): number; + + // Utility methods + toFixed( + value: number, + precision?: number + ): string; + + toPrecision( + value: number, + precision?: number + ): string; + + toNumber(value: string | number): number; + + // Helper methods + isNumber(value: any): boolean; + isArray(value: any): boolean; + isObject(value: any): boolean; + isString(value: any): boolean; + isFunction(value: any): boolean; + isDefined(value: any): boolean; + + // Currency formatting helpers + formatPrice( + price: number | string, + args?: FormatNumberOptions + ): string; + + formatWeight( + weight: number | string, + args?: FormatNumberOptions + ): string; + + formatDimension( + dimension: number | string, + args?: FormatNumberOptions + ): string; + + // Currency data methods + getCurrencySymbol(): string; + getCurrencyFormat(): string; + getCurrencyDecimal(): string; + getCurrencyThousand(): string; + getCurrencyPrecision(): number; + } +} + +export default WooCommerceAccounting; diff --git a/src/hooks/ViewportDimensions.ts b/src/hooks/ViewportDimensions.ts new file mode 100644 index 0000000000..ccfad55898 --- /dev/null +++ b/src/hooks/ViewportDimensions.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useCallback } from '@wordpress/element'; + +interface ViewportDimensions { + width: number | null; + height: number | null; +} + +/** + * Hook to track viewport dimensions. + * + * @since DOKAN_PRO_SINCE + * + * @return {ViewportDimensions} The viewport dimensions. + */ +export default function useWindowDimensions() { + const getViewportDimensions = useCallback((): ViewportDimensions => ({ + width: typeof window !== 'undefined' ? window.innerWidth : null, + height: typeof window !== 'undefined' ? window.innerHeight : null, + }), []); + + const [viewport, setViewport] = useState(getViewportDimensions()); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const handleResize = () => { + // Use requestAnimationFrame to throttle updates + window.requestAnimationFrame(() => { + setViewport(getViewportDimensions()); + }); + }; + + window.addEventListener('resize', handleResize); + + // Initial measurement after mount + handleResize(); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [getViewportDimensions]); + + return viewport; +}; diff --git a/src/hooks/ViewportDimensions.tsx b/src/hooks/ViewportDimensions.tsx new file mode 100644 index 0000000000..ccfad55898 --- /dev/null +++ b/src/hooks/ViewportDimensions.tsx @@ -0,0 +1,46 @@ +import { useState, useEffect, useCallback } from '@wordpress/element'; + +interface ViewportDimensions { + width: number | null; + height: number | null; +} + +/** + * Hook to track viewport dimensions. + * + * @since DOKAN_PRO_SINCE + * + * @return {ViewportDimensions} The viewport dimensions. + */ +export default function useWindowDimensions() { + const getViewportDimensions = useCallback((): ViewportDimensions => ({ + width: typeof window !== 'undefined' ? window.innerWidth : null, + height: typeof window !== 'undefined' ? window.innerHeight : null, + }), []); + + const [viewport, setViewport] = useState(getViewportDimensions()); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const handleResize = () => { + // Use requestAnimationFrame to throttle updates + window.requestAnimationFrame(() => { + setViewport(getViewportDimensions()); + }); + }; + + window.addEventListener('resize', handleResize); + + // Initial measurement after mount + handleResize(); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [getViewportDimensions]); + + return viewport; +}; diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx new file mode 100644 index 0000000000..04ba98426a --- /dev/null +++ b/src/hooks/index.tsx @@ -0,0 +1,3 @@ +export { default as useWindowDimensions } from '@dokan/hooks/ViewportDimensions'; +export { useCurrentUser } from '@dokan/hooks/useCurrentUser'; +export { default as useMutationObserver } from './useMutationObserver'; diff --git a/src/hooks/useCurrentUser.ts b/src/hooks/useCurrentUser.ts new file mode 100644 index 0000000000..3f0204f810 --- /dev/null +++ b/src/hooks/useCurrentUser.ts @@ -0,0 +1,60 @@ +import { useSelect } from '@wordpress/data'; + +interface Links { + self: { href: string; targetHints: { allow: string[] } }[]; + collection: { href: string }[]; +} + +interface CurrentUserResponse { + id: number; + name: string; + url: string; + description: string; + link: string; + slug: string; + avatar_urls: Record< any, any >; + meta: any[]; + is_super_admin: boolean; + woocommerce_meta: Record< string, any >; + _links: Links; +} + +export interface UseCurrentUserReturn { + data: CurrentUserResponse | null; + isLoading: boolean; + hasFinished: boolean; + error: Error | null; +} + +export const useCurrentUser = ( + enabled: boolean = true +): UseCurrentUserReturn => { + // @ts-ignore + return useSelect( + ( select ) => { + if ( ! enabled ) { + return { + data: null, + isLoading: false, + hasFinished: false, + error: null, + }; + } + + const store = select( 'core' ); + + return { + // @ts-ignore + data: store.getCurrentUser() as CurrentUserResponse, + // @ts-ignore + isLoading: store.isResolving( 'getCurrentUser', [] ), + // @ts-ignore + hasFinished: store.hasFinishedResolution( + 'getCurrentUser', + [] + ), + }; + }, + [ enabled ] + ); +}; diff --git a/src/hooks/useMutationObserver.ts b/src/hooks/useMutationObserver.ts new file mode 100644 index 0000000000..76bef96f4c --- /dev/null +++ b/src/hooks/useMutationObserver.ts @@ -0,0 +1,21 @@ +/** + * useMutationObserver hook. + * + * @since DOKAN_SINCE + * + * @param {Node} targetNode Target node + * @param {MutationCallback} mutationCallback Callback function + * @param {MutationObserverInit} config (Optional) MutationObserverInit + */ +const useMutationObserver = ( + targetNode: Node, + mutationCallback: MutationCallback, + config?: MutationObserverInit +) => { + const observer = new MutationObserver( mutationCallback ); + observer.observe( targetNode, config ); + + return observer; +}; + +export default useMutationObserver; diff --git a/src/layout/404.tsx b/src/layout/404.tsx new file mode 100644 index 0000000000..917254d3ad --- /dev/null +++ b/src/layout/404.tsx @@ -0,0 +1,33 @@ +import { __ } from '@wordpress/i18n'; + +const NotFound = () => { + // @ts-ignore + const dashBoardUrl = window.dokan?.urls?.dashboardUrl ?? '#'; + + return ( +
    +

    + { __( '404', 'dokan-lite' ) } +

    +

    + { __( 'Page not found', 'dokan-lite' ) } +

    +

    + { __( + 'Sorry, we couldn’t find the page you’re looking for.', + 'dokan-lite' + ) } +

    + +
    + ); +}; + +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..b8c1645d0e --- /dev/null +++ b/src/layout/Header.tsx @@ -0,0 +1,27 @@ +import {Slot} from "@wordpress/components"; +import {useNavigate} from "react-router-dom"; + +const Header = ( { title = '' } ) => { + const navigate = useNavigate(); + + // @ts-ignore + title = wp.hooks.applyFilters( + 'dokan-vendor-dashboard-header-title', + title + ); + + 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..5c74e365c7 --- /dev/null +++ b/src/layout/index.tsx @@ -0,0 +1,110 @@ +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"; +import { useLocation } from 'react-router-dom'; + +// 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; +} + +const handleMenuActiveStates = ( currentPath ) => { + const menuRoute = currentPath.replace( /^\//, '' ); // Remove leading slash. + const menuItem = document.querySelector( `.dokan-dashboard-menu li[data-react-route='${ menuRoute }']` ) || null; + + // Return if menu item not found. + if ( ! menuItem ) { + return; + } + + document.querySelectorAll( '.dokan-dashboard-menu li' ).forEach( item => { + item.classList.remove( 'active' ); + item.querySelectorAll( '.navigation-submenu li' ).forEach( subItem => { + subItem.classList.remove( 'current' ); + }); + }); + + // Get parent menu item if this is a submenu item. + const parentMenuItem = menuItem.closest( '.dokan-dashboard-menu > li' ); + if ( parentMenuItem ) { // Add `active` to parent menu. + parentMenuItem.classList.add( 'active' ); + } + + const subMenuItem = document.querySelector( `.navigation-submenu li[data-react-route='${ menuRoute }']` ); + if ( subMenuItem ) { // Add `current` to submenu item. + subMenuItem.classList.add( 'current' ); + } +}; + +// Create a Layout component that uses the ThemeProvider +const Layout = ( { + children, + route, + title = '', + headerComponent, + footerComponent, +}: LayoutProps ) => { + const location = useLocation(); // Use the location hook to get the current path. + handleMenuActiveStates( location?.pathname ); + + 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..41f5fcb0d9 --- /dev/null +++ b/src/routing/index.tsx @@ -0,0 +1,93 @@ +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, createSearchParams } from 'react-router-dom'; +import Withdraw from "../Dashboard/Withdraw"; +import WithdrawRequests from "../Dashboard/Withdraw/WithdrawRequests"; + +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, + createSearchParams, + }; + + // 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< DokanRoute > = []; + + // routes.push( + // { + // id: 'dokan-base', + // title: __( 'Dashboard', 'dokan-lite' ), + // element:

    Dashboard body

    , + // path: '/', + // exact: true, + // order: 10, + // } + // ); + + routes.push( + { + id: 'dokan-withdraw', + title: __( 'Withdraw', 'dokan-lite' ), + element: , + path: '/withdraw', + exact: true, + order: 10, + } + ); + + routes.push( + { + id: 'dokan-withdraw-requests', + title: __( 'Withdraw', 'dokan-lite' ), + element: , + path: '/withdraw-requests', + 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/utilities/Accounting.ts b/src/utilities/Accounting.ts new file mode 100644 index 0000000000..f7fd4e7e51 --- /dev/null +++ b/src/utilities/Accounting.ts @@ -0,0 +1,73 @@ +import '../definitions/window-types'; + +export const formatPrice = ( + price: number | string = '', + currencySymbol = '', + precision = null, + thousand = '', + decimal = '', + format = '' +): string | number => { + if ( ! window.accounting ) { + console.warn( 'Woocommerce Accounting Library Not Found' ); + return price; + } + + if ( ! window?.dokanFrontend?.currency ) { + console.warn( 'Dokan Currency Data Not Found' ); + return price; + } + + if ( ! currencySymbol ) { + currencySymbol = window?.dokanFrontend?.currency.symbol; + } + + if ( ! precision ) { + precision = window?.dokanFrontend?.currency.precision; + } + + if ( ! thousand ) { + thousand = window?.dokanFrontend?.currency.thousand; + } + + if ( ! decimal ) { + decimal = window?.dokanFrontend?.currency.decimal; + } + + if ( ! format ) { + format = window?.dokanFrontend?.currency.format; + } + + return window.accounting.formatMoney( + price, + currencySymbol, + precision, + thousand, + decimal, + format + ); +}; + +export const formatNumber = ( value ) => { + if ( value === '' ) { + return value; + } + + if ( ! window.accounting ) { + console.warn( 'Woocommerce Accounting Library Not Found' ); + return value; + } + + if ( ! window?.dokanFrontend?.currency ) { + console.warn( 'Dokan Currency Data Not Found' ); + return value; + } + + return window.accounting.formatNumber( + value, + // @ts-ignore + window?.dokanFrontend?.currency.precision, + window?.dokanFrontend?.currency.thousand, + window?.dokanFrontend?.currency.decimal + ); +}; diff --git a/src/utilities/ChangeCase.ts b/src/utilities/ChangeCase.ts new file mode 100644 index 0000000000..c7810ee135 --- /dev/null +++ b/src/utilities/ChangeCase.ts @@ -0,0 +1,13 @@ +export { + camelCase, + capitalCase, + constantCase, + dotCase, + headerCase, + noCase, + paramCase as kebabCase, + pascalCase, + pathCase, + sentenceCase, + snakeCase +} from 'change-case'; diff --git a/src/utilities/index.ts b/src/utilities/index.ts new file mode 100644 index 0000000000..22f98cfb20 --- /dev/null +++ b/src/utilities/index.ts @@ -0,0 +1,2 @@ +export * from './ChangeCase'; +export * from './Accounting'; diff --git a/src/utils/Bootstrap.js b/src/utils/Bootstrap.js index ea9de7940b..3f7875f84d 100644 --- a/src/utils/Bootstrap.js +++ b/src/utils/Bootstrap.js @@ -4,11 +4,11 @@ import Moment from 'moment' import Notifications from 'vue-notification' import ListTable from 'vue-wp-list-table'; import Multiselect from 'vue-multiselect' -import API_Helper from '@/utils/Api' +import API_Helper from '@dokan/utils/Api' import ChartJS from 'vue-chartjs' import Mixin from './Mixin' import Debounce from 'debounce' -import VersionCompare from '@/utils/VersionCompare' +import VersionCompare from '@dokan/utils/VersionCompare' import { parse } from 'papaparse' window.__ = function( text, domain ) { diff --git a/templates/dashboard/new-dashboard.php b/templates/dashboard/new-dashboard.php new file mode 100755 index 0000000000..805c74f2d6 --- /dev/null +++ b/templates/dashboard/new-dashboard.php @@ -0,0 +1,72 @@ + + + +
    + + +
    + + + +
    +
    + +
    +
    + + + + +
    + + + +
    + + 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/tests/php/src/REST/DokanDataContinentsControllerTest.php b/tests/php/src/REST/DokanDataContinentsControllerTest.php new file mode 100644 index 0000000000..46b19f5212 --- /dev/null +++ b/tests/php/src/REST/DokanDataContinentsControllerTest.php @@ -0,0 +1,65 @@ +server->get_routes( $this->namespace ); + $full_route = $this->get_route( $this->rest_base ); + + $this->assertArrayHasKey( $full_route, $routes ); + } + + /** + * Test that the endpoint exist. + */ + public function test_if_get_a_single_continent_api_exists() { + $routes = $this->server->get_routes( $this->namespace ); + $full_route = $this->get_route( $this->rest_base . '/(?P[\w-]+)' ); + + $this->assertArrayHasKey( $full_route, $routes ); + } + + public function test_that_we_can_get_all_continents() { + wp_set_current_user( $this->seller_id1 ); + + $response = $this->get_request( $this->rest_base ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'code', $data[0] ); + $this->assertArrayHasKey( 'name', $data[0] ); + $this->assertArrayHasKey( 'countries', $data[0] ); + } + + public function test_that_we_can_get_single_continent_item_by_code() { + wp_set_current_user( $this->seller_id1 ); + + $response = $this->get_request( $this->rest_base . '/AS' ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'code', $data ); + $this->assertArrayHasKey( 'name', $data ); + $this->assertArrayHasKey( 'countries', $data ); + } +} diff --git a/tests/php/src/REST/DokanDataCountriesControllerTest.php b/tests/php/src/REST/DokanDataCountriesControllerTest.php new file mode 100644 index 0000000000..9da5f694e0 --- /dev/null +++ b/tests/php/src/REST/DokanDataCountriesControllerTest.php @@ -0,0 +1,83 @@ +server->get_routes( $this->namespace ); + $full_route = $this->get_route( $this->rest_base ); + + $this->assertArrayHasKey( $full_route, $routes ); + } + + /** + * Test that the endpoint exist. + */ + public function test_if_get_a_single_continent_api_exists() { + $routes = $this->server->get_routes( $this->namespace ); + $full_route = $this->get_route( $this->rest_base . '/(?P[\w-]+)' ); + + $this->assertArrayHasKey( $full_route, $routes ); + } + + public function test_that_we_can_get_all_continents() { + wp_set_current_user( $this->seller_id1 ); + + $response = $this->get_request( $this->rest_base ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'code', $data[0] ); + $this->assertArrayHasKey( 'name', $data[0] ); + $this->assertArrayHasKey( 'states', $data[0] ); + } + + public function test_that_we_can_get_single_continent_item_by_code() { + wp_set_current_user( $this->seller_id1 ); + + $response = $this->get_request( $this->rest_base . '/BD' ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertIsArray( $data ); + + $this->assertArrayHasKey( 'code', $data ); + $this->assertEquals( 'BD', $data['code'] ); + + $this->assertArrayHasKey( 'name', $data ); + $this->assertEquals( 'Bangladesh', $data['name'] ); + + $this->assertArrayHasKey( 'states', $data ); + $this->assertIsArray( $data['states'] ); + + $found = array_filter( + $data['states'], function ( $state ) { + return $state['code'] === 'BD-13'; + } + ); + $this->assertEquals( 1, count( $found ) ); + + $found = reset( $found ); + $this->assertArrayHasKey( 'name', $found ); + $this->assertEquals( 'Dhaka', $found['name'] ); + $this->assertEquals( 'BD-13', $found['code'] ); + } +} diff --git a/tests/php/src/VendorNavMenuCheckerTest.php b/tests/php/src/VendorNavMenuCheckerTest.php new file mode 100644 index 0000000000..f54aa17b14 --- /dev/null +++ b/tests/php/src/VendorNavMenuCheckerTest.php @@ -0,0 +1,347 @@ +get( VendorNavMenuChecker::class ); + $this->assertInstanceOf( VendorNavMenuChecker::class, $service ); + } + + /** + * Test that template dependencies are returned. + * + * @test + */ + public function test_that_template_dependencies_are_returned() { + $checker = new VendorNavMenuChecker(); + $dependencies = $checker->get_template_dependencies(); + $this->assertIsArray( $dependencies ); + } + + /** + * Test that menu items are converted to react menu items. + * + * @test + */ + public function test_that_menu_items_are_converted_to_react_menu_items() { + $checker = new VendorNavMenuChecker(); + $menu_items = [ + 'dashboard' => [ + 'route' => 'dashboard', + 'url' => 'http://example.com/dashboard', + 'name' => 'Dashboard', + ], + 'products' => [ + 'route' => 'products', + 'url' => 'http://example.com/products', + 'name' => 'Products', + ], + 'orders' => [ + 'route' => 'orders', + 'url' => 'http://example.com/orders', + 'name' => 'Orders', + ], + ]; + $react_menu_items = $checker->convert_to_react_menu( $menu_items ); + $this->assertIsArray( $react_menu_items ); + $this->assertNotEquals( $menu_items['dashboard']['url'], $react_menu_items['dashboard']['url'] ); + $this->assertNotEquals( $menu_items['products']['url'], $react_menu_items['products']['url'] ); + + add_filter( + 'dokan_get_dashboard_nav_template_dependency', + function ( $template_dependencies ) { + $template_dependencies['dashboard'][] = [ + 'slug' => 'dashboard/dashboard', + 'name' => '', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'big-counter', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'orders', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'products', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'sales-chart', + 'name' => 'widget', + 'args' => [], + ]; + return $template_dependencies; + } + ); + + $react_menu_items = $checker->convert_to_react_menu( $menu_items ); + $this->assertIsArray( $react_menu_items ); + $this->assertNotEquals( $menu_items['dashboard']['url'], $react_menu_items['dashboard']['url'] ); + $this->assertNotEquals( $menu_items['products']['url'], $react_menu_items['products']['url'] ); + } + + /** + * Test that menu items are not converted to react if template is overridden by file. + * + * @test + */ + public function test_that_menu_items_are_not_converted_to_react_if_template_is_overridden_by_file() { + $checker = new VendorNavMenuChecker(); + $menu_items = [ + 'dashboard' => [ + 'route' => 'dashboard', + 'url' => dokan_get_navigation_url(), + 'name' => 'Dashboard', + ], + 'products' => [ + 'route' => 'products', + 'url' => dokan_get_navigation_url( 'products' ), + 'name' => 'Products', + ], + 'orders' => [ + 'route' => 'orders', + 'url' => dokan_get_navigation_url( 'orders' ), + 'name' => 'Orders', + ], + ]; + + add_filter( + 'dokan_get_dashboard_nav_template_dependency', + function ( $template_dependencies ) { + $template_dependencies['dashboard'][] = [ + 'slug' => 'dashboard/dashboard', + 'name' => '', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'big-counter', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'orders', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'products', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'sales-chart', + 'name' => 'widget', + 'args' => [], + ]; + + return $template_dependencies; + } + ); + + $react_menu_items = $checker->convert_to_react_menu( $menu_items ); + $this->assertIsArray( $react_menu_items ); + $this->assertNotEquals( $menu_items['dashboard']['url'], $react_menu_items['dashboard']['url'] ); + $this->assertNotEquals( $menu_items['products']['url'], $react_menu_items['products']['url'] ); + + $theme_dir = wp_get_theme()->get_stylesheet_directory(); + $file = $theme_dir . '/dokan/dashboard/dashboard.php'; + + self::touch( $file ); + + $react_menu_items = $checker->convert_to_react_menu( $menu_items ); + $this->assertIsArray( $react_menu_items ); + $this->assertEquals( $menu_items['dashboard']['url'], $react_menu_items['dashboard']['url'] ); + $this->assertNotEquals( $menu_items['products']['url'], $react_menu_items['products']['url'] ); + + self::unlink( $file ); + } + + /** + * Test that menu items are not converted to react if template is overridden by third party plugin. + * + * @test + */ + public function test_that_menu_items_are_not_converted_to_react_if_template_is_overridden_by_third_party_plugin() { + $checker = new VendorNavMenuChecker(); + $menu_items = [ + 'dashboard' => [ + 'route' => 'dashboard', + 'url' => dokan_get_navigation_url(), + 'name' => 'Dashboard', + ], + 'products' => [ + 'route' => 'products', + 'url' => dokan_get_navigation_url( 'products' ), + 'name' => 'Products', + ], + 'orders' => [ + 'route' => 'orders', + 'url' => dokan_get_navigation_url( 'orders' ), + 'name' => 'Orders', + ], + ]; + + add_filter( + 'dokan_get_dashboard_nav_template_dependency', + function ( $template_dependencies ) { + $template_dependencies['dashboard'][] = [ + 'slug' => 'dashboard/dashboard', + 'name' => '', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'big-counter', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'orders', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'products', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'sales-chart', + 'name' => 'widget', + 'args' => [], + ]; + + return $template_dependencies; + } + ); + + $react_menu_items = $checker->convert_to_react_menu( $menu_items ); + $this->assertIsArray( $react_menu_items ); + $this->assertNotEquals( $menu_items['dashboard']['url'], $react_menu_items['dashboard']['url'] ); + $this->assertNotEquals( $menu_items['products']['url'], $react_menu_items['products']['url'] ); + + $custom_template_file = WP_PLUGIN_DIR . '/dokan-custom/dashboard/dashboard.php'; + + add_filter( + 'dokan_get_template_part', + function ( $template, $slug, $name ) use ( $custom_template_file ) { + if ( 'dashboard/dashboard' === $slug && '' === $name ) { + return $custom_template_file; + } + + return $template; + }, + 10, + 3 + ); + + $react_menu_items = $checker->convert_to_react_menu( $menu_items ); + $this->assertIsArray( $react_menu_items ); + $this->assertEquals( $menu_items['dashboard']['url'], $react_menu_items['dashboard']['url'] ); + $this->assertNotEquals( $menu_items['products']['url'], $react_menu_items['products']['url'] ); + } + + /** + * Test that menu items template dependency is being resolved on template override. + * + * @test + */ + public function test_that_menu_items_template_dependency_is_being_resolved_on_template_override() { + $checker = new VendorNavMenuChecker(); + $menu_items = [ + 'dashboard' => [ + 'route' => 'dashboard', + 'url' => dokan_get_navigation_url(), + 'name' => 'Dashboard', + ], + 'products' => [ + 'route' => 'products', + 'url' => dokan_get_navigation_url( 'products' ), + 'name' => 'Products', + ], + 'orders' => [ + 'route' => 'orders', + 'url' => dokan_get_navigation_url( 'orders' ), + 'name' => 'Orders', + ], + ]; + + add_filter( + 'dokan_get_dashboard_nav_template_dependency', + function ( $template_dependencies ) { + $template_dependencies['dashboard'][] = [ + 'slug' => 'dashboard/dashboard', + 'name' => '', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'dashboard/big-counter', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'dashboard/orders', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'dashboard/products', + 'name' => 'widget', + 'args' => [], + ]; + $template_dependencies['dashboard'][] = [ + 'slug' => 'dashboard/sales-chart', + 'name' => 'widget', + 'args' => [], + ]; + + return $template_dependencies; + } + ); + + $theme_dir = wp_get_theme()->get_stylesheet_directory(); + $products_widget_file = $theme_dir . '/dokan/dashboard/products-widget.php'; + $orders_widget_file = $theme_dir . '/dokan/dashboard/orders-widget.php'; + + self::touch( $products_widget_file ); + self::touch( $orders_widget_file ); + + $custom_template_file = WP_PLUGIN_DIR . '/dokan-custom/dashboard/dashboard.php'; + + add_filter( + 'dokan_get_template_part', + function ( $template, $slug, $name ) use ( $custom_template_file ) { + if ( 'dashboard/dashboard' === $slug && '' === $name ) { + return $custom_template_file; + } + + return $template; + }, + 10, + 3 + ); + + $overridden = $checker->list_overridden_templates(); + + self::unlink( $products_widget_file ); + self::unlink( $orders_widget_file ); + + $this->assertIsArray( $overridden ); + $this->assertContains( $products_widget_file, $overridden ); + $this->assertContains( $orders_widget_file, $overridden ); + $this->assertContains( $custom_template_file, $overridden ); + } +} diff --git a/webpack-entries.js b/webpack-entries.js new file mode 100644 index 0000000000..c5c9d994df --- /dev/null +++ b/webpack-entries.js @@ -0,0 +1,55 @@ +const entryPoints = { + // Dokan tailwind css + 'dokan-tailwind': './src/tailwind.css', + + frontend: './src/dashboard/index.tsx', + 'dokan-admin-dashboard': './src/admin/dashboard/index.tsx', + 'vue-frontend': './src/frontend/main.js', + 'vue-admin': './src/admin/main.js', + 'vue-bootstrap': './src/utils/Bootstrap.js', + 'vue-vendor': [ './src/utils/vue-vendor.js' ], + 'dokan-promo-notice': './src/promo-notice/main.js', + 'dokan-admin-notice': './src/admin/notice/main.js', + 'reverse-withdrawal': './assets/src/js/reverse-withdrawal.js', + 'product-category-ui': './assets/src/js/product-category-ui.js', + 'dokan-admin-product': './assets/src/js/dokan-admin-product.js', + 'vendor-address': './assets/src/js/vendor-address.js', + 'vendor-registration': './assets/src/js/vendor-registration.js', + 'customize-controls': './assets/src/js/customize-controls.js', + 'customize-preview': './assets/src/js/customize-preview.js', + pointers: './assets/src/js/pointers.js', + dokan: [ + './assets/src/js/orders.js', + './assets/src/js/product-editor.js', + './assets/src/js/script.js', + './assets/src/js/store-lists.js', + './assets/src/js/withdraw.js', + './assets/src/js/dokan-daterangepicker.js', + ], + 'login-form-popup': './assets/src/js/login-form-popup.js', + 'dokan-maps-compat': './assets/src/js/dokan-maps-compat.js', + 'dokan-admin': './assets/src/js/admin.js', + 'dokan-setup-no-wc': [ './assets/src/js/setup-no-wc.js' ], + helper: './assets/src/js/helper.js', + 'dokan-frontend': './assets/src/js/dokan-frontend.js', + + style: '/assets/src/less/style.less', + rtl: '/assets/src/less/rtl.less', + admin: '/assets/src/less/admin.less', + plugin: '/assets/src/less/plugin.less', + 'global-admin': '/assets/src/less/global-admin.less', + setup: '/assets/src/less/setup.less', + 'setup-no-wc-style': [ '/assets/src/less/setup-no-wc.less' ], + 'reverse-withdrawal-style': '/assets/src/less/reverse-withdrawal.less', + 'dokan-product-category-ui': + '/assets/src/less/dokan-product-category-ui.less', + 'dokan-admin-product-style': '/assets/src/less/dokan-admin-product.less', + 'page-views': './assets/src/js/page-views.js', + 'dokan-setup-wizard-commission': + './assets/src/js/setup-wizard/commission/index.js', + // Category commission component styles. + 'dokan-category-commission': '/src/admin/components/Commission/index.js', + 'dokan-status': '/src/Status/index.tsx', +}; + +module.exports = entryPoints; diff --git a/webpack.config.js b/webpack.config.js index 53c5ed26fa..3447552a57 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,78 +1,41 @@ -const path = require('path'); -const package = require('./package.json'); -const {VueLoaderPlugin} = require('vue-loader'); -const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const defaultConfig = require('@wordpress/scripts/config/webpack.config'); +const path = require( 'path' ); +const { VueLoaderPlugin } = require( 'vue-loader' ); +const entryPoints = require( './webpack-entries' ); +const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' ); +const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); const isProduction = process.env.NODE_ENV === 'production'; -const entryPoint = { - // Dokan tailwind css - 'dokan-tailwind': './src/tailwind.css', - - 'vue-frontend': './src/frontend/main.js', - 'vue-admin': './src/admin/main.js', - 'vue-bootstrap': './src/utils/Bootstrap.js', - 'vue-vendor': [ - './src/utils/vue-vendor.js', - ], - 'dokan-promo-notice': './src/promo-notice/main.js', - 'dokan-admin-notice': './src/admin/notice/main.js', - 'reverse-withdrawal': './assets/src/js/reverse-withdrawal.js', - 'product-category-ui': './assets/src/js/product-category-ui.js', - 'dokan-admin-product': './assets/src/js/dokan-admin-product.js', - 'vendor-address': './assets/src/js/vendor-address.js', - 'vendor-registration': './assets/src/js/vendor-registration.js', - 'customize-controls': './assets/src/js/customize-controls.js', - 'customize-preview': './assets/src/js/customize-preview.js', - 'pointers': './assets/src/js/pointers.js', - 'dokan': [ - './assets/src/js/orders.js', - './assets/src/js/product-editor.js', - './assets/src/js/script.js', - './assets/src/js/store-lists.js', - './assets/src/js/withdraw.js', - './assets/src/js/dokan-daterangepicker.js' - ], - 'login-form-popup': './assets/src/js/login-form-popup.js', - 'dokan-maps-compat': './assets/src/js/dokan-maps-compat.js', - 'dokan-admin': './assets/src/js/admin.js', - 'dokan-setup-no-wc': [ - './assets/src/js/setup-no-wc.js' - ], - 'helper': './assets/src/js/helper.js', - 'dokan-frontend': './assets/src/js/dokan-frontend.js', - - 'style': '/assets/src/less/style.less', - 'rtl': '/assets/src/less/rtl.less', - 'admin': '/assets/src/less/admin.less', - 'plugin': '/assets/src/less/plugin.less', - 'global-admin': '/assets/src/less/global-admin.less', - 'setup': '/assets/src/less/setup.less', - 'setup-no-wc-style': [ - '/assets/src/less/setup-no-wc.less' - ], - 'reverse-withdrawal-style': '/assets/src/less/reverse-withdrawal.less', - 'dokan-product-category-ui': '/assets/src/less/dokan-product-category-ui.less', - 'dokan-admin-product-style': '/assets/src/less/dokan-admin-product.less', - 'page-views': './assets/src/js/page-views.js', - 'dokan-setup-wizard-commission': './assets/src/js/setup-wizard/commission/index.js', - // Category commission component styles. - 'dokan-category-commission': '/src/admin/components/Commission/index.js', -}; - const updatedConfig = { mode: defaultConfig.mode, - entry: entryPoint, + entry: { + ...entryPoints, + 'components': { + import: '@dokan/components/index.tsx', + }, + 'utilities': { + import: '@dokan/utilities/index.ts', + }, + 'hooks': { + import: '@dokan/hooks/index.tsx', + }, + 'dokan-status': '/src/Status/index.tsx', + }, output: { path: path.resolve(__dirname, './assets/js'), filename: '[name].js', clean: true, + devtoolNamespace: 'dokan', + library: { + name: [ 'dokan', '[name]' ], + type: 'window' + } }, resolve: { + ...defaultConfig.resolve, alias: { 'vue$': 'vue/dist/vue.esm.js', - '@': path.resolve('./src/'), + '@dokan': path.resolve('./src/'), 'frontend': path.resolve('./src/frontend/'), 'admin': path.resolve('./src/admin/'), },