Initial commit: backend, storefront, vendor-panel added

This commit is contained in:
2025-08-01 11:05:32 +08:00
commit 08174125d2
2958 changed files with 310810 additions and 0 deletions

166
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,129 @@
![Mercur Main Cover](https://cdn.prod.website-files.com/6790aeffc4b432ccaf1b56e5/67a225dc6fa298afc1cc4ae6_Mercur%20Cover.png)
<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, were 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)
![Mercur Use Cases](https://cdn.prod.website-files.com/6790aeffc4b432ccaf1b56e5/67b46aa08180d5b8499c6a15_Use-cases.jpg)
&nbsp;
# 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.
![Mercur](https://cdn.prod.website-files.com/6790aeffc4b432ccaf1b56e5/67a1020f202572832c954ead_6b96703adfe74613f85133f83a19b1f0_Fleek%20Tilt%20-%20Readme.png)
&nbsp;
## 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
```
&nbsp;
## 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)

View 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"
]
}

View 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

View File

28
backend/apps/backend/.gitignore vendored Normal file
View 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

View 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"
]
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View 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"]

View 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

View 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'
}
}
]

View 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
// }
// })
// }

View 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).

View 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)
})
})
}
})

View 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']
}

View 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']
}
}
]
}
}
]
})

View 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
View File

@@ -0,0 +1 @@
.medusa/server/public/

View 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 products details page.

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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>
}
}

View File

@@ -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>
)
}

View File

@@ -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>
}
}

View File

@@ -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>
}
}

View File

@@ -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>
)
}

View File

@@ -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} />
}

View File

@@ -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>
}
}

View File

@@ -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>
)
}

View 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>
);
};

View File

@@ -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>
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,9 @@
export interface IFilter {
filter: {
key: string
label: string
}
readonly?: boolean
openOnMount?: boolean
prefix?: string
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./data-table-order-by"

View File

@@ -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>
)
)
}

View File

@@ -0,0 +1 @@
export * from "./data-table-query"

View File

@@ -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}
/>
)
}

View File

@@ -0,0 +1 @@
export * from "./data-table-root"

View File

@@ -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}
/>
)
}

View File

@@ -0,0 +1 @@
export * from "./data-table-search"

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 }
}

View File

@@ -0,0 +1,2 @@
export * from "./data-table"
export type { Filter } from "./data-table-filter"

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./code-cell"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./created-at-cell"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./date-cell"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./email-cell"

View File

@@ -0,0 +1 @@
export * from "./money-amount-cell"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./name-cell"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./placeholder-cell"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./status-cell"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./text-cell"

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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' })
})
}

View 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 }
}

View 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
})
})
}

View 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
})
}

View 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 }
}

View 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
})
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View 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 }
}

View 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 }
}

View 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 }
}

View 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
})
}

View 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
})
}

View 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 }
}

View 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 }
}

View 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'
})
})
}

View 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 }
}

View 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 }
}

View File

@@ -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
}
}

View File

@@ -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>
);
},
}),
],
[]
);
};

View File

@@ -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}
/>
);
},
}),
],
[],
);
};

View 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 }
}

View 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