Initial commit: backend, storefront, vendor-panel added
This commit is contained in:
166
.gitignore
vendored
Normal file
166
.gitignore
vendored
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# ---> macOS
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
41
backend/.gitignore
vendored
Normal file
41
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Yarn 2+
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
**/build
|
||||||
|
**/dist
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.development.local
|
||||||
|
**/.env.test.local
|
||||||
|
**/.env.production.local
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
203
backend/CHANGELOG.md
Normal file
203
backend/CHANGELOG.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Mercur will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.1] - 2025-07-30
|
||||||
|
|
||||||
|
### Release 1.0.1 - B2C Marketplace
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Extract modules** ([#323](https://github.com/mercurjs/mercur/pull/323) @slusarczykmichal)
|
||||||
|
- **Docs: update OAS** ([#326](https://github.com/mercurjs/mercur/pull/326) @slusarczykmichal)
|
||||||
|
- **Version: bump to medusa 2.8.6** ([#333](https://github.com/mercurjs/mercur/pull/333) @slusarczykmichal)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Product update request** ([#340](https://github.com/mercurjs/mercur/pull/340) @WojciechPlodzien)
|
||||||
|
- **Seller detail page** ([#359](https://github.com/mercurjs/mercur/pull/359) @slusarczykmichal)
|
||||||
|
- **Move withDeleted out of filters to prevent query error** ([#361](https://github.com/mercurjs/mercur/pull/361) @slusarczykmichal)
|
||||||
|
|
||||||
|
## [1.0.0] - 2025-06-23
|
||||||
|
|
||||||
|
### Release 1.0 - B2C Marketplace
|
||||||
|
|
||||||
|
This release marks the official 1.0 version of Mercur, with significant improvements to the marketplace platform including enhanced seller management, product features, and numerous bug fixes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Product Attributes** ([#316](https://github.com/mercurjs/mercur/pull/316) @slusarczykmichal)
|
||||||
|
- **Seller Management API** ([#259](https://github.com/mercurjs/mercur/pull/259) @slusarczykmichal)
|
||||||
|
- **Invite Seller to Platform** ([#257](https://github.com/mercurjs/mercur/pull/257) @slusarczykmichal)
|
||||||
|
- **Vendor Panel UI Notifications** ([#284](https://github.com/mercurjs/mercur/pull/284) @slusarczykmichal)
|
||||||
|
- **Order Sets List** ([#252](https://github.com/mercurjs/mercur/pull/252), [#262](https://github.com/mercurjs/mercur/pull/262) @slusarczykmichal, @WojciechPlodzien)
|
||||||
|
- **Enable Filtering Order Set by Order ID** ([#256](https://github.com/mercurjs/mercur/pull/256) @slusarczykmichal)
|
||||||
|
- **Customer Returns List** ([#306](https://github.com/mercurjs/mercur/pull/306) @slusarczykmichal)
|
||||||
|
- **Commissions API & Admin Dashboard** ([#298](https://github.com/mercurjs/mercur/pull/298) @slusarczykmichal)
|
||||||
|
- **Seed Default Configuration Rules** ([#267](https://github.com/mercurjs/mercur/pull/267) @slusarczykmichal)
|
||||||
|
- **Seller Suspension Logic** ([#266](https://github.com/mercurjs/mercur/pull/266) @slusarczykmichal)
|
||||||
|
- **Remove Shipping Methods from Cart** ([#247](https://github.com/mercurjs/mercur/pull/247) @slusarczykmichal)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Password Reset Emails Refactoring** ([#278](https://github.com/mercurjs/mercur/pull/278) @slusarczykmichal)
|
||||||
|
- **Orders Format Change** ([#277](https://github.com/mercurjs/mercur/pull/277) @slusarczykmichal)
|
||||||
|
- **Notification Cleanup** ([#276](https://github.com/mercurjs/mercur/pull/276) @slusarczykmichal)
|
||||||
|
- **Remove HTTP Client** ([#313](https://github.com/mercurjs/mercur/pull/313) @slusarczykmichal)
|
||||||
|
- **Change Supported Countries** ([#265](https://github.com/mercurjs/mercur/pull/265) @slusarczykmichal)
|
||||||
|
- **Update README** ([#318](https://github.com/mercurjs/mercur/pull/318) @slusarczykmichal)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Product Search and Filters** ([#255](https://github.com/mercurjs/mercur/pull/255) @NicolasGorga)
|
||||||
|
- **More Information on Requests Page** ([#254](https://github.com/mercurjs/mercur/pull/254) @slusarczykmichal)
|
||||||
|
- **Show Only Current Seller Product When Creating Promotion** ([#253](https://github.com/mercurjs/mercur/pull/253), [#264](https://github.com/mercurjs/mercur/pull/264) @slusarczykmichal, @WojciechPlodzien)
|
||||||
|
- **Stripe Provider** ([#251](https://github.com/mercurjs/mercur/pull/251) @slusarczykmichal)
|
||||||
|
- **Create Additional Subscribers** ([#249](https://github.com/mercurjs/mercur/pull/249) @slusarczykmichal)
|
||||||
|
- **Vendor Update Price List** ([#245](https://github.com/mercurjs/mercur/pull/245) @slusarczykmichal)
|
||||||
|
- **Add Rules to Shipping Options** ([#243](https://github.com/mercurjs/mercur/pull/243) @slusarczykmichal)
|
||||||
|
- **Filter Out Deleted Linked Entities** ([#286](https://github.com/mercurjs/mercur/pull/286) @slusarczykmichal)
|
||||||
|
- **Update Configuration Module Import in Seed-Functions** ([#279](https://github.com/mercurjs/mercur/pull/279) @cesarve77)
|
||||||
|
- **One Review Per Order** ([#273](https://github.com/mercurjs/mercur/pull/273) @slusarczykmichal)
|
||||||
|
- **Filter Customer Groups in Promotions** ([#260](https://github.com/mercurjs/mercur/pull/260) @slusarczykmichal)
|
||||||
|
- **Duplicate Order Return Requests** ([#314](https://github.com/mercurjs/mercur/pull/314) @slusarczykmichal)
|
||||||
|
- **Create Payout Reversal** ([#312](https://github.com/mercurjs/mercur/pull/312) @slusarczykmichal)
|
||||||
|
- **Stripe Connect Updates** ([#311](https://github.com/mercurjs/mercur/pull/311) @slusarczykmichal)
|
||||||
|
- **Seller Return Shipping Options** ([#308](https://github.com/mercurjs/mercur/pull/308) @slusarczykmichal)
|
||||||
|
- **Provide Statuses with Order Sets** ([#307](https://github.com/mercurjs/mercur/pull/307) @slusarczykmichal)
|
||||||
|
- **Mark Order as Completed After Shipping is Created** ([#304](https://github.com/mercurjs/mercur/pull/304) @slusarczykmichal)
|
||||||
|
- **If No Seller Email Provided Use Member Email** ([#303](https://github.com/mercurjs/mercur/pull/303) @slusarczykmichal)
|
||||||
|
- **Notifications** ([#297](https://github.com/mercurjs/mercur/pull/297) @slusarczykmichal)
|
||||||
|
- **Outstanding Amount** ([#291](https://github.com/mercurjs/mercur/pull/291) @slusarczykmichal)
|
||||||
|
- **Do Not Show Admin Notifications from Seller Feed** ([#289](https://github.com/mercurjs/mercur/pull/289) @slusarczykmichal)
|
||||||
|
- **Trigger Algolia Update After Modifying Inventory Items** ([#288](https://github.com/mercurjs/mercur/pull/288) @slusarczykmichal)
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to all contributors:
|
||||||
|
|
||||||
|
@pfulara, @slusarczykmichal, @WojciechPlodzien, @NicolasGorga, @cesarve77
|
||||||
|
|
||||||
|
## [0.9.0] - 2025-05-23
|
||||||
|
|
||||||
|
### Initial Release - Marketplace Platform
|
||||||
|
|
||||||
|
This is the first major release of Mercur, an open-source marketplace platform built on Medusa.js 2.0. Version 0.9.0 includes most features planned for the 1.0 MVP release but is currently under heavy testing and bug fixing.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Initial Medusa API Setup** ([#1](https://github.com/mercurjs/mercur/pull/1) @vholik)
|
||||||
|
- **Seller Registration & Onboarding** ([#92](https://github.com/mercurjs/mercur/pull/92) @slusarczykmichal, [#38](https://github.com/mercurjs/mercur/pull/38) @mjaskot)
|
||||||
|
- **Team Management System** with member invitations and role-based access
|
||||||
|
- **Commission System** ([#40](https://github.com/mercurjs/mercur/pull/40) @slusarczykmichal)
|
||||||
|
- **Stripe Connect Integration** ([#36](https://github.com/mercurjs/mercur/pull/36) @vholik)
|
||||||
|
- **Multi-vendor Order Processing** ([#34](https://github.com/mercurjs/mercur/pull/34) @slusarczykmichal)
|
||||||
|
- **Vendor Panel Orders** ([#42](https://github.com/mercurjs/mercur/pull/42) @vholik)
|
||||||
|
- **Vendor Fulfillments** ([#148](https://github.com/mercurjs/mercur/pull/148) @slusarczykmichal)
|
||||||
|
- **Order Return Request** ([#49](https://github.com/mercurjs/mercur/pull/49) @slusarczykmichal)
|
||||||
|
- **Vendor Return Management** ([#124](https://github.com/mercurjs/mercur/pull/124) @slusarczykmichal)
|
||||||
|
- **Product Categories & Collections**
|
||||||
|
- **Brand Entity & Management** ([#87](https://github.com/mercurjs/mercur/pull/87) @slusarczykmichal)
|
||||||
|
- **Inventory Management** ([#33](https://github.com/mercurjs/mercur/pull/33) @slusarczykmichal)
|
||||||
|
- **Batch Stock Editing** ([#187](https://github.com/mercurjs/mercur/pull/187) @slusarczykmichal)
|
||||||
|
- **Product Tags/Types** ([#105](https://github.com/mercurjs/mercur/pull/105) @slusarczykmichal)
|
||||||
|
- **Variant/Options Management** ([#106](https://github.com/mercurjs/mercur/pull/106) @slusarczykmichal)
|
||||||
|
- **Product Draft Mode** ([#185](https://github.com/mercurjs/mercur/pull/185) @slusarczykmichal)
|
||||||
|
- **Product Import/Export** ([#134](https://github.com/mercurjs/mercur/pull/134) @slusarczykmichal)
|
||||||
|
- **Algolia Search Integration** ([#81](https://github.com/mercurjs/mercur/pull/81) @slusarczykmichal)
|
||||||
|
- **Wishlist Module** ([#177](https://github.com/mercurjs/mercur/pull/177) @mwestrjs)
|
||||||
|
- **Resend Email Integration** ([#73](https://github.com/mercurjs/mercur/pull/73) @slusarczykmichal, [#35](https://github.com/mercurjs/mercur/pull/35) @mjaskot)
|
||||||
|
- **TalkJS Conversation Endpoint** ([#196](https://github.com/mercurjs/mercur/pull/196) @slusarczykmichal)
|
||||||
|
- **Stripe Tax Provider** ([#53](https://github.com/mercurjs/mercur/pull/53) @slusarczykmichal)
|
||||||
|
- **Seller/Product Review System** ([#57](https://github.com/mercurjs/mercur/pull/57) @slusarczykmichal)
|
||||||
|
- **Request & Approval System** ([#48](https://github.com/mercurjs/mercur/pull/48) @slusarczykmichal)
|
||||||
|
- **Edit Request System** ([#184](https://github.com/mercurjs/mercur/pull/184) @slusarczykmichal)
|
||||||
|
- **Requests Admin Panel** ([#69](https://github.com/mercurjs/mercur/pull/69) @slusarczykmichal)
|
||||||
|
- **Customer Groups Management** ([#136](https://github.com/mercurjs/mercur/pull/136) @slusarczykmichal)
|
||||||
|
- **Vendor Promotions** ([#103](https://github.com/mercurjs/mercur/pull/103), [#164](https://github.com/mercurjs/mercur/pull/164) @slusarczykmichal)
|
||||||
|
- **Vendor Campaigns** ([#111](https://github.com/mercurjs/mercur/pull/111) @slusarczykmichal)
|
||||||
|
- **Vendor Price Lists** ([#109](https://github.com/mercurjs/mercur/pull/109) @slusarczykmichal)
|
||||||
|
- **Reservation Management** ([#112](https://github.com/mercurjs/mercur/pull/112) @slusarczykmichal, [#190](https://github.com/mercurjs/mercur/pull/190) @slusarczykmichal)
|
||||||
|
- **Global Product Catalog** ([#64](https://github.com/mercurjs/mercur/pull/64) @slusarczykmichal)
|
||||||
|
- **Admin Product Catalog Settings** ([#52](https://github.com/mercurjs/mercur/pull/52) @slusarczykmichal)
|
||||||
|
- **Charts Data Endpoint** ([#113](https://github.com/mercurjs/mercur/pull/113) @slusarczykmichal)
|
||||||
|
- **Sales Channels Route** ([#93](https://github.com/mercurjs/mercur/pull/93) @slusarczykmichal)
|
||||||
|
- **Team Member Email** ([#77](https://github.com/mercurjs/mercur/pull/77) @slusarczykmichal)
|
||||||
|
- **Vendor File Uploads** ([#107](https://github.com/mercurjs/mercur/pull/107) @slusarczykmichal)
|
||||||
|
- **Extended Seller Info** ([#110](https://github.com/mercurjs/mercur/pull/110) @slusarczykmichal, [#91](https://github.com/mercurjs/mercur/pull/91) @slusarczykmichal)
|
||||||
|
- **Seed Script** ([#160](https://github.com/mercurjs/mercur/pull/160) @slusarczykmichal)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Medusa Upgrade to 2.7.0** ([#159](https://github.com/mercurjs/mercur/pull/159) @slusarczykmichal)
|
||||||
|
- **Medusa Upgrade to 2.6.1** ([#122](https://github.com/mercurjs/mercur/pull/122) @slusarczykmichal)
|
||||||
|
- **Medusa Upgrade to 2.4.0** ([#115](https://github.com/mercurjs/mercur/pull/115) @slusarczykmichal)
|
||||||
|
- **Enhanced Algolia Data** ([#199](https://github.com/mercurjs/mercur/pull/199) @slusarczykmichal)
|
||||||
|
- **Product Request Refactoring** ([#149](https://github.com/mercurjs/mercur/pull/149) @slusarczykmichal, [#137](https://github.com/mercurjs/mercur/pull/137) @slusarczykmichal)
|
||||||
|
- **Dashboard Layout Improvements** ([#166](https://github.com/mercurjs/mercur/pull/166) @slusarczykmichal)
|
||||||
|
- **Documentation Updates** ([#70](https://github.com/mercurjs/mercur/pull/70) @slusarczykmichal, [#18](https://github.com/mercurjs/mercur/pull/18) @haf)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Zero Percent Commission Support** ([#235](https://github.com/mercurjs/mercur/pull/235), [#228](https://github.com/mercurjs/mercur/pull/228))
|
||||||
|
- **Default Values in Commission Editor** ([#234](https://github.com/mercurjs/mercur/pull/234), [#230](https://github.com/mercurjs/mercur/pull/230))
|
||||||
|
- **Commission Hook Mounting** ([#213](https://github.com/mercurjs/mercur/pull/213))
|
||||||
|
- **Commission in Order Payouts** ([#217](https://github.com/mercurjs/mercur/pull/217))
|
||||||
|
- **Commission Calculation Step** ([#121](https://github.com/mercurjs/mercur/pull/121))
|
||||||
|
- **Order Query in Payout Workflow** ([#123](https://github.com/mercurjs/mercur/pull/123))
|
||||||
|
- **Stripe Payout Account** ([#119](https://github.com/mercurjs/mercur/pull/119))
|
||||||
|
- **Customer Selection with Groups** ([#233](https://github.com/mercurjs/mercur/pull/233), [#227](https://github.com/mercurjs/mercur/pull/227))
|
||||||
|
- **Promotions in Cart Splitting** ([#215](https://github.com/mercurjs/mercur/pull/215))
|
||||||
|
- **Service Zone Editing** ([#223](https://github.com/mercurjs/mercur/pull/223))
|
||||||
|
- **Invalid Promotion Rule Attributes** ([#202](https://github.com/mercurjs/mercur/pull/202))
|
||||||
|
- **Shipping Options List** ([#170](https://github.com/mercurjs/mercur/pull/170))
|
||||||
|
- **Promotion Rules Batch Actions** ([#139](https://github.com/mercurjs/mercur/pull/139))
|
||||||
|
- **Multi-vendor Cart Completion** ([#89](https://github.com/mercurjs/mercur/pull/89))
|
||||||
|
- **Product Import Request Creation** ([#224](https://github.com/mercurjs/mercur/pull/224))
|
||||||
|
- **Batch Location Level Acceptance** ([#211](https://github.com/mercurjs/mercur/pull/211))
|
||||||
|
- **Price List Product Fetching** ([#207](https://github.com/mercurjs/mercur/pull/207))
|
||||||
|
- **Default Shipping Profile Assignment** ([#204](https://github.com/mercurjs/mercur/pull/204))
|
||||||
|
- **Inventory Item Seller Link** ([#200](https://github.com/mercurjs/mercur/pull/200))
|
||||||
|
- **Seller Stock Locations** ([#188](https://github.com/mercurjs/mercur/pull/188))
|
||||||
|
- **Algolia Upsert Logic** ([#146](https://github.com/mercurjs/mercur/pull/146))
|
||||||
|
- **Additional Data in Product Flows** ([#145](https://github.com/mercurjs/mercur/pull/145), [#140](https://github.com/mercurjs/mercur/pull/140))
|
||||||
|
- **Algolia Product Updates** ([#95](https://github.com/mercurjs/mercur/pull/95))
|
||||||
|
- **Product Variants Formatting** ([#90](https://github.com/mercurjs/mercur/pull/90))
|
||||||
|
- **Product Default Options** ([#61](https://github.com/mercurjs/mercur/pull/61))
|
||||||
|
- **HTTP Client Query Parameters** ([#191](https://github.com/mercurjs/mercur/pull/191)) - Thanks to Nicolas Gorga for this contribution
|
||||||
|
- **Unrecognized Field Error** ([#172](https://github.com/mercurjs/mercur/pull/172))
|
||||||
|
- **Query Parameters for Vendor Categories** ([#171](https://github.com/mercurjs/mercur/pull/171))
|
||||||
|
- **OAS Documentation** ([#201](https://github.com/mercurjs/mercur/pull/201))
|
||||||
|
- **File Paths** ([#154](https://github.com/mercurjs/mercur/pull/154))
|
||||||
|
- **Type Errors** ([#131](https://github.com/mercurjs/mercur/pull/131))
|
||||||
|
- **Type Problems and Unnecessary Checks** ([#126](https://github.com/mercurjs/mercur/pull/126))
|
||||||
|
- **Wrong API Route File Name** ([#85](https://github.com/mercurjs/mercur/pull/85))
|
||||||
|
- **Min/Max OAS Constraints Syntax** ([#83](https://github.com/mercurjs/mercur/pull/83))
|
||||||
|
- **Custom OAS Fixes** ([#80](https://github.com/mercurjs/mercur/pull/80))
|
||||||
|
- **Shipping Options OAS Route** ([#78](https://github.com/mercurjs/mercur/pull/78))
|
||||||
|
- **HTTP Client** ([#46](https://github.com/mercurjs/mercur/pull/46))
|
||||||
|
- **Request Info Background Color** ([#179](https://github.com/mercurjs/mercur/pull/179))
|
||||||
|
- **Seller ID Fetching** ([#68](https://github.com/mercurjs/mercur/pull/68))
|
||||||
|
- **CORS Configuration** ([#7](https://github.com/mercurjs/mercur/pull/7))
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to all contributors:
|
||||||
|
|
||||||
|
@pfulara, @slusarczykmichal, @vholik, @NicolasGorga, @WojciechPlodzien, @dominicrathbone, @haf, @LukaszMielczarek, @mjaskot, @mwestrjs
|
||||||
|
|
||||||
|
### Current Limitations
|
||||||
|
|
||||||
|
⚠️ **Beta Status**: This release is under heavy testing and may contain bugs
|
||||||
|
|
||||||
|
- Edge cases in multi-vendor order processing requiring additional refinement
|
||||||
|
- Commission calculation in specific currency scenarios needs further testing
|
||||||
|
- Some API endpoints need additional input validation
|
||||||
|
|
||||||
|
### Coming Soon (v1.0)
|
||||||
|
|
||||||
|
- Enhanced seller management in Admin panel
|
||||||
|
- Extended documentation
|
||||||
21
backend/LICENSE
Normal file
21
backend/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Rigby
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
129
backend/README.md
Normal file
129
backend/README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|

|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<h1>Mercur <br> Open Source Marketplace Platform</h1>
|
||||||
|
<!-- Shields.io Badges -->
|
||||||
|
<a href="https://github.com/mercurjs/mercur/tree/main?tab=MIT-1-ov-file">
|
||||||
|
<img alt="License" src="https://img.shields.io/badge/license-MIT-blue.svg" />
|
||||||
|
</a>
|
||||||
|
<a href="#">
|
||||||
|
<img alt="PRs Welcome" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" />
|
||||||
|
</a>
|
||||||
|
<a href="https://rigbyjs.com/#contact">
|
||||||
|
<img alt="Support" src="https://img.shields.io/badge/support-contact%20author-blueviolet.svg" />
|
||||||
|
</a>
|
||||||
|
<!-- Website Links -->
|
||||||
|
<p>
|
||||||
|
<a href="https://mercurjs.com/">Mercur</a> | <a href="https://docs.mercurjs.com/">Docs</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# What is Mercur?
|
||||||
|
|
||||||
|
<a href="https://www.mercurjs.com/">Mercur</a> is the first truly limitless open source marketplace platform that combines the simplicity of SaaS with the freedom of open source. Built on [MedusaJS](https://github.com/medusajs/medusa), it empowers businesses to create custom marketplaces without choosing between ownership and ease of use.
|
||||||
|
|
||||||
|
Mercur is a platform to start, customize, manage, and scale your marketplace for every business model with a modern technology stack.
|
||||||
|
|
||||||
|
## Announcing Mercur 1.0
|
||||||
|
|
||||||
|
After months of development, testing, and close collaboration with early adopters, we’re excited to announce the official release of **Mercur 1.0** - the first truly limitless marketplace platform. Version 1.0 is fully open source and ready to be self-hosted, giving you **full control over infrastructure, customizations, and data**.
|
||||||
|
|
||||||
|
With this version, **Mercur is production-ready for B2C marketplaces**. The first complete version includes a vendor system, admin panel, and a fully built B2C Storefront. Read more in **[official release announcement](https://www.mercurjs.com/updates/mercur-1-0-release)**
|
||||||
|
|
||||||
|
## Why Choose Mercur?
|
||||||
|
|
||||||
|
- Full Ownership: Unlike SaaS platforms, you own your marketplace with no transaction fees or vendor lock-in
|
||||||
|
- Modern Foundation: Built on MedusaJS, offering a modern tech stack that developers love
|
||||||
|
- Beautiful by Default: Create stunning storefronts without sacrificing customization
|
||||||
|
|
||||||
|
## Power Any Marketplace Model
|
||||||
|
|
||||||
|
- Custom B2B Marketplace: Build enterprise-grade platforms with specialized workflows
|
||||||
|
- Custom B2C Marketplace: Create engaging consumer marketplaces with modern UX
|
||||||
|
- eCommerce Extension: Transform your store into a marketplace (coming soon)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
# Ready-to-go marketplace features
|
||||||
|
|
||||||
|
<b>Storefronts for Marketplace </b> <br>
|
||||||
|
Customizable storefronts designed for B2B and B2C with all elements including browsing and buying products across multiple vendors at once.
|
||||||
|
|
||||||
|
Discover <a href="https://github.com/mercurjs/b2c-marketplace-storefront">B2C Storefront Repository</a> - <a href="https://b2c.mercurjs.com/">🛍️ Check demo </a>
|
||||||
|
|
||||||
|
<b>Admin Panel</b> <br>
|
||||||
|
Control over whole marketplace: setting product categories, vendors, commissions and rules
|
||||||
|
|
||||||
|
<b>Vendor Panel</b> <br>
|
||||||
|
A powerful dashboard giving sellers complete control over their products, orders, and store management in one intuitive interface.
|
||||||
|
|
||||||
|
Discover <a href="https://github.com/mercurjs/vendor-panel">Vendor Panel</a> - <a href="https://www.mercurjs.com/contact"> Contact us to get demo </a>
|
||||||
|
|
||||||
|
<b>Integrations</b> <br>
|
||||||
|
Built-in integration with Stripe for payments and Resend for communication needs. More integrations coming soon.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
#### Setup Medusa project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/mercurjs/mercur.git
|
||||||
|
|
||||||
|
# Change directory
|
||||||
|
cd mercur
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Build packages
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# Go to backend folder
|
||||||
|
cd apps/backend
|
||||||
|
|
||||||
|
# Clone .env.template
|
||||||
|
cp .env.template .env
|
||||||
|
|
||||||
|
# In the .env file replace user, password, address and port parameters in the DATABASE_URL variable with your values
|
||||||
|
DATABASE_URL=postgres://[user]:[password]@[address]:[port]/$DB_NAME
|
||||||
|
# For example:
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/$DB_NAME
|
||||||
|
|
||||||
|
# Setup database and run migrations
|
||||||
|
yarn medusa db:create && yarn medusa db:migrate && yarn run seed
|
||||||
|
|
||||||
|
# Create admin user
|
||||||
|
npx medusa user --email <email> --password <password>
|
||||||
|
|
||||||
|
# Go to root folder
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
# Start Mercur
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js v20+
|
||||||
|
- PostgreSQL
|
||||||
|
- Git CLI
|
||||||
|
|
||||||
|
# Resources
|
||||||
|
|
||||||
|
#### Learn more about Mercur
|
||||||
|
|
||||||
|
- [Mercur Website](https://www.mercurjs.com/)
|
||||||
|
- [Mercur Docs](https://docs.mercurjs.com/introduction)
|
||||||
|
|
||||||
|
#### Learn more about Medusa
|
||||||
|
|
||||||
|
- [Medusa Website](https://www.medusajs.com/)
|
||||||
|
- [Medusa Docs](https://docs.medusajs.com/v2)
|
||||||
34
backend/_tsconfig.base.json
Normal file
34
backend/_tsconfig.base.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"ES2021"
|
||||||
|
],
|
||||||
|
"target": "ES2021",
|
||||||
|
"outDir": "${configDir}/dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"module": "node16",
|
||||||
|
"moduleResolution": "node16",
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"incremental": false
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"${configDir}/src"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"${configDir}/dist",
|
||||||
|
"${configDir}/node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
26
backend/apps/backend/.env.template
Normal file
26
backend/apps/backend/.env.template
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
STORE_CORS=http://localhost:3000
|
||||||
|
ADMIN_CORS=http://localhost:9000
|
||||||
|
VENDOR_CORS=http://localhost:5173
|
||||||
|
AUTH_CORS=http://localhost:9000,http://localhost:5173,http://localhost:3000
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
JWT_SECRET=supersecret
|
||||||
|
COOKIE_SECRET=supersecret
|
||||||
|
DATABASE_URL=postgres://[user]:[password]@[address]:[port]/$DB_NAME
|
||||||
|
DB_NAME=
|
||||||
|
|
||||||
|
STRIPE_SECRET_API_KEY=supersecret
|
||||||
|
STRIPE_CONNECTED_ACCOUNTS_WEBHOOK_SECRET=supersecret
|
||||||
|
|
||||||
|
RESEND_API_KEY=supersecret
|
||||||
|
RESEND_FROM_EMAIL=onboarding@resend.dev
|
||||||
|
|
||||||
|
ALGOLIA_APP_ID=XXX
|
||||||
|
ALGOLIA_API_KEY=supersecret
|
||||||
|
|
||||||
|
VITE_TALK_JS_APP_ID=xxx
|
||||||
|
VITE_TALK_JS_SECRET_API_KEY=xxx
|
||||||
|
|
||||||
|
# Used in notifications
|
||||||
|
VENDOR_PANEL_URL=http://localhost:5173
|
||||||
|
STOREFRONT_URL=http://localhost:7001
|
||||||
|
BACKEND_URL=http://localhost:9000
|
||||||
0
backend/apps/backend/.env.test
Normal file
0
backend/apps/backend/.env.test
Normal file
28
backend/apps/backend/.gitignore
vendored
Normal file
28
backend/apps/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/dist
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
/uploads
|
||||||
|
/node_modules
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
.idea
|
||||||
|
|
||||||
|
coverage
|
||||||
|
|
||||||
|
!src/**
|
||||||
|
|
||||||
|
*.oas.json
|
||||||
|
|
||||||
|
./tsconfig.tsbuildinfo
|
||||||
|
medusa-db.sql
|
||||||
|
build
|
||||||
|
.cache
|
||||||
|
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
.medusa
|
||||||
21
backend/apps/backend/.prettierrc
Normal file
21
backend/apps/backend/.prettierrc
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 80,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"importOrder": [
|
||||||
|
"^@medusajs/(.*)$",
|
||||||
|
"^@mercurjs/(.*)$",
|
||||||
|
"^[./]"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderSortSpecifiers": true,
|
||||||
|
"plugins": [
|
||||||
|
"@trivago/prettier-plugin-sort-imports"
|
||||||
|
],
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
backend/apps/backend/.vscode/settings.json
vendored
Normal file
1
backend/apps/backend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
backend/apps/backend/.yarnrc.yml
Normal file
1
backend/apps/backend/.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
56
backend/apps/backend/Dockerfile
Normal file
56
backend/apps/backend/Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# The web Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
|
||||||
|
# Make sure you update this Dockerfile, the Dockerfile in the web workspace and copy that over to Dockerfile in the docs.
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk update
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
RUN yarn global add turbo
|
||||||
|
COPY . .
|
||||||
|
RUN turbo prune api --docker
|
||||||
|
|
||||||
|
# Add lockfile and package.json's of isolated subworkspace
|
||||||
|
FROM base AS installer
|
||||||
|
RUN apk update
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# First install dependencies (as they change less often)
|
||||||
|
COPY --from=builder /app/out/json/ .
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
|
# Build the project and its dependencies
|
||||||
|
COPY --from=builder /app/out/full/ .
|
||||||
|
|
||||||
|
# Uncomment and use build args to enable remote caching
|
||||||
|
# ARG TURBO_TEAM
|
||||||
|
# ENV TURBO_TEAM=$TURBO_TEAM
|
||||||
|
|
||||||
|
# ARG TURBO_TOKEN
|
||||||
|
# ENV TURBO_TOKEN=$TURBO_TOKEN
|
||||||
|
|
||||||
|
RUN yarn turbo build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Don't run production as root
|
||||||
|
RUN addgroup --system --gid 1001 medusa
|
||||||
|
RUN adduser --system --uid 1001 medusa
|
||||||
|
|
||||||
|
# Ensure the medusa user owns the backend folder
|
||||||
|
RUN mkdir -p /app/apps/backend/static
|
||||||
|
RUN chown -R medusa:medusa /app/apps/backend
|
||||||
|
|
||||||
|
USER medusa
|
||||||
|
COPY --from=installer /app .
|
||||||
|
|
||||||
|
WORKDIR /app/apps/backend
|
||||||
|
|
||||||
|
RUN yarn db:migrate
|
||||||
|
|
||||||
|
CMD ["yarn", "start"]
|
||||||
24
backend/apps/backend/README.md
Normal file
24
backend/apps/backend/README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Mercur Backend
|
||||||
|
|
||||||
|
Marketplace backend for Mercur.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js v20+
|
||||||
|
- PostgreSQL
|
||||||
|
- Git CLI
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `yarn build` - Build the backend
|
||||||
|
- `yarn seed` - Seed the database
|
||||||
|
- `yarn start` - Start the backend
|
||||||
|
- `yarn dev` - Start the backend in development mode
|
||||||
|
- `yarn db:migrate` - Run migrations and module links
|
||||||
|
- `yarn test:integration:http` - Run API integration tests
|
||||||
|
- `yarn test:integration:modules` - Run module integration tests
|
||||||
|
- `yarn test:unit` - Run unit tests
|
||||||
|
- `yarn format` - Format the code
|
||||||
|
- `yarn lint` - Lint the code
|
||||||
|
- `yarn lint:fix` - Fix lint errors
|
||||||
|
- `yarn generate:oas` - Generate OpenAPI specification
|
||||||
18
backend/apps/backend/eslint.config.mjs
Normal file
18
backend/apps/backend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import pluginJs from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
|
export default [
|
||||||
|
{ files: ['src/**/*.{js,mjs,cjs,ts}'] },
|
||||||
|
{ languageOptions: { globals: globals.browser } },
|
||||||
|
|
||||||
|
pluginJs.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'prefer-rest-params': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
24
backend/apps/backend/instrumentation.ts
Normal file
24
backend/apps/backend/instrumentation.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Uncomment this file to enable instrumentation and observability using OpenTelemetry
|
||||||
|
// Refer to the docs for installation instructions: https://docs.medusajs.com/v2/debugging-and-testing/instrumentation
|
||||||
|
// If using an exporter other than Zipkin, require it here.
|
||||||
|
// import { ZipkinExporter } from '@opentelemetry/exporter-zipkin'
|
||||||
|
|
||||||
|
// import { registerOtel } from '@medusajs/medusa'
|
||||||
|
|
||||||
|
// // If using an exporter other than Zipkin, initialize it here.
|
||||||
|
// const exporter = new ZipkinExporter({
|
||||||
|
// serviceName: 'my-medusa-project'
|
||||||
|
// })
|
||||||
|
|
||||||
|
// export function register() {
|
||||||
|
// registerOtel({
|
||||||
|
// serviceName: 'medusajs',
|
||||||
|
// // pass exporter
|
||||||
|
// exporter,
|
||||||
|
// instrument: {
|
||||||
|
// http: true,
|
||||||
|
// workflows: true,
|
||||||
|
// query: true
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// }
|
||||||
27
backend/apps/backend/integration-tests/http/README.md
Normal file
27
backend/apps/backend/integration-tests/http/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Integration Tests
|
||||||
|
|
||||||
|
The `medusa-test-utils` package provides utility functions to create integration tests for your API routes and workflows.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { medusaIntegrationTestRunner } from 'medusa-test-utils'
|
||||||
|
|
||||||
|
medusaIntegrationTestRunner({
|
||||||
|
testSuite: ({ api, getContainer }) => {
|
||||||
|
describe('Custom endpoints', () => {
|
||||||
|
describe('GET /store/custom', () => {
|
||||||
|
it('returns correct message', async () => {
|
||||||
|
const response = await api.get(`/store/custom`)
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200)
|
||||||
|
expect(response.data).toHaveProperty('message')
|
||||||
|
expect(response.data.message).toEqual('Hello, World!')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Learn more in [this documentation](https://docs.medusajs.com/v2/debugging-and-testing/testing-tools/integration-tests).
|
||||||
16
backend/apps/backend/integration-tests/http/health.spec.ts
Normal file
16
backend/apps/backend/integration-tests/http/health.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { medusaIntegrationTestRunner } from '@medusajs/test-utils'
|
||||||
|
|
||||||
|
jest.setTimeout(60 * 1000)
|
||||||
|
|
||||||
|
medusaIntegrationTestRunner({
|
||||||
|
inApp: true,
|
||||||
|
env: {},
|
||||||
|
testSuite: ({ api }) => {
|
||||||
|
describe('Ping', () => {
|
||||||
|
it('ping the server health endpoint', async () => {
|
||||||
|
const response = await api.get('/health')
|
||||||
|
expect(response.status).toEqual(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
26
backend/apps/backend/jest.config.js
Normal file
26
backend/apps/backend/jest.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const { loadEnv } = require('@medusajs/utils')
|
||||||
|
loadEnv('test', process.cwd())
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
transform: {
|
||||||
|
'^.+\\.[jt]s$': [
|
||||||
|
'@swc/jest',
|
||||||
|
{
|
||||||
|
jsc: {
|
||||||
|
parser: { syntax: 'typescript', decorators: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
testEnvironment: 'node',
|
||||||
|
moduleFileExtensions: ['js', 'ts', 'json'],
|
||||||
|
modulePathIgnorePatterns: ['dist/']
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.TEST_TYPE === 'integration:http') {
|
||||||
|
module.exports.testMatch = ['**/integration-tests/http/*.spec.[jt]s']
|
||||||
|
} else if (process.env.TEST_TYPE === 'integration:modules') {
|
||||||
|
module.exports.testMatch = ['**/src/modules/*/__tests__/**/*.[jt]s']
|
||||||
|
} else if (process.env.TEST_TYPE === 'unit') {
|
||||||
|
module.exports.testMatch = ['**/src/**/__tests__/**/*.unit.spec.[jt]s']
|
||||||
|
}
|
||||||
88
backend/apps/backend/medusa-config.ts
Normal file
88
backend/apps/backend/medusa-config.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { defineConfig, loadEnv } from '@medusajs/framework/utils'
|
||||||
|
|
||||||
|
loadEnv(process.env.NODE_ENV || 'development', process.cwd())
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
projectConfig: {
|
||||||
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
|
http: {
|
||||||
|
storeCors: process.env.STORE_CORS!,
|
||||||
|
adminCors: process.env.ADMIN_CORS!,
|
||||||
|
// @ts-expect-error: vendorCors is not a valid config
|
||||||
|
vendorCors: process.env.VENDOR_CORS!,
|
||||||
|
authCors: process.env.AUTH_CORS!,
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'supersecret',
|
||||||
|
cookieSecret: process.env.COOKIE_SECRET || 'supersecret'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modules: [
|
||||||
|
{ resolve: '@mercurjs/seller' },
|
||||||
|
{ resolve: '@mercurjs/reviews' },
|
||||||
|
{ resolve: '@mercurjs/marketplace' },
|
||||||
|
{ resolve: '@mercurjs/configuration' },
|
||||||
|
{ resolve: '@mercurjs/order-return-request' },
|
||||||
|
{ resolve: '@mercurjs/requests' },
|
||||||
|
{ resolve: '@mercurjs/brand' },
|
||||||
|
{ resolve: '@mercurjs/wishlist' },
|
||||||
|
{ resolve: '@mercurjs/split-order-payment' },
|
||||||
|
{ resolve: '@mercurjs/attribute' },
|
||||||
|
{
|
||||||
|
resolve: '@mercurjs/taxcode',
|
||||||
|
options: {
|
||||||
|
apiKey: process.env.STRIPE_SECRET_API_KEY
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ resolve: '@mercurjs/commission' },
|
||||||
|
{
|
||||||
|
resolve: '@mercurjs/payout',
|
||||||
|
options: {
|
||||||
|
apiKey: process.env.STRIPE_SECRET_API_KEY,
|
||||||
|
webhookSecret: process.env.STRIPE_CONNECTED_ACCOUNTS_WEBHOOK_SECRET
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: '@mercurjs/algolia',
|
||||||
|
options: {
|
||||||
|
apiKey: process.env.ALGOLIA_API_KEY,
|
||||||
|
appId: process.env.ALGOLIA_APP_ID
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: '@medusajs/medusa/payment',
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: '@mercurjs/payment-stripe-connect',
|
||||||
|
id: 'stripe-connect',
|
||||||
|
options: {
|
||||||
|
apiKey: process.env.STRIPE_SECRET_API_KEY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: '@medusajs/medusa/notification',
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: '@mercurjs/resend',
|
||||||
|
id: 'resend',
|
||||||
|
options: {
|
||||||
|
channels: ['email'],
|
||||||
|
api_key: process.env.RESEND_API_KEY,
|
||||||
|
from: process.env.RESEND_FROM_EMAIL
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: '@medusajs/medusa/notification-local',
|
||||||
|
id: 'local',
|
||||||
|
options: {
|
||||||
|
channels: ['feed', 'seller_feed']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
91
backend/apps/backend/package.json
Normal file
91
backend/apps/backend/package.json
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "A starter for Medusa projects.",
|
||||||
|
"author": "Medusa (https://medusajs.com)",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"sqlite",
|
||||||
|
"postgres",
|
||||||
|
"typescript",
|
||||||
|
"ecommerce",
|
||||||
|
"headless",
|
||||||
|
"medusa"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "medusa build && ln -s .medusa/server/public/ public",
|
||||||
|
"seed": "medusa exec ./src/scripts/seed.ts",
|
||||||
|
"start": "medusa start --types=false",
|
||||||
|
"dev": "medusa develop --types=false",
|
||||||
|
"db:migrate": "medusa db:migrate",
|
||||||
|
"test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
|
||||||
|
"test:integration:modules": "TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit",
|
||||||
|
"test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "eslint ./src ",
|
||||||
|
"lint:fix": "eslint ./src --fix ",
|
||||||
|
"generate:oas": "medusa-oas oas --type combined --paths ./src/api"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@medusajs/admin-sdk": "2.8.6",
|
||||||
|
"@medusajs/cli": "2.8.6",
|
||||||
|
"@medusajs/framework": "2.8.6",
|
||||||
|
"@medusajs/medusa": "2.8.6",
|
||||||
|
"@mercurjs/framework": "*",
|
||||||
|
"@mercurjs/configuration": "*",
|
||||||
|
"@mercurjs/marketplace": "*",
|
||||||
|
"@mercurjs/seller": "*",
|
||||||
|
"@mercurjs/reviews": "*",
|
||||||
|
"@mercurjs/payout": "*",
|
||||||
|
"@mercurjs/brand": "*",
|
||||||
|
"@mercurjs/commission": "*",
|
||||||
|
"@mercurjs/wishlist": "*",
|
||||||
|
"@mercurjs/attribute": "*",
|
||||||
|
"@mercurjs/order-return-request": "*",
|
||||||
|
"@mercurjs/requests": "*",
|
||||||
|
"@mercurjs/split-order-payment": "*",
|
||||||
|
"@mercurjs/taxcode": "*",
|
||||||
|
"@mercurjs/algolia": "*",
|
||||||
|
"@mercurjs/resend": "*",
|
||||||
|
"@mercurjs/stripe-tax-provider": "*",
|
||||||
|
"@mercurjs/payment-stripe-connect": "*",
|
||||||
|
"@mikro-orm/core": "6.4.3",
|
||||||
|
"@mikro-orm/knex": "6.4.3",
|
||||||
|
"@mikro-orm/migrations": "6.4.3",
|
||||||
|
"@mikro-orm/postgresql": "6.4.3",
|
||||||
|
"@talkjs/react": "^0.1.11",
|
||||||
|
"algoliasearch": "^5.20.2",
|
||||||
|
"awilix": "^8.0.1",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"resend": "^4.1.2",
|
||||||
|
"stripe": "^17.4.0",
|
||||||
|
"talkjs": "^0.38.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.15.0",
|
||||||
|
"@medusajs/medusa-oas-cli": "2.8.6",
|
||||||
|
"@medusajs/test-utils": "2.8.6",
|
||||||
|
"@mikro-orm/cli": "6.4.3",
|
||||||
|
"@swc/core": "1.5.7",
|
||||||
|
"@swc/jest": "^0.2.36",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
|
"@types/jest": "^29.5.13",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^18.3.2",
|
||||||
|
"@types/react-dom": "^18.2.25",
|
||||||
|
"eslint": "^9.15.0",
|
||||||
|
"globals": "^15.12.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"typescript-eslint": "^8.15.0",
|
||||||
|
"vite": "^5.2.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
backend/apps/backend/public
Symbolic link
1
backend/apps/backend/public
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
.medusa/server/public/
|
||||||
31
backend/apps/backend/src/admin/README.md
Normal file
31
backend/apps/backend/src/admin/README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Admin Customizations
|
||||||
|
|
||||||
|
You can extend the Medusa Admin to add widgets and new pages. Your customizations interact with API routes to provide merchants with custom functionalities.
|
||||||
|
|
||||||
|
## Example: Create a Widget
|
||||||
|
|
||||||
|
A widget is a React component that can be injected into an existing page in the admin dashboard.
|
||||||
|
|
||||||
|
For example, create the file `src/admin/widgets/product-widget.tsx` with the following content:
|
||||||
|
|
||||||
|
```tsx title="src/admin/widgets/product-widget.tsx"
|
||||||
|
import { defineWidgetConfig } from '@medusajs/admin-sdk'
|
||||||
|
|
||||||
|
// The widget
|
||||||
|
const ProductWidget = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Product Widget</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The widget's configurations
|
||||||
|
export const config = defineWidgetConfig({
|
||||||
|
zone: 'product.details.after'
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ProductWidget
|
||||||
|
```
|
||||||
|
|
||||||
|
This inserts a widget with the text “Product Widget” at the end of a product’s details page.
|
||||||
19
backend/apps/backend/src/admin/common/ActionsButton.tsx
Normal file
19
backend/apps/backend/src/admin/common/ActionsButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Button, DropdownMenu } from "@medusajs/ui"
|
||||||
|
import { EllipsisHorizontal } from "@medusajs/icons"
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const ActionsButton = ({actions}: {actions: {label: string, onClick: () => void, icon?: JSX.Element}[]}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenu.Trigger asChild><Button variant="transparent" className="h-8 w-12 p-0" onClick={() => setOpen(true)}><EllipsisHorizontal /></Button></DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
{actions.map(({ label, onClick, icon}) => (
|
||||||
|
<DropdownMenu.Item key={label} onClick={onClick} className="flex items-center gap-2">
|
||||||
|
{icon}{label}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
backend/apps/backend/src/admin/common/LoadingSpinner.tsx
Normal file
9
backend/apps/backend/src/admin/common/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spinner } from "@medusajs/icons"
|
||||||
|
|
||||||
|
export const LoadingSpinner = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Spinner className="text-ui-fg-interactive animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { DropdownMenu, IconButton, clx } from "@medusajs/ui"
|
||||||
|
|
||||||
|
import { EllipsisHorizontal } from "@medusajs/icons"
|
||||||
|
import { PropsWithChildren, ReactNode } from "react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { ConditionalTooltip } from "../conditional-tooltip"
|
||||||
|
|
||||||
|
export type Action = {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
/**
|
||||||
|
* Optional tooltip to display when a disabled action is hovered.
|
||||||
|
*/
|
||||||
|
disabledTooltip?: string | ReactNode
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
to: string
|
||||||
|
onClick?: never
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
onClick: () => void
|
||||||
|
to?: never
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ActionGroup = {
|
||||||
|
actions: Action[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionMenuProps = PropsWithChildren<{
|
||||||
|
groups: ActionGroup[]
|
||||||
|
variant?: "transparent" | "primary"
|
||||||
|
}>
|
||||||
|
|
||||||
|
export const ActionMenu = ({
|
||||||
|
groups,
|
||||||
|
variant = "transparent",
|
||||||
|
children,
|
||||||
|
}: ActionMenuProps) => {
|
||||||
|
const inner = children ?? (
|
||||||
|
<IconButton size="small" variant={variant}>
|
||||||
|
<EllipsisHorizontal />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenu.Trigger asChild>{inner}</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
{groups.map((group, index) => {
|
||||||
|
if (!group.actions.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLast = index === groups.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Group key={index}>
|
||||||
|
{group.actions.map((action, index) => {
|
||||||
|
const Wrapper = action.disabledTooltip
|
||||||
|
? ({ children }: { children: ReactNode }) => (
|
||||||
|
<ConditionalTooltip
|
||||||
|
showTooltip={action.disabled}
|
||||||
|
content={action.disabledTooltip}
|
||||||
|
side="right"
|
||||||
|
>
|
||||||
|
<div>{children}</div>
|
||||||
|
</ConditionalTooltip>
|
||||||
|
)
|
||||||
|
: "div"
|
||||||
|
|
||||||
|
if (action.onClick) {
|
||||||
|
return (
|
||||||
|
<Wrapper key={index}>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
action.onClick()
|
||||||
|
}}
|
||||||
|
className={clx(
|
||||||
|
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||||
|
{
|
||||||
|
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper key={index}>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
className={clx(
|
||||||
|
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||||
|
{
|
||||||
|
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
asChild
|
||||||
|
disabled={action.disabled}
|
||||||
|
>
|
||||||
|
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{action.icon}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{!isLast && <DropdownMenu.Separator />}
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Tooltip } from "@medusajs/ui"
|
||||||
|
import { ComponentPropsWithoutRef, PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
type ConditionalTooltipProps = PropsWithChildren<
|
||||||
|
ComponentPropsWithoutRef<typeof Tooltip> & {
|
||||||
|
showTooltip?: boolean
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export const ConditionalTooltip = ({
|
||||||
|
children,
|
||||||
|
showTooltip = false,
|
||||||
|
...props
|
||||||
|
}: ConditionalTooltipProps) => {
|
||||||
|
if (showTooltip) {
|
||||||
|
return <Tooltip {...props}>{children}</Tooltip>
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { StatusCell } from "../table/table-cells/common/status-cell"
|
||||||
|
|
||||||
|
export const FulfillmentStatusBadge = ({ status }: { status: string }) => {
|
||||||
|
const formattedStatus = status.charAt(0).toUpperCase() + status.slice(1)
|
||||||
|
switch(formattedStatus) {
|
||||||
|
case 'Not_fulfilled':
|
||||||
|
return <StatusCell color='orange'>{formattedStatus.replace('_', ' ')}</StatusCell>
|
||||||
|
case 'Fulfilled':
|
||||||
|
return <StatusCell color='green'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Delivered':
|
||||||
|
return <StatusCell color='green'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Canceled':
|
||||||
|
return <StatusCell color='red'>{formattedStatus}</StatusCell>
|
||||||
|
default:
|
||||||
|
return <StatusCell color='grey'>{formattedStatus}</StatusCell>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Button, Input, Text, Heading, DropdownMenu } from "@medusajs/ui"
|
||||||
|
import { useFieldArray, UseFormReturn, FieldValues, Path, FieldError, FieldErrors, ArrayPath, FieldArray } from "react-hook-form"
|
||||||
|
import { EllipsisHorizontal, Trash } from "@medusajs/icons"
|
||||||
|
|
||||||
|
interface MetadataField {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataEditorProps<T extends FieldValues & { metadata: MetadataField[] }> {
|
||||||
|
form: UseFormReturn<T>
|
||||||
|
name?: ArrayPath<T>
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MetadataEditor = <T extends FieldValues & { metadata: MetadataField[] }>({
|
||||||
|
form,
|
||||||
|
name = "metadata" as ArrayPath<T>,
|
||||||
|
title = "Metadata"
|
||||||
|
}: MetadataEditorProps<T>) => {
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getErrorMessage = (error: FieldError | undefined) => {
|
||||||
|
return error?.message ? String(error.message) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFieldErrors = (index: number) => {
|
||||||
|
const errors = form.formState.errors[name] as FieldErrors<MetadataField[]> | undefined
|
||||||
|
return {
|
||||||
|
key: errors?.[index]?.key,
|
||||||
|
value: errors?.[index]?.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-2 mt-4">
|
||||||
|
<Heading level="h3" className="inter-small-semibold mb-2">{title}</Heading>
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_40px] bg-ui-bg-subtle border-b py-2 px-3 text-ui-fg-subtle text-sm font-semibold">
|
||||||
|
<span className="border-r pr-2">Key</span>
|
||||||
|
<span>Value</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
{fields.map((field, index) => {
|
||||||
|
const fieldErrors = getFieldErrors(index)
|
||||||
|
return (
|
||||||
|
<div key={field.id} className="grid grid-cols-[1fr_1fr_40px] items-center border-b last:border-b-0">
|
||||||
|
<div className="py-2 pl-3 pr-2 border-r">
|
||||||
|
<Input
|
||||||
|
placeholder="Key"
|
||||||
|
className="!shadow-none !border-none focus-visible:!outline-none bg-transparent"
|
||||||
|
{...form.register(`${name}.${index}.key` as Path<T>)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.key && (
|
||||||
|
<Text className="text-red-500 text-sm mt-1">
|
||||||
|
{getErrorMessage(fieldErrors.key)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="py-2 pl-3 pr-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Value"
|
||||||
|
className="!shadow-none !border-none focus-visible:!outline-none bg-transparent"
|
||||||
|
{...form.register(`${name}.${index}.value` as Path<T>)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.value && (
|
||||||
|
<Text className="text-red-500 text-sm mt-1">
|
||||||
|
{getErrorMessage(fieldErrors.value)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pr-3">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<Button variant="transparent" size="small">
|
||||||
|
<EllipsisHorizontal />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item onClick={() => remove(index)} className="gap-x-2">
|
||||||
|
<Trash className="text-ui-fg-subtle" />
|
||||||
|
Remove
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="p-3">
|
||||||
|
<Button type="button" variant="secondary" size="small" onClick={() => append({ key: "", value: "" } as FieldArray<T, ArrayPath<T>>)} className="w-full">
|
||||||
|
+ Add Row
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StatusCell } from "../table/table-cells/common/status-cell"
|
||||||
|
|
||||||
|
export const OrderStatusBadge = ({ status }: { status: string }) => {
|
||||||
|
const formattedStatus = status.charAt(0).toUpperCase() + status.slice(1)
|
||||||
|
switch(formattedStatus) {
|
||||||
|
case 'Pending':
|
||||||
|
return <StatusCell color='orange'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Completed':
|
||||||
|
return <StatusCell color='green'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Canceled':
|
||||||
|
return <StatusCell color='red'>{formattedStatus}</StatusCell>
|
||||||
|
default:
|
||||||
|
return <StatusCell color='grey'>{formattedStatus}</StatusCell>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { StatusCell } from "../table/table-cells/common/status-cell"
|
||||||
|
|
||||||
|
export const PaymentStatusBadge = ({ status }: { status: string }) => {
|
||||||
|
const formattedStatus = status.charAt(0).toUpperCase() + status.slice(1)
|
||||||
|
switch(formattedStatus) {
|
||||||
|
case 'Pending':
|
||||||
|
return <StatusCell color='orange'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Captured':
|
||||||
|
return <StatusCell color='green'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Completed':
|
||||||
|
return <StatusCell color='green'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Cancelled':
|
||||||
|
return <StatusCell color='red'>{formattedStatus}</StatusCell>
|
||||||
|
default:
|
||||||
|
return <StatusCell color='grey'>{formattedStatus}</StatusCell>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Select } from "@medusajs/ui"
|
||||||
|
|
||||||
|
export const AttributeSelect = ({
|
||||||
|
values,
|
||||||
|
field,
|
||||||
|
}: {
|
||||||
|
values: any[]
|
||||||
|
field: any
|
||||||
|
}) => {
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
field.onChange({
|
||||||
|
target: {
|
||||||
|
name: field.name,
|
||||||
|
value: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select onValueChange={(value) => handleChange(value)} value={field.value}>
|
||||||
|
<Select.Trigger className="bg-ui-bg-base">
|
||||||
|
<Select.Value placeholder="Select value" />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{values.map(({ id, attribute_id, value }) => (
|
||||||
|
<Select.Item
|
||||||
|
key={`select-option-${attribute_id}-${id}`}
|
||||||
|
value={value}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Input, Switch, Textarea } from "@medusajs/ui"
|
||||||
|
import { AttributeSelect } from "./AttributeSelect"
|
||||||
|
|
||||||
|
|
||||||
|
export const Components = ({
|
||||||
|
attribute,
|
||||||
|
field,
|
||||||
|
}: {
|
||||||
|
attribute: any
|
||||||
|
field: any
|
||||||
|
}) => {
|
||||||
|
const { ui_component, possible_values } = attribute
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (ui_component === "select")
|
||||||
|
return <AttributeSelect values={possible_values} field={field} />
|
||||||
|
|
||||||
|
if (ui_component === "toggle")
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
name={field.name}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
field.onChange({
|
||||||
|
target: {
|
||||||
|
name: field.name,
|
||||||
|
value: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
checked={field.value === "true" || field.value === true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (ui_component === "text_area") return <Textarea {...field} rows={4} />
|
||||||
|
|
||||||
|
if (ui_component === "unit") return <Input type="number" {...field} />
|
||||||
|
|
||||||
|
if (ui_component === "text") return <Input {...field} />
|
||||||
|
|
||||||
|
return <Input {...field} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { StatusCell } from "../table/table-cells/common/status-cell"
|
||||||
|
|
||||||
|
export const ProductStatusBadge = ({ status }: { status: string }) => {
|
||||||
|
const formattedStatus = status.charAt(0).toUpperCase() + status.slice(1)
|
||||||
|
switch(formattedStatus) {
|
||||||
|
case '':
|
||||||
|
return <StatusCell color='orange'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Published':
|
||||||
|
return <StatusCell color='green'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Canceled':
|
||||||
|
return <StatusCell color='red'>{formattedStatus}</StatusCell>
|
||||||
|
case 'Draft':
|
||||||
|
return <StatusCell color='grey'>{formattedStatus}</StatusCell>
|
||||||
|
default:
|
||||||
|
return <StatusCell color='grey'>{formattedStatus}</StatusCell>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Drawer } from "@medusajs/ui"
|
||||||
|
|
||||||
|
export const RouteDrawer = ({
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
header = ''
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode,
|
||||||
|
onClose: (open: boolean) => void,
|
||||||
|
header?: string
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Drawer open={true} onOpenChange={onClose}>
|
||||||
|
<Drawer.Content className="!w-1/3 !right-0">
|
||||||
|
<Drawer.Header>
|
||||||
|
<Drawer.Title>{header}</Drawer.Title>
|
||||||
|
</Drawer.Header>
|
||||||
|
<Drawer.Body className="overflow-y-auto h-full">
|
||||||
|
<div className="flex flex-col gap-4 px-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Drawer.Body>
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
backend/apps/backend/src/admin/components/section-row.tsx
Normal file
41
backend/apps/backend/src/admin/components/section-row.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Text, clx } from "@medusajs/ui";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type SectionRowProps = {
|
||||||
|
title: string;
|
||||||
|
value?: ReactNode | string | null;
|
||||||
|
actions?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SectionRow = ({ title, value, actions }: SectionRowProps) => {
|
||||||
|
const isValueString = typeof value === "string" || !value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
`text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4`,
|
||||||
|
{
|
||||||
|
"grid-cols-[1fr_1fr_28px]": !!actions,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isValueString ? (
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
className="whitespace-pre-line text-pretty"
|
||||||
|
>
|
||||||
|
{value ?? "-"}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">{value}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actions && <div>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { StatusCell } from "../table/table-cells/common/status-cell"
|
||||||
|
|
||||||
|
export const SellerStatusBadge = ({ status }: { status: string }) => {
|
||||||
|
switch(status) {
|
||||||
|
case "INACTIVE":
|
||||||
|
return <StatusCell color='orange'>{status}</StatusCell>
|
||||||
|
case 'ACTIVE':
|
||||||
|
return <StatusCell color='green'>{status}</StatusCell>
|
||||||
|
case 'SUSPENDED':
|
||||||
|
return <StatusCell color='red'>{status}</StatusCell>
|
||||||
|
default:
|
||||||
|
return <StatusCell color='grey'>{status}</StatusCell>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createContext, useContext } from "react"
|
||||||
|
|
||||||
|
type DataTableFilterContextValue = {
|
||||||
|
removeFilter: (key: string) => void
|
||||||
|
removeAllFilters: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableFilterContext =
|
||||||
|
createContext<DataTableFilterContextValue | null>(null)
|
||||||
|
|
||||||
|
export const useDataTableFilterContext = () => {
|
||||||
|
const ctx = useContext(DataTableFilterContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useDataTableFacetedFilterContext must be used within a DataTableFacetedFilter"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { Button, clx } from "@medusajs/ui"
|
||||||
|
import * as Popover from "@radix-ui/react-popover"
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { DataTableFilterContext, useDataTableFilterContext } from "./context"
|
||||||
|
import { NumberFilter } from "./number-filter"
|
||||||
|
import { SelectFilter } from "./select-filter"
|
||||||
|
import { StringFilter } from "./string-filter"
|
||||||
|
import { DateFilter } from "./date-filter"
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
label: string
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Filter = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
type: "select"
|
||||||
|
options: Option[]
|
||||||
|
multiple?: boolean
|
||||||
|
searchable?: boolean
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "date"
|
||||||
|
options?: never
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "string"
|
||||||
|
options?: never
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "number"
|
||||||
|
options?: never
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type DataTableFilterProps = {
|
||||||
|
filters: Filter[]
|
||||||
|
readonly?: boolean
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableFilter = ({
|
||||||
|
filters,
|
||||||
|
readonly,
|
||||||
|
prefix,
|
||||||
|
}: DataTableFilterProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const [activeFilters, setActiveFilters] = useState(
|
||||||
|
getInitialFilters({ searchParams, filters, prefix })
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableFilters = filters.filter(
|
||||||
|
(f) => !activeFilters.find((af) => af.key === f.key)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there are any filters in the URL that are not in the active filters,
|
||||||
|
* add them to the active filters. This ensures that we display the filters
|
||||||
|
* if a user navigates to a page with filters in the URL.
|
||||||
|
*/
|
||||||
|
const initialMount = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialMount.current) {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
const key = prefix ? `${prefix}_${filter.key}` : filter.key
|
||||||
|
const value = params.get(key)
|
||||||
|
if (value && !activeFilters.find((af) => af.key === filter.key)) {
|
||||||
|
if (filter.type === "select") {
|
||||||
|
setActiveFilters((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
...filter,
|
||||||
|
multiple: filter.multiple,
|
||||||
|
options: filter.options,
|
||||||
|
openOnMount: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
setActiveFilters((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ ...filter, openOnMount: false },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
initialMount.current = false
|
||||||
|
}, [activeFilters, filters, prefix, searchParams])
|
||||||
|
|
||||||
|
const addFilter = (filter: Filter) => {
|
||||||
|
setOpen(false)
|
||||||
|
setActiveFilters((prev) => [...prev, { ...filter, openOnMount: true }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFilter = useCallback((key: string) => {
|
||||||
|
setActiveFilters((prev) => prev.filter((f) => f.key !== key))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeAllFilters = useCallback(() => {
|
||||||
|
setActiveFilters([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTableFilterContext.Provider
|
||||||
|
value={useMemo(
|
||||||
|
() => ({
|
||||||
|
removeFilter,
|
||||||
|
removeAllFilters,
|
||||||
|
}),
|
||||||
|
[removeAllFilters, removeFilter]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="max-w-2/3 flex flex-wrap items-center gap-2">
|
||||||
|
{activeFilters.map((filter) => {
|
||||||
|
switch (filter.type) {
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<SelectFilter
|
||||||
|
key={filter.key}
|
||||||
|
filter={filter}
|
||||||
|
prefix={prefix}
|
||||||
|
readonly={readonly}
|
||||||
|
options={filter.options}
|
||||||
|
multiple={filter.multiple}
|
||||||
|
searchable={filter.searchable}
|
||||||
|
openOnMount={filter.openOnMount}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<DateFilter
|
||||||
|
key={filter.key}
|
||||||
|
filter={filter}
|
||||||
|
prefix={prefix}
|
||||||
|
readonly={readonly}
|
||||||
|
openOnMount={filter.openOnMount}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "string":
|
||||||
|
return (
|
||||||
|
<StringFilter
|
||||||
|
key={filter.key}
|
||||||
|
filter={filter}
|
||||||
|
prefix={prefix}
|
||||||
|
readonly={readonly}
|
||||||
|
openOnMount={filter.openOnMount}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<NumberFilter
|
||||||
|
key={filter.key}
|
||||||
|
filter={filter}
|
||||||
|
prefix={prefix}
|
||||||
|
readonly={readonly}
|
||||||
|
openOnMount={filter.openOnMount}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{!readonly && availableFilters.length > 0 && (
|
||||||
|
<Popover.Root modal open={open} onOpenChange={setOpen}>
|
||||||
|
<Popover.Trigger asChild id="filters_menu_trigger">
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
{t("filters.addFilter")}
|
||||||
|
</Button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-auto rounded-lg p-1 outline-none"
|
||||||
|
)}
|
||||||
|
data-name="filters_menu_content"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={8}
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
const hasOpenFilter = activeFilters.find(
|
||||||
|
(filter) => filter.openOnMount
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasOpenFilter) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{availableFilters.map((filter) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||||
|
role="menuitem"
|
||||||
|
key={filter.key}
|
||||||
|
onClick={() => {
|
||||||
|
addFilter(filter)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
)}
|
||||||
|
{!readonly && activeFilters.length > 0 && (
|
||||||
|
<ClearAllFilters filters={filters} prefix={prefix} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DataTableFilterContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClearAllFiltersProps = {
|
||||||
|
filters: Filter[]
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClearAllFilters = ({ filters, prefix }: ClearAllFiltersProps) => {
|
||||||
|
const { removeAllFilters } = useDataTableFilterContext()
|
||||||
|
const [_, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const handleRemoveAll = () => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const newValues = new URLSearchParams(prev)
|
||||||
|
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
newValues.delete(prefix ? `${prefix}_${filter.key}` : filter.key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return newValues
|
||||||
|
})
|
||||||
|
|
||||||
|
removeAllFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveAll}
|
||||||
|
className={clx(
|
||||||
|
"text-ui-fg-muted transition-fg txt-compact-small-plus rounded-md px-2 py-1",
|
||||||
|
"hover:text-ui-fg-subtle",
|
||||||
|
"focus-visible:shadow-borders-focus"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitialFilters = ({
|
||||||
|
searchParams,
|
||||||
|
filters,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
searchParams: URLSearchParams
|
||||||
|
filters: Filter[]
|
||||||
|
prefix?: string
|
||||||
|
}) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
const activeFilters: (Filter & { openOnMount: boolean })[] = []
|
||||||
|
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
const key = prefix ? `${prefix}_${filter.key}` : filter.key
|
||||||
|
const value = params.get(key)
|
||||||
|
if (value) {
|
||||||
|
if (filter.type === "select") {
|
||||||
|
activeFilters.push({
|
||||||
|
...filter,
|
||||||
|
multiple: filter.multiple,
|
||||||
|
options: filter.options,
|
||||||
|
openOnMount: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
activeFilters.push({ ...filter, openOnMount: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return activeFilters
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { EllipseMiniSolid } from "@medusajs/icons"
|
||||||
|
import { DatePicker, Text, clx } from "@medusajs/ui"
|
||||||
|
import isEqual from "lodash/isEqual"
|
||||||
|
import { Popover as RadixPopover } from "radix-ui"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { t } from "i18next"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useSelectedParams } from "../hooks"
|
||||||
|
import { useDataTableFilterContext } from "./context"
|
||||||
|
import FilterChip from "./filter-chip"
|
||||||
|
import { IFilter } from "./types"
|
||||||
|
import { useDate } from "../../../../hooks/use-date"
|
||||||
|
|
||||||
|
type DateFilterProps = IFilter
|
||||||
|
|
||||||
|
type DateComparisonOperator = {
|
||||||
|
/**
|
||||||
|
* The filtered date must be greater than or equal to this value.
|
||||||
|
*/
|
||||||
|
$gte?: string
|
||||||
|
/**
|
||||||
|
* The filtered date must be less than or equal to this value.
|
||||||
|
*/
|
||||||
|
$lte?: string
|
||||||
|
/**
|
||||||
|
* The filtered date must be less than this value.
|
||||||
|
*/
|
||||||
|
$lt?: string
|
||||||
|
/**
|
||||||
|
* The filtered date must be greater than this value.
|
||||||
|
*/
|
||||||
|
$gt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateFilter = ({
|
||||||
|
filter,
|
||||||
|
prefix,
|
||||||
|
readonly,
|
||||||
|
openOnMount,
|
||||||
|
}: DateFilterProps) => {
|
||||||
|
const [open, setOpen] = useState(openOnMount)
|
||||||
|
const [showCustom, setShowCustom] = useState(false)
|
||||||
|
|
||||||
|
const { getFullDate } = useDate()
|
||||||
|
|
||||||
|
const { key, label } = filter
|
||||||
|
|
||||||
|
const { removeFilter } = useDataTableFilterContext()
|
||||||
|
const selectedParams = useSelectedParams({ param: key, prefix })
|
||||||
|
|
||||||
|
const presets = usePresets()
|
||||||
|
|
||||||
|
const handleSelectPreset = (value: DateComparisonOperator) => {
|
||||||
|
selectedParams.add(JSON.stringify(value))
|
||||||
|
setShowCustom(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectCustom = () => {
|
||||||
|
selectedParams.delete()
|
||||||
|
setShowCustom((prev) => !prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = selectedParams.get()
|
||||||
|
|
||||||
|
const currentDateComparison = parseDateComparison(currentValue)
|
||||||
|
const customStartValue = getDateFromComparison(currentDateComparison, "$gte")
|
||||||
|
const customEndValue = getDateFromComparison(currentDateComparison, "$lte")
|
||||||
|
|
||||||
|
const handleCustomDateChange = (value: Date | null, pos: "start" | "end") => {
|
||||||
|
const key = pos === "start" ? "$gte" : "$lte"
|
||||||
|
const dateValue = value ? value.toISOString() : undefined
|
||||||
|
|
||||||
|
selectedParams.add(
|
||||||
|
JSON.stringify({
|
||||||
|
...(currentDateComparison || {}),
|
||||||
|
[key]: dateValue,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayValueFromPresets = () => {
|
||||||
|
const preset = presets.find((p) => isEqual(p.value, currentDateComparison))
|
||||||
|
return preset?.label
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCustomDate = (date: Date | undefined) => {
|
||||||
|
return date ? getFullDate({ date: date }) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomDisplayValue = () => {
|
||||||
|
const formattedDates = [customStartValue, customEndValue].map(
|
||||||
|
formatCustomDate
|
||||||
|
)
|
||||||
|
return formattedDates.filter(Boolean).join(" - ")
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayValue = getDisplayValueFromPresets() || getCustomDisplayValue()
|
||||||
|
|
||||||
|
const [previousValue, setPreviousValue] = useState<string | undefined>(
|
||||||
|
displayValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
selectedParams.delete()
|
||||||
|
removeFilter(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setOpen(open)
|
||||||
|
setPreviousValue(displayValue)
|
||||||
|
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open && !currentValue.length) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
removeFilter(key)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadixPopover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<FilterChip
|
||||||
|
hadPreviousValue={!!previousValue}
|
||||||
|
label={label}
|
||||||
|
value={displayValue}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
{!readonly && (
|
||||||
|
<RadixPopover.Portal>
|
||||||
|
<RadixPopover.Content
|
||||||
|
data-name="date_filter_content"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={24}
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout h-full max-h-[var(--radix-popper-available-height)] w-[300px] overflow-auto rounded-lg"
|
||||||
|
)}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
if (e.target instanceof HTMLElement) {
|
||||||
|
if (
|
||||||
|
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||||
|
"filters_menu_content"
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ul className="w-full p-1">
|
||||||
|
{presets.map((preset) => {
|
||||||
|
const isSelected = selectedParams
|
||||||
|
.get()
|
||||||
|
.includes(JSON.stringify(preset.value))
|
||||||
|
return (
|
||||||
|
<li key={preset.label}>
|
||||||
|
<button
|
||||||
|
className="bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex w-full cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handleSelectPreset(preset.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"transition-fg flex h-5 w-5 items-center justify-center",
|
||||||
|
{
|
||||||
|
"[&_svg]:invisible": !isSelected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<EllipseMiniSolid />
|
||||||
|
</div>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex w-full cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectCustom}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"transition-fg flex h-5 w-5 items-center justify-center",
|
||||||
|
{
|
||||||
|
"[&_svg]:invisible": !showCustom,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<EllipseMiniSolid />
|
||||||
|
</div>
|
||||||
|
{t("filters.date.custom")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{showCustom && (
|
||||||
|
<div className="border-t px-1 pb-3 pt-1">
|
||||||
|
<div>
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<Text size="xsmall" leading="compact" weight="plus">
|
||||||
|
{t("filters.date.from")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<DatePicker
|
||||||
|
modal
|
||||||
|
maxValue={customEndValue}
|
||||||
|
value={customStartValue}
|
||||||
|
onChange={(d) => handleCustomDateChange(d, "start")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<Text size="xsmall" leading="compact" weight="plus">
|
||||||
|
{t("filters.date.to")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<DatePicker
|
||||||
|
modal
|
||||||
|
minValue={customStartValue}
|
||||||
|
value={customEndValue || undefined}
|
||||||
|
onChange={(d) => {
|
||||||
|
handleCustomDateChange(d, "end")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</RadixPopover.Content>
|
||||||
|
</RadixPopover.Portal>
|
||||||
|
)}
|
||||||
|
</RadixPopover.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const usePresets = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("filters.date.today"),
|
||||||
|
value: {
|
||||||
|
$gte: today.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("filters.date.lastSevenDays"),
|
||||||
|
value: {
|
||||||
|
$gte: new Date(
|
||||||
|
today.getTime() - 7 * 24 * 60 * 60 * 1000
|
||||||
|
).toISOString(), // 7 days ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("filters.date.lastThirtyDays"),
|
||||||
|
value: {
|
||||||
|
$gte: new Date(
|
||||||
|
today.getTime() - 30 * 24 * 60 * 60 * 1000
|
||||||
|
).toISOString(), // 30 days ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("filters.date.lastNinetyDays"),
|
||||||
|
value: {
|
||||||
|
$gte: new Date(
|
||||||
|
today.getTime() - 90 * 24 * 60 * 60 * 1000
|
||||||
|
).toISOString(), // 90 days ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("filters.date.lastTwelveMonths"),
|
||||||
|
value: {
|
||||||
|
$gte: new Date(
|
||||||
|
today.getTime() - 365 * 24 * 60 * 60 * 1000
|
||||||
|
).toISOString(), // 365 days ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseDateComparison = (value: string[]) => {
|
||||||
|
return value?.length
|
||||||
|
? (JSON.parse(value.join(",")) as DateComparisonOperator)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDateFromComparison = (
|
||||||
|
comparison: DateComparisonOperator | null,
|
||||||
|
key: "$gte" | "$lte"
|
||||||
|
) => {
|
||||||
|
return comparison?.[key] ? new Date(comparison[key] as string) : undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { XMarkMini } from "@medusajs/icons"
|
||||||
|
import { Text, clx } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { MouseEvent } from "react"
|
||||||
|
import * as Popover from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
export type FilterChipProps = {
|
||||||
|
hadPreviousValue?: boolean
|
||||||
|
label: string
|
||||||
|
value?: string
|
||||||
|
readonly?: boolean
|
||||||
|
hasOperator?: boolean
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterChip = ({
|
||||||
|
hadPreviousValue,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
readonly,
|
||||||
|
hasOperator,
|
||||||
|
onRemove,
|
||||||
|
}: FilterChipProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleRemove = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-ui-bg-field transition-fg shadow-borders-base text-ui-fg-subtle flex cursor-default select-none items-stretch overflow-hidden rounded-md"
|
||||||
|
>
|
||||||
|
{!hadPreviousValue && (
|
||||||
|
<Popover.Anchor />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"flex items-center justify-center whitespace-nowrap px-2 py-1",
|
||||||
|
{
|
||||||
|
"border-r": !!(value || hadPreviousValue),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center overflow-hidden">
|
||||||
|
{hasOperator && !!(value || hadPreviousValue) && (
|
||||||
|
<div className="border-r p-1 px-2">
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
weight="plus"
|
||||||
|
leading="compact"
|
||||||
|
className="text-ui-fg-muted"
|
||||||
|
>
|
||||||
|
{t("general.is")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!(value || hadPreviousValue) && (
|
||||||
|
<Popover.Trigger asChild className={clx("flex-1 cursor-pointer overflow-hidden border-r p-1 px-2",
|
||||||
|
{
|
||||||
|
"hover:bg-ui-bg-field-hover": !readonly,
|
||||||
|
"data-[state=open]:bg-ui-bg-field-hover": !readonly,
|
||||||
|
}
|
||||||
|
)}>
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
weight="plus"
|
||||||
|
className="truncate text-nowrap"
|
||||||
|
>
|
||||||
|
{value || "\u00A0"}
|
||||||
|
</Text>
|
||||||
|
</Popover.Trigger>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!readonly && !!(value || hadPreviousValue) && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
className={clx(
|
||||||
|
"text-ui-fg-muted transition-fg flex items-center justify-center p-1",
|
||||||
|
"hover:bg-ui-bg-subtle-hover",
|
||||||
|
"active:bg-ui-bg-subtle-pressed active:text-ui-fg-base"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<XMarkMini />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterChip
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { EllipseMiniSolid } from "@medusajs/icons"
|
||||||
|
import { Input, Label, clx } from "@medusajs/ui"
|
||||||
|
import * as Popover from "@radix-ui/react-popover"
|
||||||
|
import * as RadioGroup from "@radix-ui/react-radio-group"
|
||||||
|
import { debounce } from "lodash"
|
||||||
|
import {
|
||||||
|
ChangeEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { useSelectedParams } from "../hooks"
|
||||||
|
import { useDataTableFilterContext } from "./context"
|
||||||
|
import { IFilter } from "./types"
|
||||||
|
import { TFunction } from "i18next"
|
||||||
|
import FilterChip from "./filter-chip"
|
||||||
|
|
||||||
|
type NumberFilterProps = IFilter
|
||||||
|
|
||||||
|
type Comparison = "exact" | "range"
|
||||||
|
type Operator = "lt" | "gt" | "eq"
|
||||||
|
|
||||||
|
export const NumberFilter = ({
|
||||||
|
filter,
|
||||||
|
prefix,
|
||||||
|
readonly,
|
||||||
|
openOnMount,
|
||||||
|
}: NumberFilterProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(openOnMount)
|
||||||
|
|
||||||
|
const { key, label } = filter
|
||||||
|
|
||||||
|
const { removeFilter } = useDataTableFilterContext()
|
||||||
|
const selectedParams = useSelectedParams({
|
||||||
|
param: key,
|
||||||
|
prefix,
|
||||||
|
multiple: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentValue = selectedParams.get()
|
||||||
|
const [previousValue, setPreviousValue] = useState<string[] | undefined>(
|
||||||
|
currentValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const [operator, setOperator] = useState<Comparison | undefined>(
|
||||||
|
getOperator(currentValue)
|
||||||
|
)
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const debouncedOnChange = useCallback(
|
||||||
|
debounce((e: ChangeEvent<HTMLInputElement>, operator: Operator) => {
|
||||||
|
const value = e.target.value
|
||||||
|
const curr = JSON.parse(currentValue?.join(",") || "{}")
|
||||||
|
const isCurrentNumber = !isNaN(Number(curr))
|
||||||
|
|
||||||
|
const handleValue = (operator: Operator) => {
|
||||||
|
if (!value && isCurrentNumber) {
|
||||||
|
selectedParams.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curr && !value) {
|
||||||
|
delete curr[operator]
|
||||||
|
selectedParams.add(JSON.stringify(curr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!curr) {
|
||||||
|
selectedParams.add(JSON.stringify({ [operator]: value }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedParams.add(JSON.stringify({ ...curr, [operator]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case "eq":
|
||||||
|
if (!value) {
|
||||||
|
selectedParams.delete()
|
||||||
|
} else {
|
||||||
|
selectedParams.add(value)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "lt":
|
||||||
|
case "gt":
|
||||||
|
handleValue(operator)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[selectedParams, currentValue]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedOnChange.cancel()
|
||||||
|
}
|
||||||
|
}, [debouncedOnChange])
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setOpen(open)
|
||||||
|
setPreviousValue(currentValue)
|
||||||
|
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open && !currentValue.length) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
removeFilter(key)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
selectedParams.delete()
|
||||||
|
removeFilter(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const operators: { operator: Comparison; label: string }[] = [
|
||||||
|
{
|
||||||
|
operator: "exact",
|
||||||
|
label: t("filters.compare.exact"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: "range",
|
||||||
|
label: t("filters.compare.range"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const GT_KEY = `${key}-gt`
|
||||||
|
const LT_KEY = `${key}-lt`
|
||||||
|
const EQ_KEY = key
|
||||||
|
|
||||||
|
const displayValue = parseDisplayValue(currentValue, t)
|
||||||
|
const previousDisplayValue = parseDisplayValue(previousValue, t)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<FilterChip
|
||||||
|
hasOperator
|
||||||
|
hadPreviousValue={!!previousDisplayValue}
|
||||||
|
label={label}
|
||||||
|
value={displayValue}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
{!readonly && (
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
data-name="number_filter_content"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={24}
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout max-h-[var(--radix-popper-available-height)] w-[300px] divide-y overflow-y-auto rounded-lg outline-none"
|
||||||
|
)}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
if (e.target instanceof HTMLElement) {
|
||||||
|
if (
|
||||||
|
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||||
|
"filters_menu_content"
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-1">
|
||||||
|
<RadioGroup.Root
|
||||||
|
value={operator}
|
||||||
|
onValueChange={(val) => setOperator(val as Comparison)}
|
||||||
|
className="flex flex-col items-start"
|
||||||
|
orientation="vertical"
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
{operators.map((o) => (
|
||||||
|
<RadioGroup.Item
|
||||||
|
key={o.operator}
|
||||||
|
value={o.operator}
|
||||||
|
className="txt-compact-small hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed transition-fg grid w-full grid-cols-[20px_1fr] gap-2 rounded-[4px] px-2 py-1.5 text-left outline-none"
|
||||||
|
>
|
||||||
|
<div className="size-5">
|
||||||
|
<RadioGroup.Indicator>
|
||||||
|
<EllipseMiniSolid />
|
||||||
|
</RadioGroup.Indicator>
|
||||||
|
</div>
|
||||||
|
<span className="w-full">{o.label}</span>
|
||||||
|
</RadioGroup.Item>
|
||||||
|
))}
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{operator === "range" ? (
|
||||||
|
<div className="px-1 pb-3 pt-1" key="range">
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<Label size="xsmall" weight="plus" htmlFor={GT_KEY}>
|
||||||
|
{t("filters.compare.greaterThan")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-0.5">
|
||||||
|
<Input
|
||||||
|
name={GT_KEY}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
defaultValue={getValue(currentValue, "gt")}
|
||||||
|
onChange={(e) => debouncedOnChange(e, "gt")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<Label size="xsmall" weight="plus" htmlFor={LT_KEY}>
|
||||||
|
{t("filters.compare.lessThan")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-0.5">
|
||||||
|
<Input
|
||||||
|
name={LT_KEY}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
defaultValue={getValue(currentValue, "lt")}
|
||||||
|
onChange={(e) => debouncedOnChange(e, "lt")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-1 pb-3 pt-1" key="exact">
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<Label size="xsmall" weight="plus" htmlFor={EQ_KEY}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-0.5">
|
||||||
|
<Input
|
||||||
|
name={EQ_KEY}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
defaultValue={getValue(currentValue, "eq")}
|
||||||
|
onChange={(e) => debouncedOnChange(e, "eq")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseDisplayValue = (value: string[] | null | undefined, t: TFunction) => {
|
||||||
|
const parsed = JSON.parse(value?.join(",") || "{}")
|
||||||
|
let displayValue = ""
|
||||||
|
|
||||||
|
if (typeof parsed === "object") {
|
||||||
|
const parts = []
|
||||||
|
if (parsed.gt) {
|
||||||
|
parts.push(t("filters.compare.greaterThanLabel", { value: parsed.gt }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.lt) {
|
||||||
|
parts.push(
|
||||||
|
t("filters.compare.lessThanLabel", {
|
||||||
|
value: parsed.lt,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
displayValue = parts.join(` ${t("filters.compare.andLabel")} `)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed === "number") {
|
||||||
|
displayValue = parsed.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseValue = (value: string[] | null | undefined) => {
|
||||||
|
if (!value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const val = value.join(",")
|
||||||
|
if (!val) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getValue = (
|
||||||
|
value: string[] | null | undefined,
|
||||||
|
key: Operator
|
||||||
|
): number | undefined => {
|
||||||
|
const parsed = parseValue(value)
|
||||||
|
|
||||||
|
if (typeof parsed === "object") {
|
||||||
|
return parsed[key]
|
||||||
|
}
|
||||||
|
if (typeof parsed === "number" && key === "eq") {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperator = (value?: string[] | null): Comparison | undefined => {
|
||||||
|
const parsed = parseValue(value)
|
||||||
|
|
||||||
|
return typeof parsed === "object" ? "range" : "exact"
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { CheckMini, EllipseMiniSolid, XMarkMini } from "@medusajs/icons"
|
||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import * as Popover from "@radix-ui/react-popover"
|
||||||
|
import { Command } from "cmdk"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { useSelectedParams } from "../hooks"
|
||||||
|
import { useDataTableFilterContext } from "./context"
|
||||||
|
import { IFilter } from "./types"
|
||||||
|
import FilterChip from "./filter-chip"
|
||||||
|
|
||||||
|
interface SelectFilterProps extends IFilter {
|
||||||
|
options: { label: string; value: unknown }[]
|
||||||
|
readonly?: boolean
|
||||||
|
multiple?: boolean
|
||||||
|
searchable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectFilter = ({
|
||||||
|
filter,
|
||||||
|
prefix,
|
||||||
|
readonly,
|
||||||
|
multiple,
|
||||||
|
searchable,
|
||||||
|
options,
|
||||||
|
openOnMount,
|
||||||
|
}: SelectFilterProps) => {
|
||||||
|
const [open, setOpen] = useState(openOnMount)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [searchRef, setSearchRef] = useState<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { removeFilter } = useDataTableFilterContext()
|
||||||
|
|
||||||
|
const { key, label } = filter
|
||||||
|
const selectedParams = useSelectedParams({ param: key, prefix, multiple })
|
||||||
|
const currentValue = selectedParams.get()
|
||||||
|
|
||||||
|
const labelValues = currentValue
|
||||||
|
.map((v) => options.find((o) => o.value === v)?.label)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
|
||||||
|
const [previousValue, setPreviousValue] = useState<string | string[] | undefined>(labelValues)
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
selectedParams.delete()
|
||||||
|
removeFilter(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setOpen(open)
|
||||||
|
|
||||||
|
setPreviousValue(labelValues)
|
||||||
|
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open && !currentValue.length) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
removeFilter(key)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearch("")
|
||||||
|
|
||||||
|
if (searchRef) {
|
||||||
|
searchRef.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (value: unknown) => {
|
||||||
|
const isSelected = selectedParams.get().includes(String(value))
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
selectedParams.delete(String(value))
|
||||||
|
} else {
|
||||||
|
selectedParams.add(String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValues = labelValues ? (Array.isArray(labelValues) ? labelValues : [labelValues]) : null
|
||||||
|
const normalizedPrev = previousValue ? (Array.isArray(previousValue) ? previousValue : [previousValue]) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<FilterChip
|
||||||
|
hasOperator
|
||||||
|
hadPreviousValue={!!normalizedPrev?.length}
|
||||||
|
readonly={readonly}
|
||||||
|
label={label}
|
||||||
|
value={normalizedValues?.join(", ")}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
{!readonly && (
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
hideWhenDetached
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={8}
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-hidden rounded-lg outline-none"
|
||||||
|
)}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
if (e.target instanceof HTMLElement) {
|
||||||
|
if (
|
||||||
|
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||||
|
"filters_menu_content"
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Command className="h-full">
|
||||||
|
{searchable && (
|
||||||
|
<div className="border-b p-1">
|
||||||
|
<div className="grid grid-cols-[1fr_20px] gap-x-2 rounded-md px-2 py-1">
|
||||||
|
<Command.Input
|
||||||
|
ref={setSearchRef}
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
className="txt-compact-small placeholder:text-ui-fg-muted bg-transparent outline-none"
|
||||||
|
placeholder="Search"
|
||||||
|
/>
|
||||||
|
<div className="flex h-5 w-5 items-center justify-center">
|
||||||
|
<button
|
||||||
|
disabled={!search}
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
className={clx(
|
||||||
|
"transition-fg text-ui-fg-muted focus-visible:bg-ui-bg-base-pressed rounded-md outline-none",
|
||||||
|
{
|
||||||
|
invisible: !search,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<XMarkMini />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Command.Empty className="txt-compact-small flex items-center justify-center p-1">
|
||||||
|
<span className="w-full px-2 py-1 text-center">
|
||||||
|
{t("general.noResultsTitle")}
|
||||||
|
</span>
|
||||||
|
</Command.Empty>
|
||||||
|
<Command.List className="h-full max-h-[163px] min-h-[0] overflow-auto p-1 outline-none">
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedParams
|
||||||
|
.get()
|
||||||
|
.includes(String(option.value))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command.Item
|
||||||
|
key={String(option.value)}
|
||||||
|
className="bg-ui-bg-base hover:bg-ui-bg-base-hover aria-selected:bg-ui-bg-base-pressed focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => {
|
||||||
|
handleSelect(option.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"transition-fg flex h-5 w-5 items-center justify-center",
|
||||||
|
{
|
||||||
|
"[&_svg]:invisible": !isSelected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{multiple ? <CheckMini /> : <EllipseMiniSolid />}
|
||||||
|
</div>
|
||||||
|
{option.label}
|
||||||
|
</Command.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Command.List>
|
||||||
|
</Command>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Input, Label, clx } from "@medusajs/ui"
|
||||||
|
import * as Popover from "@radix-ui/react-popover"
|
||||||
|
import { debounce } from "lodash"
|
||||||
|
import { ChangeEvent, useCallback, useEffect, useState } from "react"
|
||||||
|
import { useSelectedParams } from "../hooks"
|
||||||
|
import { useDataTableFilterContext } from "./context"
|
||||||
|
import { IFilter } from "./types"
|
||||||
|
import FilterChip from "./filter-chip"
|
||||||
|
|
||||||
|
type StringFilterProps = IFilter
|
||||||
|
|
||||||
|
export const StringFilter = ({
|
||||||
|
filter,
|
||||||
|
prefix,
|
||||||
|
readonly,
|
||||||
|
openOnMount,
|
||||||
|
}: StringFilterProps) => {
|
||||||
|
const [open, setOpen] = useState(openOnMount)
|
||||||
|
|
||||||
|
const { key, label } = filter
|
||||||
|
|
||||||
|
const { removeFilter } = useDataTableFilterContext()
|
||||||
|
const selectedParams = useSelectedParams({ param: key, prefix })
|
||||||
|
|
||||||
|
const query = selectedParams.get()
|
||||||
|
|
||||||
|
const [previousValue, setPreviousValue] = useState<string | undefined>(query?.[0])
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const debouncedOnChange = useCallback(
|
||||||
|
debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
selectedParams.delete()
|
||||||
|
} else {
|
||||||
|
selectedParams.add(value)
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[selectedParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedOnChange.cancel()
|
||||||
|
}
|
||||||
|
}, [debouncedOnChange])
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setOpen(open)
|
||||||
|
setPreviousValue(query?.[0])
|
||||||
|
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open && !query.length) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
removeFilter(key)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
selectedParams.delete()
|
||||||
|
removeFilter(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<FilterChip
|
||||||
|
hasOperator
|
||||||
|
hadPreviousValue={!!previousValue}
|
||||||
|
label={label}
|
||||||
|
value={query?.[0]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
{!readonly && (
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
hideWhenDetached
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={8}
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-hidden rounded-lg outline-none"
|
||||||
|
)}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
if (e.target instanceof HTMLElement) {
|
||||||
|
if (
|
||||||
|
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||||
|
"filters_menu_content"
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="px-1 pb-3 pt-1">
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<Label size="xsmall" weight="plus" htmlFor={key}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-0.5">
|
||||||
|
<Input
|
||||||
|
name={key}
|
||||||
|
size="small"
|
||||||
|
defaultValue={query?.[0] || undefined}
|
||||||
|
onChange={debouncedOnChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface IFilter {
|
||||||
|
filter: {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
readonly?: boolean
|
||||||
|
openOnMount?: boolean
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { DescendingSorting } from "@medusajs/icons"
|
||||||
|
import { DropdownMenu, IconButton } from "@medusajs/ui"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
export type DataTableOrderByKey<TData> = {
|
||||||
|
key: keyof TData
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataTableOrderByProps<TData> = {
|
||||||
|
keys: DataTableOrderByKey<TData>[]
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SortDirection {
|
||||||
|
ASC = "asc",
|
||||||
|
DESC = "desc",
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortState = {
|
||||||
|
key?: string
|
||||||
|
dir: SortDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
const initState = (params: URLSearchParams, prefix?: string): SortState => {
|
||||||
|
const param = prefix ? `${prefix}_order` : "order"
|
||||||
|
const sortParam = params.get(param)
|
||||||
|
|
||||||
|
if (!sortParam) {
|
||||||
|
return {
|
||||||
|
dir: SortDirection.ASC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = sortParam.startsWith("-") ? SortDirection.DESC : SortDirection.ASC
|
||||||
|
const key = sortParam.replace("-", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableOrderBy = <TData,>({
|
||||||
|
keys,
|
||||||
|
prefix,
|
||||||
|
}: DataTableOrderByProps<TData>) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
key?: string
|
||||||
|
dir: SortDirection
|
||||||
|
}>(initState(searchParams, prefix))
|
||||||
|
const param = prefix ? `${prefix}_order` : "order"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleDirChange = (dir: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
dir: dir as SortDirection,
|
||||||
|
}))
|
||||||
|
updateOrderParam({
|
||||||
|
key: state.key,
|
||||||
|
dir: dir as SortDirection,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyChange = (value: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
key: value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
updateOrderParam({
|
||||||
|
key: value,
|
||||||
|
dir: state.dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOrderParam = (state: SortState) => {
|
||||||
|
if (!state.key) {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
prev.delete(param)
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderParam =
|
||||||
|
state.dir === SortDirection.ASC ? state.key : `-${state.key}`
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
prev.set(param, orderParam)
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<IconButton size="small">
|
||||||
|
<DescendingSorting />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content className="z-[1]" align="end">
|
||||||
|
<DropdownMenu.RadioGroup
|
||||||
|
value={state.key}
|
||||||
|
onValueChange={handleKeyChange}
|
||||||
|
>
|
||||||
|
{keys.map((key) => {
|
||||||
|
const stringKey = String(key.key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.RadioItem
|
||||||
|
key={stringKey}
|
||||||
|
value={stringKey}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{key.label}
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.RadioGroup
|
||||||
|
value={state.dir}
|
||||||
|
onValueChange={handleDirChange}
|
||||||
|
>
|
||||||
|
<DropdownMenu.RadioItem
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
value="asc"
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{t("general.ascending")}
|
||||||
|
<DropdownMenu.Label>1 - 30</DropdownMenu.Label>
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
<DropdownMenu.RadioItem
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
value="desc"
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{t("general.descending")}
|
||||||
|
<DropdownMenu.Label>30 - 1</DropdownMenu.Label>
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-order-by"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Filter } from ".."
|
||||||
|
import { DataTableFilter } from "../data-table-filter/data-table-filter"
|
||||||
|
import { DataTableOrderBy, DataTableOrderByKey } from "../data-table-order-by"
|
||||||
|
import { DataTableSearch } from "../data-table-search"
|
||||||
|
|
||||||
|
export interface DataTableQueryProps<TData> {
|
||||||
|
search?: boolean | "autofocus"
|
||||||
|
orderBy?: DataTableOrderByKey<TData>[]
|
||||||
|
filters?: Filter[]
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableQuery = <TData,>({
|
||||||
|
search,
|
||||||
|
orderBy,
|
||||||
|
filters,
|
||||||
|
prefix,
|
||||||
|
}: DataTableQueryProps<TData>) => {
|
||||||
|
return (
|
||||||
|
(search || orderBy || filters || prefix) && (
|
||||||
|
<div className="flex items-start justify-between gap-x-4 px-6 py-4">
|
||||||
|
<div className="w-full max-w-[60%]">
|
||||||
|
{filters && filters.length > 0 && (
|
||||||
|
<DataTableFilter filters={filters} prefix={prefix} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-x-2">
|
||||||
|
{search && (
|
||||||
|
<DataTableSearch
|
||||||
|
prefix={prefix}
|
||||||
|
autofocus={search === "autofocus"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{orderBy && <DataTableOrderBy keys={orderBy} prefix={prefix} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-query"
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { CommandBar, Table, clx } from "@medusajs/ui"
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
Table as ReactTable,
|
||||||
|
Row,
|
||||||
|
flexRender,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import {
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
Fragment,
|
||||||
|
UIEvent,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { NoResults } from "../empty-table-content"
|
||||||
|
|
||||||
|
type BulkCommand = {
|
||||||
|
label: string
|
||||||
|
shortcut: string
|
||||||
|
action: (selection: Record<string, boolean>) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableRootProps<TData> {
|
||||||
|
/**
|
||||||
|
* The table instance to render
|
||||||
|
*/
|
||||||
|
table: ReactTable<TData>
|
||||||
|
/**
|
||||||
|
* The columns to render
|
||||||
|
*/
|
||||||
|
columns: ColumnDef<TData, any>[]
|
||||||
|
/**
|
||||||
|
* Function to generate a link to navigate to when clicking on a row
|
||||||
|
*/
|
||||||
|
navigateTo?: (row: Row<TData>) => string
|
||||||
|
/**
|
||||||
|
* Bulk actions to render
|
||||||
|
*/
|
||||||
|
commands?: BulkCommand[]
|
||||||
|
/**
|
||||||
|
* The total number of items in the table
|
||||||
|
*/
|
||||||
|
count?: number
|
||||||
|
/**
|
||||||
|
* Whether to display pagination controls
|
||||||
|
*/
|
||||||
|
pagination?: boolean
|
||||||
|
/**
|
||||||
|
* Whether the table is empty due to no results from the active query
|
||||||
|
*/
|
||||||
|
noResults?: boolean
|
||||||
|
/**
|
||||||
|
* Whether to display the tables header
|
||||||
|
*/
|
||||||
|
noHeader?: boolean
|
||||||
|
/**
|
||||||
|
* The layout of the table
|
||||||
|
*/
|
||||||
|
layout?: "fill" | "fit"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO
|
||||||
|
*
|
||||||
|
* Add a sticky header to the table that shows the column name when scrolling through the table vertically.
|
||||||
|
*
|
||||||
|
* This is a bit tricky as we can't support horizontal scrolling and sticky headers at the same time, natively
|
||||||
|
* with CSS. We need to implement a custom solution for this. One solution is to render a duplicate table header
|
||||||
|
* using a DIV that, but it will require rerendeing the duplicate header every time the window is resized, to keep
|
||||||
|
* the columns aligned.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table component for rendering a table with pagination, filtering and ordering.
|
||||||
|
*/
|
||||||
|
export const DataTableRoot = <TData,>({
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
navigateTo,
|
||||||
|
commands,
|
||||||
|
count = 0,
|
||||||
|
noResults = false,
|
||||||
|
noHeader = false,
|
||||||
|
layout = "fit",
|
||||||
|
}: DataTableRootProps<TData>) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [showStickyBorder, setShowStickyBorder] = useState(false)
|
||||||
|
|
||||||
|
const scrollableRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const hasSelect = columns.find((c) => c.id === "select")
|
||||||
|
const hasActions = columns.find((c) => c.id === "actions")
|
||||||
|
const hasCommandBar = commands && commands.length > 0
|
||||||
|
|
||||||
|
const rowSelection = table.getState().rowSelection
|
||||||
|
const { pageIndex, pageSize } = table.getState().pagination
|
||||||
|
|
||||||
|
const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0)
|
||||||
|
const colWidth = 100 / colCount
|
||||||
|
|
||||||
|
const handleHorizontalScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||||
|
const scrollLeft = e.currentTarget.scrollLeft
|
||||||
|
|
||||||
|
if (scrollLeft > 0) {
|
||||||
|
setShowStickyBorder(true)
|
||||||
|
} else {
|
||||||
|
setShowStickyBorder(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAction = async (action: BulkCommand["action"]) => {
|
||||||
|
await action(rowSelection).then(() => {
|
||||||
|
table.resetRowSelection()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollableRef.current?.scroll({ top: 0, left: 0 })
|
||||||
|
}, [pageIndex])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx("flex w-full flex-col overflow-hidden", {
|
||||||
|
"flex flex-1 flex-col": layout === "fill",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={scrollableRef}
|
||||||
|
onScroll={handleHorizontalScroll}
|
||||||
|
className={clx("w-full", {
|
||||||
|
"min-h-0 flex-grow overflow-auto": layout === "fill",
|
||||||
|
"overflow-x-auto": layout === "fit",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{!noResults ? (
|
||||||
|
<Table className="relative w-full">
|
||||||
|
{!noHeader && (
|
||||||
|
<Table.Header className="border-t-0">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => {
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
key={headerGroup.id}
|
||||||
|
className={clx({
|
||||||
|
"relative border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
|
||||||
|
hasActions,
|
||||||
|
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
|
||||||
|
hasSelect,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{headerGroup.headers.map((header, index) => {
|
||||||
|
const isActionHeader = header.id === "actions"
|
||||||
|
const isSelectHeader = header.id === "select"
|
||||||
|
const isSpecialHeader = isActionHeader || isSelectHeader
|
||||||
|
|
||||||
|
const firstHeader = headerGroup.headers.findIndex(
|
||||||
|
(h) => h.id !== "select"
|
||||||
|
)
|
||||||
|
const isFirstHeader =
|
||||||
|
firstHeader !== -1
|
||||||
|
? header.id === headerGroup.headers[firstHeader].id
|
||||||
|
: index === 0
|
||||||
|
|
||||||
|
const isStickyHeader = isSelectHeader || isFirstHeader
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.HeaderCell
|
||||||
|
data-table-header-id={header.id}
|
||||||
|
key={header.id}
|
||||||
|
style={{
|
||||||
|
width: !isSpecialHeader
|
||||||
|
? `${colWidth}%`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
className={clx({
|
||||||
|
"sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||||
|
isStickyHeader,
|
||||||
|
"left-[68px]":
|
||||||
|
isStickyHeader && hasSelect && !isSelectHeader,
|
||||||
|
"after:bg-ui-border-base":
|
||||||
|
showStickyBorder &&
|
||||||
|
isStickyHeader &&
|
||||||
|
!isSpecialHeader,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</Table.HeaderCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Header>
|
||||||
|
)}
|
||||||
|
<Table.Body className="border-b-0">
|
||||||
|
{table.getRowModel().rows.map((row) => {
|
||||||
|
const to = navigateTo ? navigateTo(row) : undefined
|
||||||
|
const isRowDisabled = hasSelect && !row.getCanSelect()
|
||||||
|
|
||||||
|
const isOdd = row.depth % 2 !== 0
|
||||||
|
|
||||||
|
const cells = row.getVisibleCells()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
key={row.id}
|
||||||
|
data-selected={row.getIsSelected()}
|
||||||
|
className={clx(
|
||||||
|
"transition-fg group/row group relative [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||||
|
"has-[[data-row-link]:focus-visible]:bg-ui-bg-base-hover",
|
||||||
|
{
|
||||||
|
"bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover": isOdd,
|
||||||
|
"cursor-pointer": !!to,
|
||||||
|
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||||
|
row.getIsSelected(),
|
||||||
|
"!bg-ui-bg-disabled !hover:bg-ui-bg-disabled":
|
||||||
|
isRowDisabled,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cells.map((cell, index) => {
|
||||||
|
const visibleCells = row.getVisibleCells()
|
||||||
|
const isSelectCell = cell.column.id === "select"
|
||||||
|
|
||||||
|
const isActionCell = cell.column.id === "actions"
|
||||||
|
|
||||||
|
const firstCell = visibleCells.findIndex(
|
||||||
|
(h) => h.column.id !== "select"
|
||||||
|
)
|
||||||
|
const isFirstCell =
|
||||||
|
firstCell !== -1
|
||||||
|
? cell.column.id === visibleCells[firstCell].column.id
|
||||||
|
: index === 0
|
||||||
|
|
||||||
|
const isStickyCell = isSelectCell || isFirstCell
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the table has nested rows, we need to offset the cell padding
|
||||||
|
* to indicate the depth of the row.
|
||||||
|
*/
|
||||||
|
const depthOffset =
|
||||||
|
row.depth > 0 && isFirstCell
|
||||||
|
? row.depth * 14 + 24
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const hasLeftOffset =
|
||||||
|
isStickyCell && hasSelect && !isSelectCell
|
||||||
|
|
||||||
|
const Inner = flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)
|
||||||
|
|
||||||
|
const isTabableLink = isFirstCell && !!to
|
||||||
|
const shouldRenderAsLink = !!to && !isSelectCell && !isActionCell
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Cell
|
||||||
|
key={cell.id}
|
||||||
|
className={clx({
|
||||||
|
"!pl-0 !pr-0": shouldRenderAsLink,
|
||||||
|
"bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-hover/row:bg-ui-bg-base-hover transition-fg group-has-[[data-row-link]:focus-visible]:bg-ui-bg-base-hover sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||||
|
isStickyCell,
|
||||||
|
"bg-ui-bg-subtle group-hover/row:bg-ui-bg-subtle-hover":
|
||||||
|
isOdd && isStickyCell,
|
||||||
|
"left-[68px]": hasLeftOffset,
|
||||||
|
"after:bg-ui-border-base":
|
||||||
|
showStickyBorder && isStickyCell && !isSelectCell,
|
||||||
|
"!bg-ui-bg-disabled !hover:bg-ui-bg-disabled":
|
||||||
|
isRowDisabled,
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
paddingLeft: depthOffset
|
||||||
|
? `${depthOffset}px`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shouldRenderAsLink ? (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="size-full outline-none"
|
||||||
|
data-row-link
|
||||||
|
tabIndex={isTabableLink ? 0 : -1}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"flex size-full items-center pr-6",
|
||||||
|
{
|
||||||
|
"pl-6": isTabableLink && !hasLeftOffset,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Inner}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
Inner
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className={clx({ "border-b": layout === "fit" })}>
|
||||||
|
<NoResults />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pagination && (
|
||||||
|
<div className={clx({ "border-t": layout === "fill" })}>
|
||||||
|
<Pagination
|
||||||
|
canNextPage={table.getCanNextPage()}
|
||||||
|
canPreviousPage={table.getCanPreviousPage()}
|
||||||
|
nextPage={table.nextPage}
|
||||||
|
previousPage={table.previousPage}
|
||||||
|
count={count}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageCount={table.getPageCount()}
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasCommandBar && (
|
||||||
|
<CommandBar open={!!Object.keys(rowSelection).length}>
|
||||||
|
<CommandBar.Bar>
|
||||||
|
<CommandBar.Value>
|
||||||
|
{t("general.countSelected", {
|
||||||
|
count: Object.keys(rowSelection).length,
|
||||||
|
})}
|
||||||
|
</CommandBar.Value>
|
||||||
|
<CommandBar.Seperator />
|
||||||
|
{commands?.map((command, index) => {
|
||||||
|
return (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<CommandBar.Command
|
||||||
|
label={command.label}
|
||||||
|
shortcut={command.shortcut}
|
||||||
|
action={() => handleAction(command.action)}
|
||||||
|
/>
|
||||||
|
{index < commands.length - 1 && <CommandBar.Seperator />}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandBar.Bar>
|
||||||
|
</CommandBar>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationProps = Omit<
|
||||||
|
ComponentPropsWithoutRef<typeof Table.Pagination>,
|
||||||
|
"translations"
|
||||||
|
>
|
||||||
|
|
||||||
|
const Pagination = (props: PaginationProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
of: t("general.of"),
|
||||||
|
results: t("general.results"),
|
||||||
|
pages: t("general.pages"),
|
||||||
|
prev: t("general.prev"),
|
||||||
|
next: t("general.next"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Pagination
|
||||||
|
className="flex-shrink-0"
|
||||||
|
{...props}
|
||||||
|
translations={translations}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-root"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Input } from "@medusajs/ui"
|
||||||
|
import { ChangeEvent, useCallback, useEffect } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { debounce } from "lodash"
|
||||||
|
import { useSelectedParams } from "../hooks"
|
||||||
|
|
||||||
|
type DataTableSearchProps = {
|
||||||
|
placeholder?: string
|
||||||
|
prefix?: string
|
||||||
|
autofocus?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableSearch = ({
|
||||||
|
placeholder,
|
||||||
|
prefix,
|
||||||
|
autofocus,
|
||||||
|
}: DataTableSearchProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const placeholderText = placeholder || t("general.search")
|
||||||
|
const selectedParams = useSelectedParams({
|
||||||
|
param: "q",
|
||||||
|
prefix,
|
||||||
|
multiple: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const query = selectedParams.get()
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const debouncedOnChange = useCallback(
|
||||||
|
debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
selectedParams.delete()
|
||||||
|
} else {
|
||||||
|
selectedParams.add(value)
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[selectedParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedOnChange.cancel()
|
||||||
|
}
|
||||||
|
}, [debouncedOnChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
size="small"
|
||||||
|
autoFocus={autofocus}
|
||||||
|
defaultValue={query?.[0] || undefined}
|
||||||
|
onChange={debouncedOnChange}
|
||||||
|
placeholder={placeholderText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-search"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import { memo } from "react"
|
||||||
|
|
||||||
|
import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
|
||||||
|
import { DataTableRoot, DataTableRootProps } from "./data-table-root"
|
||||||
|
import { NoRecords, NoResultsProps } from "./empty-table-content"
|
||||||
|
import { TableSkeleton } from "./skeleton"
|
||||||
|
|
||||||
|
interface DataTableProps<TData>
|
||||||
|
extends Omit<DataTableRootProps<TData>, "noResults">,
|
||||||
|
DataTableQueryProps<TData> {
|
||||||
|
isLoading?: boolean
|
||||||
|
pageSize: number
|
||||||
|
queryObject?: Record<string, any>
|
||||||
|
noRecords?: Pick<NoResultsProps, "title" | "message">
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe we should use the memoized version of DataTableRoot
|
||||||
|
// const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot
|
||||||
|
const MemoizedDataTableQuery = memo(DataTableQuery) as typeof DataTableQuery
|
||||||
|
|
||||||
|
export const DataTable = <TData,>({
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
navigateTo,
|
||||||
|
commands,
|
||||||
|
count = 0,
|
||||||
|
search = false,
|
||||||
|
orderBy,
|
||||||
|
filters,
|
||||||
|
prefix,
|
||||||
|
queryObject = {},
|
||||||
|
pageSize,
|
||||||
|
isLoading = false,
|
||||||
|
noHeader = false,
|
||||||
|
layout = "fit",
|
||||||
|
noRecords: noRecordsProps = {},
|
||||||
|
}: DataTableProps<TData>) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<TableSkeleton
|
||||||
|
layout={layout}
|
||||||
|
rowCount={pageSize}
|
||||||
|
search={!!search}
|
||||||
|
filters={!!filters?.length}
|
||||||
|
orderBy={!!orderBy?.length}
|
||||||
|
pagination={!!pagination}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const noQuery =
|
||||||
|
Object.values(queryObject).filter((v) => Boolean(v)).length === 0
|
||||||
|
const noResults = !isLoading && count === 0 && !noQuery
|
||||||
|
const noRecords = !isLoading && count === 0 && noQuery
|
||||||
|
|
||||||
|
if (noRecords) {
|
||||||
|
return (
|
||||||
|
<NoRecords
|
||||||
|
className={clx({
|
||||||
|
"flex h-full flex-col overflow-hidden": layout === "fill",
|
||||||
|
})}
|
||||||
|
{...noRecordsProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx("divide-y", {
|
||||||
|
"flex h-full flex-col overflow-hidden": layout === "fill",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MemoizedDataTableQuery
|
||||||
|
search={search}
|
||||||
|
orderBy={orderBy}
|
||||||
|
filters={filters}
|
||||||
|
prefix={prefix}
|
||||||
|
/>
|
||||||
|
<DataTableRoot
|
||||||
|
table={table}
|
||||||
|
count={count}
|
||||||
|
columns={columns}
|
||||||
|
pagination
|
||||||
|
navigateTo={navigateTo}
|
||||||
|
commands={commands}
|
||||||
|
noResults={noResults}
|
||||||
|
noHeader={noHeader}
|
||||||
|
layout={layout}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { ExclamationCircle, MagnifyingGlass, PlusMini } from "@medusajs/icons"
|
||||||
|
import { Button, Text, clx } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
|
||||||
|
export type NoResultsProps = {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoResults = ({ title, message, className }: NoResultsProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"flex h-[400px] w-full items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-y-2">
|
||||||
|
<MagnifyingGlass />
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{title ?? t("general.noResultsTitle")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
{message ?? t("general.noResultsMessage")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionProps = {
|
||||||
|
action?: {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoRecordsProps = {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
className?: string
|
||||||
|
buttonVariant?: string
|
||||||
|
} & ActionProps
|
||||||
|
|
||||||
|
const DefaultButton = ({ action }: ActionProps) =>
|
||||||
|
action && (
|
||||||
|
<Link to={action.to}>
|
||||||
|
<Button variant="secondary" size="small">
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
|
||||||
|
const TransparentIconLeftButton = ({ action }: ActionProps) =>
|
||||||
|
action && (
|
||||||
|
<Link to={action.to}>
|
||||||
|
<Button variant="transparent" className="text-ui-fg-interactive">
|
||||||
|
<PlusMini /> {action.label}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const NoRecords = ({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
buttonVariant = "default",
|
||||||
|
}: NoRecordsProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"flex h-[400px] w-full flex-col items-center justify-center gap-y-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-y-3">
|
||||||
|
<ExclamationCircle className="text-ui-fg-subtle" />
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-y-1">
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{title ?? t("general.noRecordsTitle")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size="small" className="text-ui-fg-muted">
|
||||||
|
{message ?? t("general.noRecordsMessage")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{buttonVariant === "default" && <DefaultButton action={action} />}
|
||||||
|
{buttonVariant === "transparentIconLeft" && (
|
||||||
|
<TransparentIconLeftButton action={action} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
export const useSelectedParams = ({
|
||||||
|
param,
|
||||||
|
prefix,
|
||||||
|
multiple = false,
|
||||||
|
}: {
|
||||||
|
param: string
|
||||||
|
prefix?: string
|
||||||
|
multiple?: boolean
|
||||||
|
}) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const identifier = prefix ? `${prefix}_${param}` : param
|
||||||
|
const offsetKey = prefix ? `${prefix}_offset` : "offset"
|
||||||
|
|
||||||
|
const add = (value: string) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const newValue = new URLSearchParams(prev)
|
||||||
|
|
||||||
|
const updateMultipleValues = () => {
|
||||||
|
const existingValues = newValue.get(identifier)?.split(",") || []
|
||||||
|
|
||||||
|
if (!existingValues.includes(value)) {
|
||||||
|
existingValues.push(value)
|
||||||
|
newValue.set(identifier, existingValues.join(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSingleValue = () => {
|
||||||
|
newValue.set(identifier, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
multiple ? updateMultipleValues() : updateSingleValue()
|
||||||
|
newValue.delete(offsetKey)
|
||||||
|
|
||||||
|
return newValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteParam = (value?: string) => {
|
||||||
|
const deleteMultipleValues = (prev: URLSearchParams) => {
|
||||||
|
const existingValues = prev.get(identifier)?.split(",") || []
|
||||||
|
const index = existingValues.indexOf(value || "")
|
||||||
|
if (index > -1) {
|
||||||
|
existingValues.splice(index, 1)
|
||||||
|
prev.set(identifier, existingValues.join(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSingleValue = (prev: URLSearchParams) => {
|
||||||
|
prev.delete(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
if (value) {
|
||||||
|
multiple ? deleteMultipleValues(prev) : deleteSingleValue(prev)
|
||||||
|
if (!prev.get(identifier)) {
|
||||||
|
prev.delete(identifier)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prev.delete(identifier)
|
||||||
|
}
|
||||||
|
prev.delete(offsetKey)
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = () => {
|
||||||
|
return searchParams.get(identifier)?.split(",").filter(Boolean) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
return { add, delete: deleteParam, get }
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./data-table"
|
||||||
|
export type { Filter } from "./data-table-filter"
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { Container, Heading, Text, clx } from "@medusajs/ui"
|
||||||
|
import { CSSProperties, ComponentPropsWithoutRef } from "react"
|
||||||
|
|
||||||
|
type SkeletonProps = {
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Skeleton = ({ className, style }: SkeletonProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-component h-3 w-3 animate-pulse rounded-[4px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextSkeletonProps = {
|
||||||
|
size?: ComponentPropsWithoutRef<typeof Text>["size"]
|
||||||
|
leading?: ComponentPropsWithoutRef<typeof Text>["leading"]
|
||||||
|
characters?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeadingSkeletonProps = {
|
||||||
|
level?: ComponentPropsWithoutRef<typeof Heading>["level"]
|
||||||
|
characters?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeadingSkeleton = ({
|
||||||
|
level = "h1",
|
||||||
|
characters = 10,
|
||||||
|
}: HeadingSkeletonProps) => {
|
||||||
|
let charWidth = 9
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "h1":
|
||||||
|
charWidth = 11
|
||||||
|
break
|
||||||
|
case "h2":
|
||||||
|
charWidth = 10
|
||||||
|
break
|
||||||
|
case "h3":
|
||||||
|
charWidth = 9
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
className={clx({
|
||||||
|
"h-7": level === "h1",
|
||||||
|
"h-6": level === "h2",
|
||||||
|
"h-5": level === "h3",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
width: `${charWidth * characters}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextSkeleton = ({
|
||||||
|
size = "small",
|
||||||
|
leading = "compact",
|
||||||
|
characters = 10,
|
||||||
|
}: TextSkeletonProps) => {
|
||||||
|
let charWidth = 9
|
||||||
|
|
||||||
|
switch (size) {
|
||||||
|
case "xlarge":
|
||||||
|
charWidth = 13
|
||||||
|
break
|
||||||
|
case "large":
|
||||||
|
charWidth = 11
|
||||||
|
break
|
||||||
|
case "base":
|
||||||
|
charWidth = 10
|
||||||
|
break
|
||||||
|
case "small":
|
||||||
|
charWidth = 9
|
||||||
|
break
|
||||||
|
case "xsmall":
|
||||||
|
charWidth = 8
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
className={clx({
|
||||||
|
"h-5": size === "xsmall",
|
||||||
|
"h-6": size === "small",
|
||||||
|
"h-7": size === "base",
|
||||||
|
"h-8": size === "xlarge",
|
||||||
|
"!h-5": leading === "compact",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
width: `${charWidth * characters}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconButtonSkeleton = () => {
|
||||||
|
return <Skeleton className="h-7 w-7 rounded-md" />
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneralSectionSkeletonProps = {
|
||||||
|
rowCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GeneralSectionSkeleton = ({
|
||||||
|
rowCount,
|
||||||
|
}: GeneralSectionSkeletonProps) => {
|
||||||
|
const rows = Array.from({ length: rowCount ?? 0 }, (_, i) => i)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0" aria-hidden>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<HeadingSkeleton characters={16} />
|
||||||
|
<IconButtonSkeleton />
|
||||||
|
</div>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row}
|
||||||
|
className="grid grid-cols-2 items-center px-6 py-4"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<TextSkeleton size="small" leading="compact" characters={12} />
|
||||||
|
<TextSkeleton size="small" leading="compact" characters={24} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableFooterSkeleton = ({ layout }: { layout: "fill" | "fit" }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx("flex items-center justify-between p-4", {
|
||||||
|
"border-t": layout === "fill",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Skeleton className="h-7 w-[138px]" />
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Skeleton className="h-7 w-24" />
|
||||||
|
<Skeleton className="h-7 w-11" />
|
||||||
|
<Skeleton className="h-7 w-11" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableSkeletonProps = {
|
||||||
|
rowCount?: number
|
||||||
|
search?: boolean
|
||||||
|
filters?: boolean
|
||||||
|
orderBy?: boolean
|
||||||
|
pagination?: boolean
|
||||||
|
layout?: "fit" | "fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableSkeleton = ({
|
||||||
|
rowCount = 10,
|
||||||
|
search = true,
|
||||||
|
filters = true,
|
||||||
|
orderBy = true,
|
||||||
|
pagination = true,
|
||||||
|
layout = "fit",
|
||||||
|
}: TableSkeletonProps) => {
|
||||||
|
// Row count + header row
|
||||||
|
const totalRowCount = rowCount + 1
|
||||||
|
|
||||||
|
const rows = Array.from({ length: totalRowCount }, (_, i) => i)
|
||||||
|
const hasToolbar = search || filters || orderBy
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={clx({
|
||||||
|
"flex h-full flex-col overflow-hidden": layout === "fill",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{hasToolbar && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
{filters && <Skeleton className="h-7 w-full max-w-[135px]" />}
|
||||||
|
{(search || orderBy) && (
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
{search && <Skeleton className="h-7 w-[160px]" />}
|
||||||
|
{orderBy && <Skeleton className="h-7 w-7" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col divide-y border-y">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<Skeleton key={row} className="h-10 w-full rounded-none" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pagination && <TableFooterSkeleton layout={layout} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableSectionSkeleton = (props: TableSkeletonProps) => {
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0" aria-hidden>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4" aria-hidden>
|
||||||
|
<HeadingSkeleton level="h2" characters={16} />
|
||||||
|
<IconButtonSkeleton />
|
||||||
|
</div>
|
||||||
|
<TableSkeleton {...props} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JsonViewSectionSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0" aria-hidden>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4" aria-hidden>
|
||||||
|
<div aria-hidden className="flex items-center gap-x-4">
|
||||||
|
<HeadingSkeleton level="h2" characters={16} />
|
||||||
|
<Skeleton className="h-5 w-12 rounded-md" />
|
||||||
|
</div>
|
||||||
|
<IconButtonSkeleton />
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SingleColumnPageSkeletonProps = {
|
||||||
|
sections?: number
|
||||||
|
showJSON?: boolean
|
||||||
|
showMetadata?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SingleColumnPageSkeleton = ({
|
||||||
|
sections = 2,
|
||||||
|
showJSON = false,
|
||||||
|
showMetadata = false,
|
||||||
|
}: SingleColumnPageSkeletonProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-3">
|
||||||
|
{Array.from({ length: sections }, (_, i) => i).map((section) => {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
key={section}
|
||||||
|
className={clx("h-full max-h-[460px] w-full rounded-lg", {
|
||||||
|
// First section is smaller on most pages, this gives us less
|
||||||
|
// layout shifting in general,
|
||||||
|
"max-h-[219px]": section === 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{showMetadata && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||||
|
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TwoColumnPageSkeletonProps = {
|
||||||
|
mainSections?: number
|
||||||
|
sidebarSections?: number
|
||||||
|
showJSON?: boolean
|
||||||
|
showMetadata?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TwoColumnPageSkeleton = ({
|
||||||
|
mainSections = 2,
|
||||||
|
sidebarSections = 1,
|
||||||
|
showJSON = false,
|
||||||
|
showMetadata = true,
|
||||||
|
}: TwoColumnPageSkeletonProps) => {
|
||||||
|
const showExtraData = showJSON || showMetadata
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-3">
|
||||||
|
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
|
||||||
|
<div className="flex w-full flex-col gap-y-3">
|
||||||
|
{Array.from({ length: mainSections }, (_, i) => i).map((section) => {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
key={section}
|
||||||
|
className={clx("h-full max-h-[460px] w-full rounded-lg", {
|
||||||
|
"max-h-[219px]": section === 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{showExtraData && (
|
||||||
|
<div className="hidden flex-col gap-y-3 xl:flex">
|
||||||
|
{showMetadata && (
|
||||||
|
<Skeleton className="h-[60px] w-full rounded-lg" />
|
||||||
|
)}
|
||||||
|
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[440px]">
|
||||||
|
{Array.from({ length: sidebarSections }, (_, i) => i).map(
|
||||||
|
(section) => {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
key={section}
|
||||||
|
className={clx("h-full max-h-[320px] w-full rounded-lg", {
|
||||||
|
"max-h-[140px]": section === 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{showExtraData && (
|
||||||
|
<div className="flex flex-col gap-y-3 xl:hidden">
|
||||||
|
{showMetadata && (
|
||||||
|
<Skeleton className="h-[60px] w-full rounded-lg" />
|
||||||
|
)}
|
||||||
|
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Badge } from "@medusajs/ui"
|
||||||
|
|
||||||
|
type CellProps = {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeCell = ({ code }: CellProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
|
||||||
|
<Badge size="2xsmall" className="truncate">
|
||||||
|
{code}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeHeader = ({ text }: HeaderProps) => {
|
||||||
|
return (
|
||||||
|
<div className=" flex h-full w-full items-center ">
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./code-cell"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Tooltip } from "@medusajs/ui"
|
||||||
|
import format from "date-fns/format"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { PlaceholderCell } from "../placeholder-cell"
|
||||||
|
|
||||||
|
type DateCellProps = {
|
||||||
|
date: Date | string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreatedAtCell = ({ date }: DateCellProps) => {
|
||||||
|
if (!date) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = new Date(date)
|
||||||
|
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
|
||||||
|
|
||||||
|
const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12
|
||||||
|
const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<Tooltip
|
||||||
|
className="z-10"
|
||||||
|
content={
|
||||||
|
<span className="text-pretty">{`${format(
|
||||||
|
value,
|
||||||
|
timestampFormat
|
||||||
|
)}`}</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">{format(value, "dd MMM yyyy")}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreatedAtHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.createdAt")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./created-at-cell"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Tooltip } from "@medusajs/ui"
|
||||||
|
import { format } from "date-fns/format"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { PlaceholderCell } from "../placeholder-cell"
|
||||||
|
|
||||||
|
type DateCellProps = {
|
||||||
|
date?: Date | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateCell = ({ date }: DateCellProps) => {
|
||||||
|
if (!date) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = new Date(date)
|
||||||
|
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
|
||||||
|
|
||||||
|
const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12
|
||||||
|
const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<Tooltip
|
||||||
|
className="z-10"
|
||||||
|
content={
|
||||||
|
<span className="text-pretty">{`${format(
|
||||||
|
value,
|
||||||
|
timestampFormat
|
||||||
|
)}`}</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">{format(value, "dd MMM yyyy")}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.date")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./date-cell"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { PlaceholderCell } from "../placeholder-cell"
|
||||||
|
|
||||||
|
type EmailCellProps = {
|
||||||
|
email?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailCell = ({ email }: EmailCellProps) => {
|
||||||
|
if (!email) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<span className="truncate">{email}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailHeader = () => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">Email</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./email-cell"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./money-amount-cell"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
|
||||||
|
import { PlaceholderCell } from "../placeholder-cell"
|
||||||
|
|
||||||
|
type MoneyAmountCellProps = {
|
||||||
|
currencyCode: string
|
||||||
|
amount?: number | null
|
||||||
|
align?: "left" | "right"
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoneyAmountCell = ({
|
||||||
|
currencyCode,
|
||||||
|
amount,
|
||||||
|
align = "left",
|
||||||
|
className,
|
||||||
|
}: MoneyAmountCellProps) => {
|
||||||
|
if (typeof amount === "undefined" || amount === null) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = getStylizedAmount(amount, currencyCode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"flex h-full w-full items-center overflow-hidden",
|
||||||
|
{
|
||||||
|
"justify-start text-left": align === "left",
|
||||||
|
"justify-end text-right": align === "right",
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{formatted}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./name-cell"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { PlaceholderCell } from "../placeholder-cell"
|
||||||
|
|
||||||
|
type NameCellProps = {
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NameCell = ({ firstName, lastName }: NameCellProps) => {
|
||||||
|
if (!firstName && !lastName) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = [firstName, lastName].filter(Boolean).join(" ")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NameHeader = () => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">Name</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./placeholder-cell"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const PlaceholderCell = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="text-ui-fg-muted">-</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./status-cell"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
type StatusCellProps = PropsWithChildren<{
|
||||||
|
color?: "green" | "red" | "blue" | "orange" | "grey" | "purple"
|
||||||
|
}>
|
||||||
|
|
||||||
|
export const StatusCell = ({ color, children }: StatusCellProps) => {
|
||||||
|
return (
|
||||||
|
<div className="txt-compact-small text-ui-fg-subtle flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
className="flex h-5 w-2 items-center justify-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"h-2 w-2 rounded-sm shadow-[0px_0px_0px_1px_rgba(0,0,0,0.12)_inset]",
|
||||||
|
{
|
||||||
|
"bg-ui-tag-neutral-icon": color === "grey",
|
||||||
|
"bg-ui-tag-green-icon": color === "green",
|
||||||
|
"bg-ui-tag-red-icon": color === "red",
|
||||||
|
"bg-ui-tag-blue-icon": color === "blue",
|
||||||
|
"bg-ui-tag-orange-icon": color === "orange",
|
||||||
|
"bg-ui-tag-purple-icon": color === "purple",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="truncate">{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./text-cell"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import { PlaceholderCell } from "../placeholder-cell"
|
||||||
|
import { ConditionalTooltip } from "../../../../conditional-tooltip"
|
||||||
|
|
||||||
|
type CellProps = {
|
||||||
|
text?: string | number
|
||||||
|
align?: "left" | "center" | "right"
|
||||||
|
maxWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
text: string
|
||||||
|
align?: "left" | "center" | "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextCell = ({ text, align = "left", maxWidth = 220 }: CellProps) => {
|
||||||
|
if (!text) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringLength = text.toString().length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConditionalTooltip content={text} showTooltip={stringLength > 20}>
|
||||||
|
<div className={clx("flex h-full w-full items-center gap-x-3 overflow-hidden", {
|
||||||
|
"justify-start text-start": align === "left",
|
||||||
|
"justify-center text-center": align === "center",
|
||||||
|
"justify-end text-end": align === "right",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
}}>
|
||||||
|
<span className="truncate">{text}</span>
|
||||||
|
</div>
|
||||||
|
</ConditionalTooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextHeader = ({ text, align = "left" }: HeaderProps) => {
|
||||||
|
return (
|
||||||
|
<div className={clx("flex h-full w-full items-center", {
|
||||||
|
"justify-start text-start": align === "left",
|
||||||
|
"justify-center text-center": align === "center",
|
||||||
|
"justify-end text-end": align === "right",
|
||||||
|
})}>
|
||||||
|
<span className="truncate">{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Photo } from "@medusajs/icons"
|
||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
|
||||||
|
type ThumbnailProps = {
|
||||||
|
src?: string | null
|
||||||
|
alt?: string
|
||||||
|
size?: "small" | "base" | "large"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Thumbnail = ({ src, alt, size = "base" }: ThumbnailProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-component border-ui-border-base flex items-center justify-center overflow-hidden rounded border",
|
||||||
|
{
|
||||||
|
"h-8 w-6": size === "base",
|
||||||
|
"h-5 w-4": size === "small",
|
||||||
|
"h-12 w-12": size === "large",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="h-full w-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Photo className="text-ui-fg-subtle" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
backend/apps/backend/src/admin/hooks/api/algolia.tsx
Normal file
22
backend/apps/backend/src/admin/hooks/api/algolia.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
|
||||||
|
export const algoliaQueryKeys = queryKeysFactory('algolia')
|
||||||
|
|
||||||
|
export const useSyncAlgolia = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
mercurQuery('/admin/algolia', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAlgolia = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['algolia'],
|
||||||
|
queryFn: () => mercurQuery('/admin/algolia', { method: 'GET' })
|
||||||
|
})
|
||||||
|
}
|
||||||
167
backend/apps/backend/src/admin/hooks/api/attributes.ts
Normal file
167
backend/apps/backend/src/admin/hooks/api/attributes.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseQueryOptions,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { FetchError } from '@medusajs/js-sdk'
|
||||||
|
import { PaginatedResponse } from '@medusajs/types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AttributeDTO,
|
||||||
|
AttributePossibleValueDTO
|
||||||
|
} from '../../../modules/attribute/types'
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
|
||||||
|
const ATTRIBUTE_QUERY_KEY = 'attribute' as const
|
||||||
|
export const attributeQueryKeys = queryKeysFactory(ATTRIBUTE_QUERY_KEY)
|
||||||
|
|
||||||
|
export const useAttributes = (
|
||||||
|
query?: any,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
PaginatedResponse<{ attributes: AttributeDTO[] }>,
|
||||||
|
FetchError,
|
||||||
|
PaginatedResponse<{ attributes: AttributeDTO[] }>,
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...rest } = useQuery({
|
||||||
|
queryKey: attributeQueryKeys.list(),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/attributes', {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
return { ...data, ...rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAttribute = (
|
||||||
|
id: string,
|
||||||
|
query?: Record<string, unknown>,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
{ attribute: AttributeDTO },
|
||||||
|
FetchError,
|
||||||
|
{ attribute: AttributeDTO },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...rest } = useQuery({
|
||||||
|
queryKey: attributeQueryKeys.detail(id, query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/attributes/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
return { ...data, ...rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateAttribute = (
|
||||||
|
id: string,
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
{ attribute: AttributeDTO },
|
||||||
|
FetchError,
|
||||||
|
Partial<Pick<AttributeDTO, 'name' | 'handle' | 'description' | 'metadata'>>
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
mercurQuery(`/admin/attributes/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
}),
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: attributeQueryKeys.detail(id)
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: attributeQueryKeys.list()
|
||||||
|
})
|
||||||
|
|
||||||
|
options?.onSuccess?.(data, variables, context)
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ATTRIBUTE_POSSIBLE_VALUE_QUERY_KEY = 'attribute-possible-value' as const
|
||||||
|
export const attributePossibleValueQueryKeys = queryKeysFactory(
|
||||||
|
ATTRIBUTE_POSSIBLE_VALUE_QUERY_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
export const useUpdateAttributePossibleValue = (
|
||||||
|
attributeId: string,
|
||||||
|
possibleValueId: string,
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
{ possible_value: AttributePossibleValueDTO },
|
||||||
|
FetchError,
|
||||||
|
Partial<Pick<AttributePossibleValueDTO, 'value' | 'rank' | 'metadata'>>
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
mercurQuery(
|
||||||
|
`/admin/attributes/${attributeId}/values/${possibleValueId}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
}
|
||||||
|
),
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: attributeQueryKeys.detail(attributeId)
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: attributeQueryKeys.list()
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: attributePossibleValueQueryKeys.detail(possibleValueId)
|
||||||
|
})
|
||||||
|
|
||||||
|
options?.onSuccess?.(data, variables, context)
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProductApplicableAttributes = (
|
||||||
|
product_id: string,
|
||||||
|
query?: any,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
PaginatedResponse<{ attributes: AttributeDTO[] }>,
|
||||||
|
FetchError,
|
||||||
|
PaginatedResponse<{ attributes: AttributeDTO[] }>,
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...rest } = useQuery({
|
||||||
|
queryKey: attributeQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/products/${product_id}/applicable-attributes`, {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
return { ...data, ...rest }
|
||||||
|
}
|
||||||
181
backend/apps/backend/src/admin/hooks/api/commission.tsx
Normal file
181
backend/apps/backend/src/admin/hooks/api/commission.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseQueryOptions,
|
||||||
|
useMutation,
|
||||||
|
useQuery
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
import { CommissionLine } from '../../routes/commission-lines/types'
|
||||||
|
import {
|
||||||
|
CommissionRule,
|
||||||
|
CreateCommissionRule,
|
||||||
|
UpdateCommissionRule,
|
||||||
|
UpsertDefaultCommissionRule
|
||||||
|
} from '../../routes/commission/types'
|
||||||
|
|
||||||
|
export const commissionRulesQueryKeys = queryKeysFactory('commission_rule')
|
||||||
|
|
||||||
|
export const useCommissionRules = (
|
||||||
|
query?: any,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
Record<string, string | number>,
|
||||||
|
Error,
|
||||||
|
{ commission_rules: CommissionRule[]; count?: number },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: commissionRulesQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/commission/rules', {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDefaultCommissionRule = (
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ commission_rule?: CommissionRule },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: commissionRulesQueryKeys.detail(''),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/commission/default', {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommissionRule = (
|
||||||
|
id: string,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ commission_rule?: CommissionRule },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: commissionRulesQueryKeys.detail(id),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/commission/rules/${id}`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreateCommisionRule = (
|
||||||
|
options: UseMutationOptions<
|
||||||
|
{ commission_rule?: CommissionRule },
|
||||||
|
Error,
|
||||||
|
CreateCommissionRule
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
mercurQuery('/admin/commission/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateCommisionRule = (
|
||||||
|
options: UseMutationOptions<
|
||||||
|
{ commission_rule?: CommissionRule },
|
||||||
|
Error,
|
||||||
|
{ id: string } & UpdateCommissionRule
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
mercurQuery(`/admin/commission/rules/${payload.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { is_active: payload.is_active }
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpsertDefaultCommisionRule = (
|
||||||
|
options: UseMutationOptions<
|
||||||
|
{ commission_rule?: CommissionRule },
|
||||||
|
Error,
|
||||||
|
UpsertDefaultCommissionRule
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
mercurQuery('/admin/commission/default', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteCommisionRule = (
|
||||||
|
options: UseMutationOptions<
|
||||||
|
{
|
||||||
|
id?: string
|
||||||
|
object?: string
|
||||||
|
deleted?: boolean
|
||||||
|
},
|
||||||
|
Error,
|
||||||
|
{ id: string }
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
mercurQuery(`/admin/commission/rules/${payload.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useListCommissionLines = (
|
||||||
|
query?: Record<string, string | number>
|
||||||
|
) => {
|
||||||
|
return useQuery<
|
||||||
|
{
|
||||||
|
commission_lines: CommissionLine[]
|
||||||
|
count: number
|
||||||
|
},
|
||||||
|
Error
|
||||||
|
>({
|
||||||
|
queryKey: ['commission-lines', query],
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/commission/commission-lines`, {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
75
backend/apps/backend/src/admin/hooks/api/configuration.tsx
Normal file
75
backend/apps/backend/src/admin/hooks/api/configuration.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseQueryOptions,
|
||||||
|
useMutation,
|
||||||
|
useQuery
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
import {
|
||||||
|
AdminCreateRule,
|
||||||
|
ConfigurationRule
|
||||||
|
} from '../../routes/configuration/types'
|
||||||
|
|
||||||
|
export const configurationQueryKeys = queryKeysFactory('configuration_rules')
|
||||||
|
|
||||||
|
export const useConfigurationRules = (
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
Record<string, string | number>,
|
||||||
|
Error,
|
||||||
|
{ configuration_rules: ConfigurationRule[] },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: configurationQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/configuration', {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreateConfigurationRule = (
|
||||||
|
options: UseMutationOptions<
|
||||||
|
{ configuration_rule?: ConfigurationRule },
|
||||||
|
Error,
|
||||||
|
AdminCreateRule
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
mercurQuery('/admin/configuration', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateConfigurationRule = (
|
||||||
|
options: UseMutationOptions<
|
||||||
|
{ configuration_rule?: ConfigurationRule },
|
||||||
|
Error,
|
||||||
|
{ id: string; is_enabled: boolean }
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, is_enabled }) =>
|
||||||
|
mercurQuery(`/admin/configuration/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { is_enabled }
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
50
backend/apps/backend/src/admin/hooks/api/order.tsx
Normal file
50
backend/apps/backend/src/admin/hooks/api/order.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
import {
|
||||||
|
OrderListResponse,
|
||||||
|
OrderQueryParams,
|
||||||
|
OrderResponse
|
||||||
|
} from '../../routes/orders/types'
|
||||||
|
|
||||||
|
export const orderQueryKeys = queryKeysFactory('order')
|
||||||
|
|
||||||
|
export const useOrders = (
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<OrderQueryParams, Error, OrderListResponse, QueryKey>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: orderQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/orders', {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOrder = (
|
||||||
|
id: string,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<unknown, Error, OrderResponse, QueryKey>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: orderQueryKeys.detail(id),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/orders/${id}`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
72
backend/apps/backend/src/admin/hooks/api/product.tsx
Normal file
72
backend/apps/backend/src/admin/hooks/api/product.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseQueryOptions,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
|
||||||
|
export const productQueryKeys = queryKeysFactory('product')
|
||||||
|
|
||||||
|
export const useProduct = (
|
||||||
|
id: string,
|
||||||
|
query?: any,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<unknown, Error, { product?: any }, QueryKey>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: productQueryKeys.detail(id),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/products/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProductAttributes = (id: string) => {
|
||||||
|
const { data, ...rest } = useQuery({
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/products/${id}/applicable-attributes`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
queryKey: ['product', id, 'product-attributes']
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateProduct = (
|
||||||
|
id: string,
|
||||||
|
options?: UseMutationOptions<any, Error, any>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: any) => {
|
||||||
|
return mercurQuery(`/admin/products/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
...payload
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: async (data, variables, context) => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: productQueryKeys.detail(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
options?.onSuccess?.(data, variables, context)
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { ProductCategoryDTO } from '@medusajs/framework/types'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
|
||||||
|
export const productCategoryQueryKeys = queryKeysFactory('product_category')
|
||||||
|
|
||||||
|
export const useProductCategory = (
|
||||||
|
id: string,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ product_category?: ProductCategoryDTO },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: productCategoryQueryKeys.detail(id),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/product-categories/${id}`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProductCategories = (
|
||||||
|
query?: Record<string, unknown>,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ product_categories: ProductCategoryDTO[] },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: productCategoryQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/product-categories`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { ProductCollectionDTO } from '@medusajs/framework/types'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
|
||||||
|
export const productCollectionQueryKeys = queryKeysFactory('product_collection')
|
||||||
|
|
||||||
|
export const useProductCollection = (
|
||||||
|
id: string,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ product_collection?: ProductCollectionDTO },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: productCollectionQueryKeys.detail(id),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/product-categories/${id}`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
32
backend/apps/backend/src/admin/hooks/api/product_tags.tsx
Normal file
32
backend/apps/backend/src/admin/hooks/api/product_tags.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { ProductTagDTO } from '@medusajs/framework/types'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
|
||||||
|
export const productTagsQueryKeys = queryKeysFactory('product_tags')
|
||||||
|
|
||||||
|
export const useProductTags = (
|
||||||
|
query?: Record<string, unknown>,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Error,
|
||||||
|
{ product_tags: ProductTagDTO[] },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: productTagsQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/product-tags`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
56
backend/apps/backend/src/admin/hooks/api/product_type.tsx
Normal file
56
backend/apps/backend/src/admin/hooks/api/product_type.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { ProductTypeDTO } from '@medusajs/framework/types'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
|
||||||
|
export const productTypeQueryKeys = queryKeysFactory('product_type')
|
||||||
|
|
||||||
|
export const useProductType = (
|
||||||
|
id: string,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ product_type?: ProductTypeDTO },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: productTypeQueryKeys.detail(id),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/product-types/${id}`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProductTypes = (
|
||||||
|
query?: Record<string, unknown>,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ product_types: ProductTypeDTO[] },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: productTypeQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/product-types`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
16
backend/apps/backend/src/admin/hooks/api/regions.tsx
Normal file
16
backend/apps/backend/src/admin/hooks/api/regions.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
|
||||||
|
export const useRegions = (query?: Record<string, string | number>) => {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['regions'],
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/regions', {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, isLoading }
|
||||||
|
}
|
||||||
77
backend/apps/backend/src/admin/hooks/api/requests.tsx
Normal file
77
backend/apps/backend/src/admin/hooks/api/requests.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseQueryOptions,
|
||||||
|
useMutation,
|
||||||
|
useQuery
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
import { AdminRequest, AdminReviewRequest } from '../../routes/requests/types'
|
||||||
|
|
||||||
|
export const requestsQueryKeys = queryKeysFactory('requests')
|
||||||
|
|
||||||
|
export const useVendorRequests = (
|
||||||
|
query?: any,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
any,
|
||||||
|
Error,
|
||||||
|
{
|
||||||
|
requests: AdminRequest[]
|
||||||
|
count?: number
|
||||||
|
},
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: requestsQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/requests', {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVendorRequest = (
|
||||||
|
id: string,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<unknown, Error, { request?: AdminRequest }, QueryKey>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: requestsQueryKeys.detail(id),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/requests/${id}`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReviewRequest = (
|
||||||
|
options: UseMutationOptions<
|
||||||
|
{ id?: string; status?: string },
|
||||||
|
Error,
|
||||||
|
{ id: string; payload: AdminReviewRequest }
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }) =>
|
||||||
|
mercurQuery(`/admin/requests/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
58
backend/apps/backend/src/admin/hooks/api/return-requests.tsx
Normal file
58
backend/apps/backend/src/admin/hooks/api/return-requests.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseQueryOptions,
|
||||||
|
useMutation,
|
||||||
|
useQuery
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
import {
|
||||||
|
AdminOrderReturnRequest,
|
||||||
|
AdminUpdateOrderReturnRequest
|
||||||
|
} from '../../routes/requests/types'
|
||||||
|
|
||||||
|
export const returnRequestsQueryKeys = queryKeysFactory('return-request')
|
||||||
|
|
||||||
|
export const useReturnRequests = (
|
||||||
|
query?: Record<string, unknown>,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ order_return_request: AdminOrderReturnRequest[]; count?: number },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: returnRequestsQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/return-request', {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReviewReturnRequest = (
|
||||||
|
options: UseMutationOptions<
|
||||||
|
{ orderReturnRequest?: AdminOrderReturnRequest },
|
||||||
|
Error,
|
||||||
|
{ id: string; payload: AdminUpdateOrderReturnRequest }
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }) =>
|
||||||
|
mercurQuery(`/admin/return-request/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
34
backend/apps/backend/src/admin/hooks/api/reviews.tsx
Normal file
34
backend/apps/backend/src/admin/hooks/api/reviews.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
id: string
|
||||||
|
rating: number
|
||||||
|
reference: 'seller' | 'product'
|
||||||
|
customer_id: string
|
||||||
|
customer_note?: string | null
|
||||||
|
seller_note?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reviewsQueryKeys = queryKeysFactory('reviews')
|
||||||
|
|
||||||
|
export const useReview = (
|
||||||
|
id: string,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<unknown, Error, { review?: Review }, QueryKey>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: reviewsQueryKeys.detail(id),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/reviews/${id}`, {
|
||||||
|
method: 'GET'
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
13
backend/apps/backend/src/admin/hooks/api/sales-channels.tsx
Normal file
13
backend/apps/backend/src/admin/hooks/api/sales-channels.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
|
||||||
|
export const useSalesChannels = (query?: Record<string, string | number>) => {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['sales-channels'],
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/sales-channels', { method: 'GET', query })
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, isLoading }
|
||||||
|
}
|
||||||
613
backend/apps/backend/src/admin/hooks/api/seller.tsx
Normal file
613
backend/apps/backend/src/admin/hooks/api/seller.tsx
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseQueryOptions,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
import { VendorSeller } from '../../routes/sellers/types'
|
||||||
|
|
||||||
|
export const sellerQueryKeys = queryKeysFactory('seller')
|
||||||
|
|
||||||
|
type SortableFields = 'email' | 'name' | 'created_at'
|
||||||
|
type SortableOrderFields = 'display_id' | 'created_at' | 'updated_at'
|
||||||
|
type SortableProductFields = 'title' | 'created_at' | 'updated_at'
|
||||||
|
type SortableCustomerGroupFields = 'name' | 'created_at' | 'updated_at'
|
||||||
|
|
||||||
|
const sortSellers = (sellers: VendorSeller[], order: string) => {
|
||||||
|
const field = order.startsWith('-')
|
||||||
|
? (order.slice(1) as SortableFields)
|
||||||
|
: (order as SortableFields)
|
||||||
|
const isDesc = order.startsWith('-')
|
||||||
|
|
||||||
|
return [...sellers].sort((a, b) => {
|
||||||
|
let aValue: string | number | null | undefined = a[field]
|
||||||
|
let bValue: string | number | null | undefined = b[field]
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (!aValue && aValue !== '') return isDesc ? -1 : 1
|
||||||
|
if (!bValue && bValue !== '') return isDesc ? 1 : -1
|
||||||
|
|
||||||
|
// Special handling for dates
|
||||||
|
if (field === 'created_at') {
|
||||||
|
const aDate = new Date(String(aValue)).getTime()
|
||||||
|
const bDate = new Date(String(bValue)).getTime()
|
||||||
|
return isDesc ? bDate - aDate : aDate - bDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle string comparison
|
||||||
|
const aString = String(aValue).toLowerCase()
|
||||||
|
const bString = String(bValue).toLowerCase()
|
||||||
|
|
||||||
|
if (aString < bString) return isDesc ? 1 : -1
|
||||||
|
if (aString > bString) return isDesc ? -1 : 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrders = (orders: any[], order: string) => {
|
||||||
|
const field = order.startsWith('-')
|
||||||
|
? (order.slice(1) as SortableOrderFields)
|
||||||
|
: (order as SortableOrderFields)
|
||||||
|
const isDesc = order.startsWith('-')
|
||||||
|
|
||||||
|
return [...orders].sort((a, b) => {
|
||||||
|
let aValue: string | number | null | undefined = a[field]
|
||||||
|
let bValue: string | number | null | undefined = b[field]
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (!aValue && aValue !== '') return isDesc ? -1 : 1
|
||||||
|
if (!bValue && bValue !== '') return isDesc ? 1 : -1
|
||||||
|
|
||||||
|
// Special handling for dates
|
||||||
|
if (field === 'created_at' || field === 'updated_at') {
|
||||||
|
const aDate = new Date(String(aValue)).getTime()
|
||||||
|
const bDate = new Date(String(bValue)).getTime()
|
||||||
|
return isDesc ? bDate - aDate : aDate - bDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle display_id as number
|
||||||
|
if (field === 'display_id') {
|
||||||
|
const aNum = Number(aValue)
|
||||||
|
const bNum = Number(bValue)
|
||||||
|
return isDesc ? bNum - aNum : aNum - bNum
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle string comparison
|
||||||
|
const aString = String(aValue).toLowerCase()
|
||||||
|
const bString = String(bValue).toLowerCase()
|
||||||
|
|
||||||
|
if (aString < bString) return isDesc ? 1 : -1
|
||||||
|
if (aString > bString) return isDesc ? -1 : 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortProducts = (products: any[], order: string) => {
|
||||||
|
const field = order.startsWith('-')
|
||||||
|
? (order.slice(1) as SortableProductFields)
|
||||||
|
: (order as SortableProductFields)
|
||||||
|
const isDesc = order.startsWith('-')
|
||||||
|
|
||||||
|
return [...products].sort((a, b) => {
|
||||||
|
let aValue: string | number | null | undefined = a[field]
|
||||||
|
let bValue: string | number | null | undefined = b[field]
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (!aValue && aValue !== '') return isDesc ? -1 : 1
|
||||||
|
if (!bValue && bValue !== '') return isDesc ? 1 : -1
|
||||||
|
|
||||||
|
// Special handling for dates
|
||||||
|
if (field === 'created_at' || field === 'updated_at') {
|
||||||
|
const aDate = new Date(String(aValue)).getTime()
|
||||||
|
const bDate = new Date(String(bValue)).getTime()
|
||||||
|
return isDesc ? bDate - aDate : aDate - bDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle string comparison
|
||||||
|
const aString = String(aValue).toLowerCase()
|
||||||
|
const bString = String(bValue).toLowerCase()
|
||||||
|
|
||||||
|
if (aString < bString) return isDesc ? 1 : -1
|
||||||
|
if (aString > bString) return isDesc ? -1 : 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortCustomerGroups = (customerGroups: any[], order: string) => {
|
||||||
|
const field = order.startsWith('-')
|
||||||
|
? (order.slice(1) as SortableCustomerGroupFields)
|
||||||
|
: (order as SortableCustomerGroupFields)
|
||||||
|
const isDesc = order.startsWith('-')
|
||||||
|
|
||||||
|
return [...customerGroups].sort((a, b) => {
|
||||||
|
let aValue: string | number | null | undefined = a[field]
|
||||||
|
let bValue: string | number | null | undefined = b[field]
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (!aValue && aValue !== '') return isDesc ? -1 : 1
|
||||||
|
if (!bValue && bValue !== '') return isDesc ? 1 : -1
|
||||||
|
|
||||||
|
// Special handling for dates
|
||||||
|
if (field === 'created_at' || field === 'updated_at') {
|
||||||
|
const aDate = new Date(String(aValue)).getTime()
|
||||||
|
const bDate = new Date(String(bValue)).getTime()
|
||||||
|
return isDesc ? bDate - aDate : aDate - bDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle string comparison
|
||||||
|
const aString = String(aValue).toLowerCase()
|
||||||
|
const bString = String(bValue).toLowerCase()
|
||||||
|
|
||||||
|
if (aString < bString) return isDesc ? 1 : -1
|
||||||
|
if (aString > bString) return isDesc ? -1 : 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSellers = (
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
Record<string, string | number>,
|
||||||
|
Error,
|
||||||
|
{ sellers: VendorSeller[] },
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>,
|
||||||
|
filters?: Record<string, string | number>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: sellerQueryKeys.list(),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/sellers', {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data?.sellers) {
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedSellers = [...data.sellers]
|
||||||
|
|
||||||
|
// Apply search filter if present
|
||||||
|
if (filters?.q) {
|
||||||
|
const searchTerm = String(filters.q).toLowerCase()
|
||||||
|
processedSellers = processedSellers.filter(
|
||||||
|
(seller) =>
|
||||||
|
seller.name?.toLowerCase().includes(searchTerm) ||
|
||||||
|
seller.email?.toLowerCase().includes(searchTerm)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting if present
|
||||||
|
if (filters?.order) {
|
||||||
|
const order = String(filters.order)
|
||||||
|
const validOrders = [
|
||||||
|
'email',
|
||||||
|
'-email',
|
||||||
|
'name',
|
||||||
|
'-name',
|
||||||
|
'created_at',
|
||||||
|
'-created_at'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
if (validOrders.includes(order as (typeof validOrders)[number])) {
|
||||||
|
processedSellers = sortSellers(processedSellers, order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
sellers: processedSellers,
|
||||||
|
...other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSeller = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: sellerQueryKeys.detail(id),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/sellers/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
query: {
|
||||||
|
fields:
|
||||||
|
'id,email,name,created_at,store_status,description,handle,phone,address_line,city,country_code,postal_code,tax_id'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSellerOrders = (
|
||||||
|
id: string,
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
filters?: any
|
||||||
|
) => {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['seller-orders', id, query],
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/sellers/${id}/orders`, {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data?.orders) {
|
||||||
|
return { data, isLoading }
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedOrders = [...data.orders]
|
||||||
|
|
||||||
|
// Apply search filter if present
|
||||||
|
if (filters?.q) {
|
||||||
|
const searchTerm = String(filters.q).toLowerCase()
|
||||||
|
processedOrders = processedOrders.filter(
|
||||||
|
(order) =>
|
||||||
|
order.customer?.first_name?.toLowerCase().includes(searchTerm) ||
|
||||||
|
order.customer?.last_name?.toLowerCase().includes(searchTerm) ||
|
||||||
|
order.customer?.email?.toLowerCase().includes(searchTerm)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by region_id
|
||||||
|
if (filters?.region_id && Array.isArray(filters.region_id)) {
|
||||||
|
processedOrders = processedOrders.filter(
|
||||||
|
(order) => order.region_id && filters.region_id.includes(order.region_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by sales_channel_id
|
||||||
|
if (filters?.sales_channel_id && Array.isArray(filters.sales_channel_id)) {
|
||||||
|
processedOrders = processedOrders.filter(
|
||||||
|
(order) =>
|
||||||
|
order.sales_channel_id &&
|
||||||
|
filters.sales_channel_id.includes(order.sales_channel_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by created_at date ranges
|
||||||
|
if (filters?.created_at) {
|
||||||
|
const dateFilter = filters.created_at as any
|
||||||
|
if (dateFilter.$gte) {
|
||||||
|
const filterDate = new Date(dateFilter.$gte)
|
||||||
|
processedOrders = processedOrders.filter((order) => {
|
||||||
|
const orderCreatedAt = new Date(order.created_at || '')
|
||||||
|
return orderCreatedAt >= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (dateFilter.$lte) {
|
||||||
|
const filterDate = new Date(dateFilter.$lte)
|
||||||
|
processedOrders = processedOrders.filter((order) => {
|
||||||
|
const orderCreatedAt = new Date(order.created_at || '')
|
||||||
|
return orderCreatedAt <= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by updated_at date ranges
|
||||||
|
if (filters?.updated_at) {
|
||||||
|
const dateFilter = filters.updated_at as any
|
||||||
|
|
||||||
|
if (dateFilter.$gte) {
|
||||||
|
const filterDate = new Date(dateFilter.$gte)
|
||||||
|
processedOrders = processedOrders.filter((order) => {
|
||||||
|
const orderUpdatedAt = new Date(order.updated_at || '')
|
||||||
|
return orderUpdatedAt >= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (dateFilter.$lte) {
|
||||||
|
const filterDate = new Date(dateFilter.$lte)
|
||||||
|
processedOrders = processedOrders.filter((order) => {
|
||||||
|
const orderUpdatedAt = new Date(order.updated_at || '')
|
||||||
|
return orderUpdatedAt <= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting if present
|
||||||
|
if (filters?.order) {
|
||||||
|
const order = String(filters.order)
|
||||||
|
const validOrders = [
|
||||||
|
'display_id',
|
||||||
|
'-display_id',
|
||||||
|
'created_at',
|
||||||
|
'-created_at',
|
||||||
|
'updated_at',
|
||||||
|
'-updated_at'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
if (validOrders.includes(order as (typeof validOrders)[number])) {
|
||||||
|
processedOrders = sortOrders(processedOrders, order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = Number(filters.offset) || 0
|
||||||
|
const limit = Number(filters.limit) || 10
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
orders: processedOrders.slice(offset, offset + limit),
|
||||||
|
count: processedOrders.length
|
||||||
|
},
|
||||||
|
isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateSeller = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
||||||
|
mercurQuery(`/admin/sellers/${id}`, { method: 'POST', body: data }),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: sellerQueryKeys.list() })
|
||||||
|
queryClient.invalidateQueries({ queryKey: sellerQueryKeys.detail(id) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSellerProducts = (
|
||||||
|
id: string,
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
filters?: any
|
||||||
|
) => {
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['seller-products', id, query],
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/sellers/${id}/products`, { method: 'GET', query })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data?.products) {
|
||||||
|
return { data, isLoading, refetch }
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedProducts = [...data.products]
|
||||||
|
|
||||||
|
// Apply search filter if present
|
||||||
|
if (filters?.q) {
|
||||||
|
const searchTerm = String(filters.q).toLowerCase()
|
||||||
|
processedProducts = processedProducts.filter((product) =>
|
||||||
|
product.title?.toLowerCase().includes(searchTerm)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by tag_id
|
||||||
|
if (filters?.tag_id && Array.isArray(filters.tag_id)) {
|
||||||
|
processedProducts = processedProducts.filter((product) =>
|
||||||
|
product.tags?.some((tag: any) => filters.tag_id.includes(tag.id))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by type_id
|
||||||
|
if (filters?.type_id && Array.isArray(filters.type_id)) {
|
||||||
|
processedProducts = processedProducts.filter((product) =>
|
||||||
|
filters.type_id.includes(product.type_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by sales_channel_id
|
||||||
|
if (filters?.sales_channel_id && Array.isArray(filters.sales_channel_id)) {
|
||||||
|
processedProducts = processedProducts.filter((product) =>
|
||||||
|
product.sales_channels?.some((channel: any) =>
|
||||||
|
filters.sales_channel_id.includes(channel.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if (filters?.status && Array.isArray(filters.status)) {
|
||||||
|
processedProducts = processedProducts.filter((product) =>
|
||||||
|
filters.status.includes(product.status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by created_at date ranges
|
||||||
|
if (filters?.created_at) {
|
||||||
|
const dateFilter = filters.created_at as any
|
||||||
|
if (dateFilter.$gte) {
|
||||||
|
const filterDate = new Date(dateFilter.$gte)
|
||||||
|
processedProducts = processedProducts.filter((product) => {
|
||||||
|
const productCreatedAt = new Date(product.created_at || '')
|
||||||
|
return productCreatedAt >= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (dateFilter.$lte) {
|
||||||
|
const filterDate = new Date(dateFilter.$lte)
|
||||||
|
processedProducts = processedProducts.filter((product) => {
|
||||||
|
const productCreatedAt = new Date(product.created_at || '')
|
||||||
|
return productCreatedAt <= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by updated_at date ranges
|
||||||
|
if (filters?.updated_at) {
|
||||||
|
const dateFilter = filters.updated_at as any
|
||||||
|
if (dateFilter.$gte) {
|
||||||
|
const filterDate = new Date(dateFilter.$gte)
|
||||||
|
processedProducts = processedProducts.filter((product) => {
|
||||||
|
const productUpdatedAt = new Date(product.updated_at || '')
|
||||||
|
return productUpdatedAt >= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (dateFilter.$lte) {
|
||||||
|
const filterDate = new Date(dateFilter.$lte)
|
||||||
|
processedProducts = processedProducts.filter((product) => {
|
||||||
|
const productUpdatedAt = new Date(product.updated_at || '')
|
||||||
|
return productUpdatedAt <= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting if present
|
||||||
|
if (filters?.order) {
|
||||||
|
const order = String(filters.order)
|
||||||
|
const validOrders = [
|
||||||
|
'title',
|
||||||
|
'-title',
|
||||||
|
'created_at',
|
||||||
|
'-created_at',
|
||||||
|
'updated_at',
|
||||||
|
'-updated_at'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
if (validOrders.includes(order as (typeof validOrders)[number])) {
|
||||||
|
processedProducts = sortProducts(processedProducts, order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
const offset = Number(filters?.offset) || 0
|
||||||
|
const limit = Number(filters?.limit) || 10
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
products: processedProducts.slice(offset, offset + limit),
|
||||||
|
count: processedProducts.length
|
||||||
|
},
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSellerCustomerGroups = (
|
||||||
|
id: string,
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
filters?: Record<string, string | number>
|
||||||
|
) => {
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['seller-customer-groups', id, query],
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/sellers/${id}/customer-groups`, {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data?.customer_groups) {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedCustomerGroups = [
|
||||||
|
...data.customer_groups.filter((group: any) => !!group)
|
||||||
|
]
|
||||||
|
|
||||||
|
// Apply search filter if present
|
||||||
|
if (filters?.q) {
|
||||||
|
const searchTerm = String(filters.q).toLowerCase()
|
||||||
|
processedCustomerGroups = processedCustomerGroups.filter((group) =>
|
||||||
|
group.name?.toLowerCase().includes(searchTerm)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by created_at date ranges
|
||||||
|
if (filters?.created_at) {
|
||||||
|
const dateFilter = filters.created_at as any
|
||||||
|
if (dateFilter.$gte) {
|
||||||
|
const filterDate = new Date(dateFilter.$gte)
|
||||||
|
processedCustomerGroups = processedCustomerGroups.filter((group) => {
|
||||||
|
const groupCreatedAt = new Date(group.created_at || '')
|
||||||
|
return groupCreatedAt >= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (dateFilter.$lte) {
|
||||||
|
const filterDate = new Date(dateFilter.$lte)
|
||||||
|
processedCustomerGroups = processedCustomerGroups.filter((group) => {
|
||||||
|
const groupCreatedAt = new Date(group.created_at || '')
|
||||||
|
return groupCreatedAt <= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by updated_at date ranges
|
||||||
|
if (filters?.updated_at) {
|
||||||
|
const dateFilter = filters.updated_at as any
|
||||||
|
if (dateFilter.$gte) {
|
||||||
|
const filterDate = new Date(dateFilter.$gte)
|
||||||
|
processedCustomerGroups = processedCustomerGroups.filter((group) => {
|
||||||
|
const groupUpdatedAt = new Date(group.updated_at || '')
|
||||||
|
return groupUpdatedAt >= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (dateFilter.$lte) {
|
||||||
|
const filterDate = new Date(dateFilter.$lte)
|
||||||
|
processedCustomerGroups = processedCustomerGroups.filter((group) => {
|
||||||
|
const groupUpdatedAt = new Date(group.updated_at || '')
|
||||||
|
return groupUpdatedAt <= filterDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting if present
|
||||||
|
if (filters?.order) {
|
||||||
|
const order = String(filters.order)
|
||||||
|
const validOrders = [
|
||||||
|
'name',
|
||||||
|
'-name',
|
||||||
|
'created_at',
|
||||||
|
'-created_at',
|
||||||
|
'updated_at',
|
||||||
|
'-updated_at'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
if (validOrders.includes(order as (typeof validOrders)[number])) {
|
||||||
|
processedCustomerGroups = sortCustomerGroups(
|
||||||
|
processedCustomerGroups,
|
||||||
|
order
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = Number(filters?.offset) || 0
|
||||||
|
const limit = Number(filters?.limit) || 10
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
customer_groups: processedCustomerGroups.slice(offset, offset + limit),
|
||||||
|
count: processedCustomerGroups.length
|
||||||
|
},
|
||||||
|
count: processedCustomerGroups.length,
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInviteSeller = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
email,
|
||||||
|
registration_url = undefined
|
||||||
|
}: {
|
||||||
|
email: string
|
||||||
|
registration_url?: string
|
||||||
|
}) =>
|
||||||
|
mercurQuery('/admin/sellers/invite', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, registration_url }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOrderSet = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['order-set', id],
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery(`/admin/order-sets?order_id=${id}`, {
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
42
backend/apps/backend/src/admin/hooks/api/stores.tsx
Normal file
42
backend/apps/backend/src/admin/hooks/api/stores.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { mercurQuery } from '../../lib/client'
|
||||||
|
import { queryKeysFactory } from '../../lib/query-keys-factory'
|
||||||
|
|
||||||
|
export const storesQueryKeys = queryKeysFactory('stores')
|
||||||
|
|
||||||
|
export interface AdminStore {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
supported_currencies: { currency_code: string }[]
|
||||||
|
default_sales_channel_id: string
|
||||||
|
default_region_id: string
|
||||||
|
default_location_id: string
|
||||||
|
metadata: object
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStoreListResponse {
|
||||||
|
stores: AdminStore[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStores = (
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<unknown, Error, AdminStoreListResponse, QueryKey>,
|
||||||
|
'queryFn' | 'queryKey'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...other } = useQuery({
|
||||||
|
queryKey: storesQueryKeys.list(query),
|
||||||
|
queryFn: () =>
|
||||||
|
mercurQuery('/admin/stores', {
|
||||||
|
method: 'GET',
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...other }
|
||||||
|
}
|
||||||
15
backend/apps/backend/src/admin/hooks/api/talk-js.tsx
Normal file
15
backend/apps/backend/src/admin/hooks/api/talk-js.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const useTalkJS = () => {
|
||||||
|
const { data: talkJs, isLoading } = useQuery({
|
||||||
|
queryKey: ['talk-js'],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch('/admin/talk-js')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch((err) => {
|
||||||
|
message: err
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...talkJs, isLoading }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { FindParams, HttpTypes } from '@medusajs/types'
|
||||||
|
|
||||||
|
import { useQueryParams } from '../use-query-params'
|
||||||
|
|
||||||
|
type UseCommissionRulesTableQueryProps = {
|
||||||
|
prefix?: string
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommissionRulesTableQuery = ({
|
||||||
|
prefix,
|
||||||
|
pageSize = 20
|
||||||
|
}: UseCommissionRulesTableQueryProps) => {
|
||||||
|
const queryObject = useQueryParams(
|
||||||
|
['offset', 'q', 'order', 'created_at', 'updated_at'],
|
||||||
|
prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
const { offset, q, order, created_at, updated_at } = queryObject
|
||||||
|
|
||||||
|
const searchParams: FindParams & HttpTypes.AdminRegionFilters = {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: offset ? Number(offset) : 0,
|
||||||
|
order,
|
||||||
|
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||||
|
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||||
|
q
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchParams,
|
||||||
|
raw: queryObject
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import { Badge, createDataTableColumnHelper } from "@medusajs/ui";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { AttributeDTO } from "../../../../modules/attribute/types";
|
||||||
|
|
||||||
|
|
||||||
|
const columnHelper = createDataTableColumnHelper<AttributeDTO>();
|
||||||
|
|
||||||
|
export const useAttributeTableColumns = () => {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: "Name",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("handle", {
|
||||||
|
header: "Handle",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("product_categories", {
|
||||||
|
header: "Global",
|
||||||
|
cell: (info) => {
|
||||||
|
const isGlobal = !info.getValue()?.length;
|
||||||
|
return (
|
||||||
|
<Badge size="xsmall" color={isGlobal ? "green" : "grey"}>
|
||||||
|
{isGlobal ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("possible_values", {
|
||||||
|
header: "Possible Values",
|
||||||
|
cell: (info) => {
|
||||||
|
const values = info.getValue();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{values?.map((value) => (
|
||||||
|
<Badge size="xsmall" key={value.id}>
|
||||||
|
{value.value}
|
||||||
|
</Badge>
|
||||||
|
)) || "-"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TextCell,
|
||||||
|
TextHeader,
|
||||||
|
} from "../../../components/table/table-cells/common/text-cell";
|
||||||
|
import { StatusCell } from "../../../components/table/table-cells/common/status-cell";
|
||||||
|
import { CommissionActionMenu } from "../../../routes/commission/components/commission-actions";
|
||||||
|
import { AdminCommissionAggregate } from "../../../routes/commission/types";
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<AdminCommissionAggregate>();
|
||||||
|
|
||||||
|
export const useCommissionRulesTableColumns = ({
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}) => {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: () => <TextHeader text={"Rule Name"} />,
|
||||||
|
cell: ({ getValue }) => <TextCell text={getValue()} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("reference", {
|
||||||
|
header: () => <TextHeader text={"Type"} />,
|
||||||
|
cell: ({ getValue }) => <TextCell text={getValue()} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("ref_value", {
|
||||||
|
header: () => <TextHeader text={"Attribute"} />,
|
||||||
|
cell: ({ getValue }) => <TextCell text={getValue()} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("fee_value", {
|
||||||
|
header: () => <TextHeader text={"Fee"} />,
|
||||||
|
cell: ({ getValue }) => <TextCell text={getValue()} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("is_active", {
|
||||||
|
header: () => <TextHeader text={"Status"} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
return (
|
||||||
|
<StatusCell color={value ? "green" : "grey"}>
|
||||||
|
{value ? "Enabled" : "Disabled"}
|
||||||
|
</StatusCell>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("id", {
|
||||||
|
header: () => <TextHeader text={"Status"} />,
|
||||||
|
cell: (props) => {
|
||||||
|
return (
|
||||||
|
<CommissionActionMenu
|
||||||
|
id={props.row.original.id!}
|
||||||
|
is_active={props.row.original.is_active!}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
};
|
||||||
132
backend/apps/backend/src/admin/hooks/table/use-data-table.tsx
Normal file
132
backend/apps/backend/src/admin/hooks/table/use-data-table.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
OnChangeFn,
|
||||||
|
PaginationState,
|
||||||
|
Row,
|
||||||
|
RowSelectionState,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
type UseDataTableProps<TData> = {
|
||||||
|
data?: TData[]
|
||||||
|
columns: ColumnDef<TData, any>[]
|
||||||
|
count?: number
|
||||||
|
pageSize?: number
|
||||||
|
enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
|
||||||
|
rowSelection?: {
|
||||||
|
state: RowSelectionState
|
||||||
|
updater: OnChangeFn<RowSelectionState>
|
||||||
|
}
|
||||||
|
enablePagination?: boolean
|
||||||
|
enableExpandableRows?: boolean
|
||||||
|
getRowId?: (original: TData, index: number) => string
|
||||||
|
getSubRows?: (original: TData) => TData[]
|
||||||
|
meta?: Record<string, unknown>
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDataTable = <TData,>({
|
||||||
|
data = [],
|
||||||
|
columns,
|
||||||
|
count = 0,
|
||||||
|
pageSize: _pageSize = 20,
|
||||||
|
enablePagination = true,
|
||||||
|
enableRowSelection = false,
|
||||||
|
enableExpandableRows = false,
|
||||||
|
rowSelection: _rowSelection,
|
||||||
|
getSubRows,
|
||||||
|
getRowId,
|
||||||
|
meta,
|
||||||
|
prefix,
|
||||||
|
}: UseDataTableProps<TData>) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const offsetKey = `${prefix ? `${prefix}_` : ""}offset`
|
||||||
|
const offset = searchParams.get(offsetKey)
|
||||||
|
|
||||||
|
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: offset ? Math.ceil(Number(offset) / _pageSize) : 0,
|
||||||
|
pageSize: _pageSize,
|
||||||
|
})
|
||||||
|
const pagination = useMemo(
|
||||||
|
() => ({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[pageIndex, pageSize]
|
||||||
|
)
|
||||||
|
const [localRowSelection, setLocalRowSelection] = useState({})
|
||||||
|
const rowSelection = _rowSelection?.state ?? localRowSelection
|
||||||
|
const setRowSelection = _rowSelection?.updater ?? setLocalRowSelection
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enablePagination) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = offset ? Math.ceil(Number(offset) / _pageSize) : 0
|
||||||
|
|
||||||
|
if (index === pageIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: index,
|
||||||
|
}))
|
||||||
|
}, [offset, enablePagination, _pageSize, pageIndex])
|
||||||
|
|
||||||
|
const onPaginationChange = (
|
||||||
|
updater: (old: PaginationState) => PaginationState
|
||||||
|
) => {
|
||||||
|
const state = updater(pagination)
|
||||||
|
const { pageIndex, pageSize } = state
|
||||||
|
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
if (!pageIndex) {
|
||||||
|
prev.delete(offsetKey)
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSearch = new URLSearchParams(prev)
|
||||||
|
newSearch.set(offsetKey, String(pageIndex * pageSize))
|
||||||
|
|
||||||
|
return newSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
setPagination(state)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
rowSelection: rowSelection, // We always pass a selection state to the table even if it's not enabled
|
||||||
|
pagination: enablePagination ? pagination : undefined,
|
||||||
|
},
|
||||||
|
pageCount: Math.ceil((count ?? 0) / pageSize),
|
||||||
|
enableRowSelection,
|
||||||
|
getRowId,
|
||||||
|
getSubRows,
|
||||||
|
onRowSelectionChange: enableRowSelection ? setRowSelection : undefined,
|
||||||
|
onPaginationChange: enablePagination
|
||||||
|
? (onPaginationChange as OnChangeFn<PaginationState>)
|
||||||
|
: undefined,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: enablePagination
|
||||||
|
? getPaginationRowModel()
|
||||||
|
: undefined,
|
||||||
|
getExpandedRowModel: enableExpandableRows
|
||||||
|
? getExpandedRowModel()
|
||||||
|
: undefined,
|
||||||
|
manualPagination: enablePagination ? true : undefined,
|
||||||
|
meta,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { table }
|
||||||
|
}
|
||||||
42
backend/apps/backend/src/admin/hooks/use-date.tsx
Normal file
42
backend/apps/backend/src/admin/hooks/use-date.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { format, formatDistance, sub } from 'date-fns'
|
||||||
|
import { enUS } from 'date-fns/locale'
|
||||||
|
|
||||||
|
// TODO: We rely on the current language to determine the date locale. This is not ideal, as we use en-US for the english translation.
|
||||||
|
// We either need to also have an en-GB translation or we need to separate the date locale from the translation language.
|
||||||
|
export const useDate = () => {
|
||||||
|
const locale = enUS
|
||||||
|
|
||||||
|
const getFullDate = ({
|
||||||
|
date,
|
||||||
|
includeTime = false
|
||||||
|
}: {
|
||||||
|
date: string | Date
|
||||||
|
includeTime?: boolean
|
||||||
|
}) => {
|
||||||
|
const ensuredDate = new Date(date)
|
||||||
|
|
||||||
|
if (isNaN(ensuredDate.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeFormat = includeTime ? 'p' : ''
|
||||||
|
|
||||||
|
return format(ensuredDate, `PP ${timeFormat}`, {
|
||||||
|
locale
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelativeDate(date: string | Date): string {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
return formatDistance(sub(new Date(date), { minutes: 0 }), now, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getFullDate,
|
||||||
|
getRelativeDate
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user