diff --git a/README.md b/README.md index 28138f56..79698ebb 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ cp .env.template .env # Setup database and run migrations yarn medusa db:create && yarn medusa db:migrate && yarn run seed +# Generate OpenAPI client +yarn codegen + # Go to root folder cd ../.. diff --git a/apps/backend/src/api/vendor/orders/[id]/route.ts b/apps/backend/src/api/vendor/orders/[id]/route.ts index 427f8577..c43da92f 100644 --- a/apps/backend/src/api/vendor/orders/[id]/route.ts +++ b/apps/backend/src/api/vendor/orders/[id]/route.ts @@ -1,5 +1,6 @@ import { AuthenticatedMedusaRequest, MedusaResponse } from '@medusajs/framework' -import { ContainerRegistrationKeys } from '@medusajs/framework/utils' +import { OrderDTO } from '@medusajs/framework/types' +import { getOrdersListWorkflow } from '@medusajs/medusa/core-flows' /** * @oas [get] /vendor/orders/{id} @@ -22,7 +23,7 @@ import { ContainerRegistrationKeys } from '@medusajs/framework/utils' * schema: * type: object * properties: - * member: + * order: * $ref: "#/components/schemas/VendorOrderDetails" * tags: * - Order @@ -34,19 +35,20 @@ export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) - const { id } = req.params - const { - data: [order] - } = await query.graph( - { - entity: 'order', + + const { result } = await getOrdersListWorkflow(req.scope).run({ + input: { fields: req.remoteQueryConfig.fields, - filters: { id: id } - }, - { throwIfKeyNotFound: true } - ) + variables: { + filters: { + id + } + } + } + }) + + const [order] = result as OrderDTO[] res.json({ order }) } diff --git a/apps/backend/src/api/vendor/orders/middlewares.ts b/apps/backend/src/api/vendor/orders/middlewares.ts index bcf04ae3..662b143e 100644 --- a/apps/backend/src/api/vendor/orders/middlewares.ts +++ b/apps/backend/src/api/vendor/orders/middlewares.ts @@ -7,10 +7,7 @@ import { import sellerOrderLink from '../../../links/seller-order' import sellerLocationLink from '../../../links/seller-stock-location' -import { - checkResourceOwnershipByResourceId, - filterBySellerId -} from '../../../shared/infra/http/middlewares' +import { checkResourceOwnershipByResourceId } from '../../../shared/infra/http/middlewares' import { vendorOrderQueryConfig } from './query-config' import { VendorCreateFulfillment, @@ -26,8 +23,7 @@ export const vendorOrderMiddlewares: MiddlewareRoute[] = [ validateAndTransformQuery( VendorGetOrderParams, vendorOrderQueryConfig.list - ), - filterBySellerId() + ) ] }, { @@ -39,7 +35,8 @@ export const vendorOrderMiddlewares: MiddlewareRoute[] = [ vendorOrderQueryConfig.retrieve ), checkResourceOwnershipByResourceId({ - entryPoint: sellerOrderLink.entryPoint + entryPoint: sellerOrderLink.entryPoint, + filterField: 'order_id' }) ] }, @@ -52,7 +49,8 @@ export const vendorOrderMiddlewares: MiddlewareRoute[] = [ vendorOrderQueryConfig.retrieve ), checkResourceOwnershipByResourceId({ - entryPoint: sellerOrderLink.entryPoint + entryPoint: sellerOrderLink.entryPoint, + filterField: 'order_id' }) ] }, @@ -65,7 +63,8 @@ export const vendorOrderMiddlewares: MiddlewareRoute[] = [ vendorOrderQueryConfig.retrieve ), checkResourceOwnershipByResourceId({ - entryPoint: sellerOrderLink.entryPoint + entryPoint: sellerOrderLink.entryPoint, + filterField: 'order_id' }) ] }, @@ -75,7 +74,8 @@ export const vendorOrderMiddlewares: MiddlewareRoute[] = [ middlewares: [ validateAndTransformBody(VendorCreateFulfillment), checkResourceOwnershipByResourceId({ - entryPoint: sellerOrderLink.entryPoint + entryPoint: sellerOrderLink.entryPoint, + filterField: 'order_id' }), checkResourceOwnershipByResourceId({ entryPoint: sellerLocationLink.entryPoint, diff --git a/apps/backend/src/api/vendor/orders/query-config.ts b/apps/backend/src/api/vendor/orders/query-config.ts index e49165f5..2f08eb06 100644 --- a/apps/backend/src/api/vendor/orders/query-config.ts +++ b/apps/backend/src/api/vendor/orders/query-config.ts @@ -1,29 +1,20 @@ -export const vendorListOrderFields = [ +export const vendorOrderFields = [ 'id', - 'status', - 'summary', 'display_id', - 'total', + 'status', + 'email', 'currency_code', + 'version', + 'summary', 'metadata', 'created_at', 'updated_at', - '*seller' -] - -export const vendorRetrieveOrderFields = [ - 'id', - 'status', - 'summary', - 'currency_code', - 'display_id', 'region_id', - 'email', 'total', 'subtotal', 'tax_total', + 'order_change', 'discount_total', - 'discount_subtotal', 'discount_tax_total', 'original_total', 'original_tax_total', @@ -39,26 +30,29 @@ export const vendorRetrieveOrderFields = [ 'original_shipping_tax_total', 'original_shipping_subtotal', 'original_shipping_total', - 'created_at', - 'updated_at', '*items', - '*items.detail', + '*items.tax_lines', + '*items.adjustments', '*items.variant', '*items.variant.product', + '*items.detail', '*shipping_address', '*billing_address', '*shipping_methods', - '*payment_collections', - '*seller' + '*shipping_methods.tax_lines', + '*shipping_methods.adjustments', + '*fulfillments', + '*fulfillments.items', + '*fulfillments.labels' ] export const vendorOrderQueryConfig = { list: { - defaults: vendorListOrderFields, + defaults: vendorOrderFields, isList: true }, retrieve: { - defaults: vendorRetrieveOrderFields, + defaults: vendorOrderFields, isList: false } } diff --git a/apps/backend/src/api/vendor/orders/route.ts b/apps/backend/src/api/vendor/orders/route.ts index 5cb544cb..1c1c669e 100644 --- a/apps/backend/src/api/vendor/orders/route.ts +++ b/apps/backend/src/api/vendor/orders/route.ts @@ -1,5 +1,10 @@ -import { MedusaRequest, MedusaResponse } from '@medusajs/framework' +import sellerOrderLink from '#/links/seller-order' +import { fetchSellerByAuthActorId } from '#/shared/infra/http/utils' + +import { AuthenticatedMedusaRequest, MedusaResponse } from '@medusajs/framework' +import { OrderDTO } from '@medusajs/framework/types' import { ContainerRegistrationKeys } from '@medusajs/framework/utils' +import { getOrdersListWorkflow } from '@medusajs/medusa/core-flows' import { VendorGetOrderParamsType } from './validators' @@ -34,6 +39,41 @@ import { VendorGetOrderParamsType } from './validators' * type: string * required: false * description: The order of the returned items. + * - name: created_at + * in: query + * schema: + * type: object + * required: false + * description: Filter by created at date range + * - name: status + * in: query + * schema: + * oneOf: + * - type: string + * - type: array + * items: + * type: string + * - type: object + * required: false + * description: Filter by order status + * - name: fulfillment_status + * in: query + * schema: + * type: string + * required: false + * description: Filter by fulfillment status + * - name: payment_status + * in: query + * schema: + * type: string + * required: false + * description: Filter by payment status + * - name: q + * in: query + * schema: + * type: string + * required: false + * description: Search query for filtering orders * responses: * "200": * description: OK @@ -42,7 +82,7 @@ import { VendorGetOrderParamsType } from './validators' * schema: * type: object * properties: - * products: + * orders: * type: array * items: * $ref: "#/components/schemas/VendorOrderDetails" @@ -62,22 +102,44 @@ import { VendorGetOrderParamsType } from './validators' * - cookie_auth: [] */ export const GET = async ( - req: MedusaRequest, + req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) - const { data: orders, metadata } = await query.graph({ - entity: 'order', - fields: req.remoteQueryConfig.fields, - filters: req.filterableFields, - pagination: { - ...req.remoteQueryConfig.pagination + const seller = await fetchSellerByAuthActorId( + req.auth_context.actor_id, + req.scope + ) + + const { data: orderRelations } = await query.graph({ + entity: sellerOrderLink.entryPoint, + fields: ['order_id'], + filters: { + seller_id: seller.id + } + }) + + const { result } = await getOrdersListWorkflow(req.scope).run({ + input: { + fields: req.remoteQueryConfig.fields, + variables: { + filters: { + ...req.filterableFields, + id: orderRelations.map((relation) => relation.order_id) + }, + ...req.remoteQueryConfig.pagination + } } }) + const { rows, metadata } = result as { + rows: OrderDTO[] + metadata: any + } + res.json({ - members: orders, + orders: rows, count: metadata!.count, offset: metadata!.skip, limit: metadata!.take diff --git a/apps/backend/src/api/vendor/orders/validators.ts b/apps/backend/src/api/vendor/orders/validators.ts index bc71df87..850578b5 100644 --- a/apps/backend/src/api/vendor/orders/validators.ts +++ b/apps/backend/src/api/vendor/orders/validators.ts @@ -1,12 +1,25 @@ import * as z from 'zod' -import { createFindParams } from '@medusajs/medusa/api/utils/validators' +import { + createFindParams, + createOperatorMap +} from '@medusajs/medusa/api/utils/validators' export type VendorGetOrderParamsType = z.infer export const VendorGetOrderParams = createFindParams({ offset: 0, limit: 50 -}) +}).merge( + z.object({ + created_at: createOperatorMap().optional(), + status: z + .union([z.string(), z.array(z.string()), createOperatorMap()]) + .optional(), + fulfillment_status: z.string().optional(), + payment_status: z.string().optional(), + q: z.string().optional() + }) +) /** * @schema VendorCreateFulfillment diff --git a/apps/backend/src/workflows/cart/workflows/split-and-complete-cart.ts b/apps/backend/src/workflows/cart/workflows/split-and-complete-cart.ts index f486bbcb..2232d42d 100644 --- a/apps/backend/src/workflows/cart/workflows/split-and-complete-cart.ts +++ b/apps/backend/src/workflows/cart/workflows/split-and-complete-cart.ts @@ -221,9 +221,10 @@ export const splitAndCompleteCartWorkflow = createWorkflow( { createdOrders, sellers, - orderSet + orderSet, + cart }, - ({ createdOrders, sellers, orderSet }) => { + ({ createdOrders, sellers, orderSet, cart }) => { const sellerOrderLinks = createdOrders.map((order, index) => ({ [SELLER_MODULE]: { seller_id: sellers[index] @@ -242,7 +243,20 @@ export const splitAndCompleteCartWorkflow = createWorkflow( } })) - return [...sellerOrderLinks, ...orderSetOrderLinks] + const orderPaymentLinks = createdOrders.map((order) => ({ + [Modules.ORDER]: { + order_id: order.id + }, + [Modules.PAYMENT]: { + payment_collection_id: cart.payment_collection.id + } + })) + + return [ + ...sellerOrderLinks, + ...orderSetOrderLinks, + ...orderPaymentLinks + ] } ) diff --git a/apps/vendor/package.json b/apps/vendor/package.json index 244cbb3f..cfcf373f 100644 --- a/apps/vendor/package.json +++ b/apps/vendor/package.json @@ -27,13 +27,14 @@ "@tanstack/react-query": "^5.62.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.1.0", + "dayjs": "^1.11.13", "lucide-react": "^0.468.0", "next-themes": "^0.4.3", "react": "^18.3.1", "react-day-picker": "8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.1", + "recharts": "^2.15.0", "sonner": "^1.7.0", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", diff --git a/apps/vendor/src/app/router/ui/app-router.tsx b/apps/vendor/src/app/router/ui/app-router.tsx index d47bd846..3557acef 100644 --- a/apps/vendor/src/app/router/ui/app-router.tsx +++ b/apps/vendor/src/app/router/ui/app-router.tsx @@ -2,21 +2,23 @@ import { Redirect, Route, Switch } from 'wouter' import { ProtectedRoute } from './protected-route' import { RegisterPageAsync } from '@/pages/register' import { LoginPageAsync } from '@/pages/login' -import { PropsWithChildren, Suspense } from 'react' -import { AppSidebarAsync } from '@/widgets/app-sidebar' +import { Suspense } from 'react' import { OrdersPageAsync } from '@/pages/orders' +import { Shell } from '@/widgets/shell' +import { FallbackLoader } from '@/widgets/fallback-loader' +import { OrderDetailsPageAsync } from '@/pages/order-details' export const AppRouter = () => { return ( {/* Public routes */} - Loading...}> + }> - Loading...}> + }> @@ -25,10 +27,15 @@ export const AppRouter = () => { - - Loading...}> + + }> + + }> + + + 404, Not Found! @@ -45,16 +52,3 @@ export const AppRouter = () => { ) } - -const Shell = ({ children }: PropsWithChildren) => { - return ( -
- Loading...
}> - - -
- {children} -
- - ) -} diff --git a/apps/vendor/src/app/router/ui/protected-route.tsx b/apps/vendor/src/app/router/ui/protected-route.tsx index b9bfba1e..0614e649 100644 --- a/apps/vendor/src/app/router/ui/protected-route.tsx +++ b/apps/vendor/src/app/router/ui/protected-route.tsx @@ -1,4 +1,5 @@ -import { useSeller } from '@/entities/seller' +import { useSeller } from '@/shared/hooks/api' +import { FallbackLoader } from '@/widgets/fallback-loader' import { PropsWithChildren } from 'react' import { Redirect, Route, RouteProps } from 'wouter' @@ -9,7 +10,7 @@ export const ProtectedRoute = ({ const { isAuthenticated, isLoading } = useAuth() if (isLoading) { - return
Loading...
+ return } if (!isAuthenticated) { diff --git a/apps/vendor/src/entities/auth/index.ts b/apps/vendor/src/entities/auth/index.ts deleted file mode 100644 index 116e6686..00000000 --- a/apps/vendor/src/entities/auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './model' diff --git a/apps/vendor/src/entities/invite/index.ts b/apps/vendor/src/entities/invite/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/vendor/src/entities/order/index.ts b/apps/vendor/src/entities/order/index.ts index ed584959..573fab57 100644 --- a/apps/vendor/src/entities/order/index.ts +++ b/apps/vendor/src/entities/order/index.ts @@ -1 +1 @@ -export * from './ui' +export { OrderStatusBadge } from './ui/order-status-badge' diff --git a/apps/vendor/src/entities/order/model.ts b/apps/vendor/src/entities/order/model.ts deleted file mode 100644 index 66fe5b0b..00000000 --- a/apps/vendor/src/entities/order/model.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { queryKeysFactory } from '@/shared/lib' -import { useQuery } from '@tanstack/react-query' - -const ORDER_QUERY_KEY = 'order' -export const orderQueryKeys = queryKeysFactory(ORDER_QUERY_KEY) - -export const useOrders = () => { - const { data, ...other } = useQuery({ - queryKey: orderQueryKeys.details(), - queryFn: () => vendorGetOrd().then((res) => res.data), - retry: false - }) - - return { ...data, ...other } -} diff --git a/apps/vendor/src/entities/order/ui/index.ts b/apps/vendor/src/entities/order/ui/index.ts deleted file mode 100644 index ab883a21..00000000 --- a/apps/vendor/src/entities/order/ui/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './order-table' diff --git a/apps/vendor/src/entities/order/ui/order-status-badge.tsx b/apps/vendor/src/entities/order/ui/order-status-badge.tsx new file mode 100644 index 00000000..84fbc16e --- /dev/null +++ b/apps/vendor/src/entities/order/ui/order-status-badge.tsx @@ -0,0 +1,18 @@ +import { Badge } from '@/shared/ui/badge' + +type OrderStatusBadgeProps = { + paymentStatus?: string +} + +export const OrderStatusBadge = ({ paymentStatus }: OrderStatusBadgeProps) => { + switch (paymentStatus) { + case 'captured': + return Paid + case 'canceled': + return Cancelled + case 'refunded': + return Refunded + default: + return Pending + } +} diff --git a/apps/vendor/src/entities/order/ui/order-table.tsx b/apps/vendor/src/entities/order/ui/order-table.tsx deleted file mode 100644 index eb736bcd..00000000 --- a/apps/vendor/src/entities/order/ui/order-table.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from '@/shared/ui' - -const orders = [ - { - display_id: 123, - customer: 'John Doe', - products: ['Product 1', 'Product 2', 'Product 3'], - status: 'active', - date: new Date(), - revenue: 250 - }, - { - display_id: 123, - customer: 'John Doe', - products: ['Product 1', 'Product 2', 'Product 3'], - status: 'active', - date: new Date(), - revenue: 250 - }, - { - display_id: 123, - customer: 'John Doe', - products: ['Product 1', 'Product 2', 'Product 3'], - status: 'active', - date: new Date(), - revenue: 250 - }, - { - display_id: 123, - customer: 'John Doe', - products: ['Product 1', 'Product 2', 'Product 3'], - status: 'active', - date: new Date(), - revenue: 250 - } -] - -export const OrderTable = () => { - return ( - - - - # - Customer - Products - Status - Date - Revenue - - - - {orders.map((order) => ( - - {order.display_id} - {order.customer} - {order.products.join(', ')} - {order.status} - {order.date.toLocaleDateString()} - {order.revenue} - - ))} - -
- ) -} diff --git a/apps/vendor/src/entities/seller/index.ts b/apps/vendor/src/entities/seller/index.ts deleted file mode 100644 index 116e6686..00000000 --- a/apps/vendor/src/entities/seller/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './model' diff --git a/apps/vendor/src/features/seller-actions-menu/index.ts b/apps/vendor/src/features/seller-actions-menu/index.ts new file mode 100644 index 00000000..c5901829 --- /dev/null +++ b/apps/vendor/src/features/seller-actions-menu/index.ts @@ -0,0 +1 @@ +export { SellerActionsMenu } from './ui/seller-actions-menu' diff --git a/apps/vendor/src/features/seller-actions-menu/ui/seller-actions-menu.tsx b/apps/vendor/src/features/seller-actions-menu/ui/seller-actions-menu.tsx new file mode 100644 index 00000000..415e40ad --- /dev/null +++ b/apps/vendor/src/features/seller-actions-menu/ui/seller-actions-menu.tsx @@ -0,0 +1,75 @@ +import { + Avatar, + AvatarFallback, + DropdownMenuItem, + Typography +} from '@/shared/ui' + +import { LogOut, EllipsisVertical } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem +} from '@/shared/ui' +import { useLogout } from '@/shared/hooks/api' +import { navigate } from 'wouter/use-browser-location' + +export const SellerActionsMenu = () => { + const { mutateAsync: logout } = useLogout() + const onLogout = async () => { + await logout() + navigate('/login') + } + + return ( + + + + + +
+ + S + +
+
+ + Seller + + + Mercur + +
+ +
+
+ + +
+ +
+ + Logout + +
+
+
+
+
+ ) +} diff --git a/apps/vendor/src/index.css b/apps/vendor/src/index.css index 89b55f84..3de8e7d6 100644 --- a/apps/vendor/src/index.css +++ b/apps/vendor/src/index.css @@ -11,6 +11,7 @@ --popover-foreground: 240 10% 3.9%; --primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%; + --brand: 246 84% 51%; --secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; --muted: 240 4.8% 95.9%; @@ -22,12 +23,15 @@ --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 10% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; + --chart-1: 211 92% 53%; + --chart-2: 39 96% 50%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; - --radius: 0.5rem + --radius: 0.5rem; + + --success: 93 85% 36%; + --success-foreground: 0 0% 98%; } .dark { --background: 240 10% 3.9%; @@ -53,7 +57,7 @@ --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; - --chart-5: 340 75% 55% + --chart-5: 340 75% 55%; } } @layer base { diff --git a/apps/vendor/src/pages/login/ui.tsx b/apps/vendor/src/pages/login/ui.tsx index 97dd8609..42bda2d5 100644 --- a/apps/vendor/src/pages/login/ui.tsx +++ b/apps/vendor/src/pages/login/ui.tsx @@ -4,7 +4,7 @@ import { Form, FormField, FormItem, - Text, + Typography, FormControl, FormMessage, Button @@ -12,7 +12,7 @@ import { import { useForm } from 'react-hook-form' import * as z from 'zod' import { zodResolver } from '@hookform/resolvers/zod' -import { useCreateSession, useEmailpassLogin } from '@/entities/auth' +import { useCreateSession, useEmailpassLogin } from '@/shared/hooks/api' import { Link, useLocation } from 'wouter' import { toast } from 'sonner' @@ -73,12 +73,12 @@ const LoginPage = () => {
- + Sign in - - + + Welcome back! Please enter your details. - +
@@ -121,12 +121,12 @@ const LoginPage = () => {
- + Don't have an account?{' '} Create new one - +
diff --git a/apps/vendor/src/pages/order-details/index.ts b/apps/vendor/src/pages/order-details/index.ts new file mode 100644 index 00000000..753dba41 --- /dev/null +++ b/apps/vendor/src/pages/order-details/index.ts @@ -0,0 +1 @@ +export * from './order-details.async' diff --git a/apps/vendor/src/pages/order-details/order-details.async.tsx b/apps/vendor/src/pages/order-details/order-details.async.tsx new file mode 100644 index 00000000..01be9f3e --- /dev/null +++ b/apps/vendor/src/pages/order-details/order-details.async.tsx @@ -0,0 +1,3 @@ +import { lazy } from 'react' + +export const OrderDetailsPageAsync = lazy(() => import('./order-details')) diff --git a/apps/vendor/src/pages/order-details/order-details.tsx b/apps/vendor/src/pages/order-details/order-details.tsx new file mode 100644 index 00000000..9c40efbe --- /dev/null +++ b/apps/vendor/src/pages/order-details/order-details.tsx @@ -0,0 +1,32 @@ +import { useOrder } from '@/shared/hooks/api' +import { useToggleState } from '@/shared/hooks' +import { ActionMenu } from '@/shared/ui' +import { useParams } from 'wouter' +import { navigate } from 'wouter/use-browser-location' +import { OrderDetailsModal } from './ui/order-details-modal' + +const OrderDetailsPage = () => { + const { toggle, state } = useToggleState(true) + const { id } = useParams() + const { order, isLoading } = useOrder(id!) + + const onOpenChange = (open: boolean) => { + if (!open) { + navigate('/dashboard/orders') + } + + toggle() + } + + return ( + } + /> + ) +} + +export default OrderDetailsPage diff --git a/apps/vendor/src/pages/order-details/ui/order-details-modal.tsx b/apps/vendor/src/pages/order-details/ui/order-details-modal.tsx new file mode 100644 index 00000000..60a33c37 --- /dev/null +++ b/apps/vendor/src/pages/order-details/ui/order-details-modal.tsx @@ -0,0 +1,303 @@ +import { + Badge, + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Separator, + Skeleton, + Thumbnail, + Typography +} from '@/shared/ui' +import { VendorOrderDetails } from '@mercurjs/http-client/types' +import { ChevronLeft, Send, Check } from 'lucide-react' +import { ReactNode, useMemo } from 'react' +import { formatAddress } from '@/shared/lib/address' +import { formatDate, formatMoney } from '@/shared/lib' +import { OrderStatusBadge } from '@/entities/order' + +type OrderDetailsModalProps = { + open: boolean + onOpenChange: (open: boolean) => void + order?: VendorOrderDetails + isLoading?: boolean + actions?: ReactNode +} + +export const OrderDetailsModal = ({ + open, + onOpenChange, + order, + isLoading, + actions +}: OrderDetailsModalProps) => { + const dialogContent = useMemo(() => { + if (isLoading) { + return ( + <> + +
+ + +
+
+
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+
+ + + + + ) + } + + if (order) { + return ( + <> + +
+
+ + Order #{order.display_id} + +
+ {actions} +
+
+ +
+ + + + + + + + + +
+ + + + + + + + ) + } + }, [actions, isLoading, onOpenChange, order]) + + if (!order && !isLoading) { + throw new Error('No order found') + } + + return ( + + {dialogContent} + + ) +} + +export const OrderDetailsHeader = ({ + order +}: { + order: VendorOrderDetails +}) => { + return ( +
+
+ + {order.shipping_address?.first_name}{' '} + {order.shipping_address?.last_name} + + + Customer + +
+ {order.email} +
+ ) +} + +export const OrderDetailsTotals = ({ + order +}: { + order: VendorOrderDetails +}) => { + return ( +
+
+ Date + {formatDate(order.created_at!)} +
+
+ Comission + + {/* TODO: fix comission rate */} + {formatMoney(order.total! * 0.1, order.currency_code!)} + +
+
+ Total + + {formatMoney(order.total!, order.currency_code!)} + +
+
+ ) +} + +export const OrderDetailsAddress = ({ + order +}: { + order: VendorOrderDetails +}) => { + return ( +
+ + Address + +
+
+ Contact + + {order.shipping_address?.first_name}{' '} + {order.shipping_address?.last_name} {order.shipping_address?.phone} + +
+
+ Shipping + + {formatAddress(order.shipping_address!)} + +
+
+ Billing + + {order.billing_address + ? formatAddress(order.billing_address!) + : 'Same as shipping'} + +
+
+
+ ) +} + +export const OrderDetailsProducts = ({ + order +}: { + order: VendorOrderDetails +}) => { + return ( +
+ + Products + +
+ {order.items?.map((item) => ( +
+ +
+ {item.product_title} +
+ {item.title} + + Quantity: {item.quantity} + + + {formatMoney(item.unit_price!, order.currency_code!)} + +
+
+
+ ))} +
+
+ ) +} + +export const OrderDetailsTimeLine = ({ + order +}: { + order: VendorOrderDetails +}) => { + const events = useMemo(() => { + const baseEvents = [ + { + title: 'Order created', + date: order.created_at, + details: order.email, + icon: + } + ] + + if (order.payment_status === 'captured') { + baseEvents.push({ + title: 'Order paid', + date: order.created_at, + details: order.email, + icon: ( +
+ +
+ ) + }) + } + + return baseEvents + }, [order]) + + return ( +
+ + Timeline + +
+ {events?.reverse().map((event, i) => ( +
+ {event.icon} +
+
+ {event.title} + + {formatDate(event.date!)} + +
+ + {event.details} + +
+
+ ))} +
+
+ ) +} diff --git a/apps/vendor/src/pages/orders/const.ts b/apps/vendor/src/pages/orders/const.ts new file mode 100644 index 00000000..00bbd6c5 --- /dev/null +++ b/apps/vendor/src/pages/orders/const.ts @@ -0,0 +1,11 @@ +import dayjs from 'dayjs' + +import { CreatedAtOption } from '@/features/order-table-filters' + +export const createdAtOptionToDate: Record = { + today: dayjs().startOf('day').toISOString(), + last_24_hours: dayjs().subtract(24, 'hour').toISOString(), + last_48_hours: dayjs().subtract(48, 'hour').toISOString(), + last_72_hours: dayjs().subtract(72, 'hour').toISOString(), + last_month: dayjs().subtract(1, 'month').toISOString() +} diff --git a/apps/vendor/src/pages/orders/index.ts b/apps/vendor/src/pages/orders/index.ts index fab0122c..748a665f 100644 --- a/apps/vendor/src/pages/orders/index.ts +++ b/apps/vendor/src/pages/orders/index.ts @@ -1 +1 @@ -export * from './ui.async' +export * from './ui/orders.async' diff --git a/apps/vendor/src/pages/orders/lib/use-orders-filters.ts b/apps/vendor/src/pages/orders/lib/use-orders-filters.ts new file mode 100644 index 00000000..1a5fc91d --- /dev/null +++ b/apps/vendor/src/pages/orders/lib/use-orders-filters.ts @@ -0,0 +1,52 @@ +import { CreatedAtOption } from '../ui/order-table-filters' +import { useSearchState } from '@/shared/hooks' +import { DateRange } from 'react-day-picker' +import dayjs from 'dayjs' + +const DEFAULT_DATE_RANGE = { + from: dayjs().subtract(1, 'month').toDate(), + to: dayjs().toDate() +} + +export const useOrdersFilters = () => { + const [createdAt, setCreatedAt] = useSearchState({ + name: 'createdAt' + }) + + const [page, setPage] = useSearchState({ + name: 'page', + deserialize: (value) => { + if (!value) return null + + const page = parseInt(value) + + return page + }, + withPageParam: true + }) + + const [dateRange, setDateRange] = useSearchState({ + name: 'dateRange', + serialize: (value) => + value?.from && value?.to + ? `${value.from.toISOString()},${value.to.toISOString()}` + : '', + deserialize: (value) => { + if (!value) return DEFAULT_DATE_RANGE + const [from, to] = value.split(',') + return { + from: new Date(from), + to: new Date(to) + } + } + }) + + return { + createdAt, + setCreatedAt, + page, + setPage, + dateRange, + setDateRange + } +} diff --git a/apps/vendor/src/pages/orders/ui.async.ts b/apps/vendor/src/pages/orders/ui.async.ts deleted file mode 100644 index a94f2649..00000000 --- a/apps/vendor/src/pages/orders/ui.async.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { lazy } from 'react' - -export const OrdersPageAsync = lazy(() => import('./ui')) diff --git a/apps/vendor/src/pages/orders/ui.tsx b/apps/vendor/src/pages/orders/ui.tsx deleted file mode 100644 index 2030a655..00000000 --- a/apps/vendor/src/pages/orders/ui.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { OrderTable } from '@/entities/order' -import { Text } from '@/shared/ui' - -export default function OrdersPage() { - return ( - <> - - Orders - - - - ) -} diff --git a/apps/vendor/src/pages/orders/ui/order-analytics-filter.tsx b/apps/vendor/src/pages/orders/ui/order-analytics-filter.tsx new file mode 100644 index 00000000..e2291676 --- /dev/null +++ b/apps/vendor/src/pages/orders/ui/order-analytics-filter.tsx @@ -0,0 +1,14 @@ +import { DatePickerWithRange } from '@/shared/ui' +import { DateRange } from 'react-day-picker' + +type OrderAnalyticsFilterProps = { + dateRange?: DateRange + onDateRangeChange: (dateRange?: DateRange) => void +} + +export const OrderAnalyticsFilter = ({ + dateRange, + onDateRangeChange +}: OrderAnalyticsFilterProps) => { + return +} diff --git a/apps/vendor/src/pages/orders/ui/order-anaylytics-chart.tsx b/apps/vendor/src/pages/orders/ui/order-anaylytics-chart.tsx new file mode 100644 index 00000000..37896cda --- /dev/null +++ b/apps/vendor/src/pages/orders/ui/order-anaylytics-chart.tsx @@ -0,0 +1,95 @@ +import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts' + +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent +} from '@/shared/ui' + +const chartData = [ + { month: 'January', desktop: 186, mobile: 80 }, + { month: 'February', desktop: 305, mobile: 200 }, + { month: 'March', desktop: 237, mobile: 120 }, + { month: 'April', desktop: 73, mobile: 190 }, + { month: 'May', desktop: 209, mobile: 130 }, + { month: 'June', desktop: 214, mobile: 140 } +] + +const chartConfig = { + desktop: { + label: 'Desktop', + color: 'hsl(var(--chart-1))' + }, + mobile: { + label: 'Mobile', + color: 'hsl(var(--chart-2))' + } +} satisfies ChartConfig + +export const OrderAnalyticsChart = () => { + return ( + + + + value.slice(0, 3)} + /> + } /> + + + + + + + + + + + + + + + ) +} diff --git a/apps/vendor/src/pages/orders/ui/order-metrics.tsx b/apps/vendor/src/pages/orders/ui/order-metrics.tsx new file mode 100644 index 00000000..aa56a2d7 --- /dev/null +++ b/apps/vendor/src/pages/orders/ui/order-metrics.tsx @@ -0,0 +1,46 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, + Typography +} from '@/shared/ui' + +export const OrderMetrics = () => { + return ( +
+ + + Total orders + + + + 2,560 + + + + + + Total revenue + + + + $2,560 + + + + + + + Average order value + + + + + $2,560 + + + +
+ ) +} diff --git a/apps/vendor/src/pages/orders/ui/order-table-filters.tsx b/apps/vendor/src/pages/orders/ui/order-table-filters.tsx new file mode 100644 index 00000000..b45724ca --- /dev/null +++ b/apps/vendor/src/pages/orders/ui/order-table-filters.tsx @@ -0,0 +1,37 @@ +import { TableFilter } from '@/shared/ui/table-filter' + +export type CreatedAtOption = + | 'today' + | 'last_24_hours' + | 'last_48_hours' + | 'last_72_hours' + | 'last_month' + +const createdAtOptions: { value: CreatedAtOption; label: string }[] = [ + { value: 'today', label: 'Today' }, + { value: 'last_24_hours', label: 'Last 24 hours' }, + { value: 'last_48_hours', label: 'Last 48 hours' }, + { value: 'last_72_hours', label: 'Last 72 hours' }, + { value: 'last_month', label: 'Last month' } +] + +type OrderTableFiltersProps = { + createdAt: CreatedAtOption | null + onCreatedAtChange: (createdAt: CreatedAtOption | null) => void +} + +export const OrderTableFilters = ({ + onCreatedAtChange, + createdAt +}: OrderTableFiltersProps) => { + return ( +
+ onCreatedAtChange(value as CreatedAtOption)} + /> +
+ ) +} diff --git a/apps/vendor/src/pages/orders/ui/orders-page.tsx b/apps/vendor/src/pages/orders/ui/orders-page.tsx new file mode 100644 index 00000000..f75879f3 --- /dev/null +++ b/apps/vendor/src/pages/orders/ui/orders-page.tsx @@ -0,0 +1,65 @@ +import { Paginator, Typography } from '@/shared/ui' + +import { createdAtOptionToDate } from '../const' +import { keepPreviousData } from '@tanstack/react-query' +import { useOrdersFilters } from '../lib/use-orders-filters' +import { OrderAnalyticsFilter } from './order-analytics-filter' +import { OrderAnalyticsChart } from './order-anaylytics-chart' +import { OrderMetrics } from './order-metrics' +import { OrderTableFilters } from './order-table-filters' +import { OrdersTable } from './orders-table' +import { useOrders } from '@/shared/hooks/api' + +const PAGE_SIZE = 50 + +export default function OrdersPage() { + const { createdAt, setCreatedAt, page, setPage, dateRange, setDateRange } = + useOrdersFilters() + + const currentPage = page ?? 1 + const offset = (currentPage - 1) * PAGE_SIZE + + const { orders, count } = useOrders( + { + order: '-created_at', + limit: PAGE_SIZE, + // @ts-expect-error: you can not provide object as a value as a query param + 'created_at[$gte]': createdAt + ? createdAtOptionToDate[createdAt] + : undefined, + offset, + fields: + 'id,status,created_at,canceled_at,email,display_id,currency_code,total,item_total,shipping_subtotal,subtotal,discount_total,discount_subtotal,shipping_total,shipping_tax_total,tax_total,refundable_total,order_change,*customer,*items,*items.variant,*items.variant.product,*items.variant.options,+items.variant.manage_inventory,*items.variant.inventory_items.inventory,+items.variant.inventory_items.required_quantity,+summary,*shipping_address,*billing_address,*sales_channel,*promotion,*shipping_methods,*fulfillments,*fulfillments.items,*fulfillments.labels,*fulfillments.labels,*payment_collections,*payment_collections.payments,*payment_collections.payments.refunds,*payment_collections.payments.refunds.refund_reason,region.automatic_taxes,*customer' + }, + { + placeholderData: keepPreviousData + } + ) + + return ( + <> +
+ + Orders + + setDateRange(value ?? null)} + /> +
+ + + + {orders && } + (page > 1 ? setPage(page) : setPage(null))} + showPreviousNext + /> + + ) +} diff --git a/apps/vendor/src/pages/orders/ui/orders-table.tsx b/apps/vendor/src/pages/orders/ui/orders-table.tsx new file mode 100644 index 00000000..72033f76 --- /dev/null +++ b/apps/vendor/src/pages/orders/ui/orders-table.tsx @@ -0,0 +1,115 @@ +import { formatMoney } from '@/shared/lib' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/shared/ui' +import { VendorOrderDetails } from '@mercurjs/http-client/types' +import { OrderStatusBadge } from '@/entities/order' +import { CustomerAvatar } from '@/shared/ui' +import { ActionMenu, Typography } from '@/shared/ui' +import dayjs from 'dayjs' +import { navigate } from 'wouter/use-browser-location' + +type OrderTableProps = { + orders: VendorOrderDetails[] +} + +export const OrdersTable = ({ orders }: OrderTableProps) => { + return ( + + + + + + # + + + + + Customer + + + + + Products + + + + + Payment Status + + + + + Date + + + + + Total + + + + + + + {orders.length === 0 && ( + + + + No orders to show + + + + )} + {orders.map((order) => ( + { + navigate(`/dashboard/orders/${order.id}`) + }} + > + + + #{order.display_id} + + + + + + + + {order.items?.map((item) => item.product_title).join(', ')} + + + + + + + + {dayjs(order.created_at!).format('MMM DD, YYYY hh:mm a')} + + + + + {formatMoney(order.total!, order.currency_code!)} + + + + + + + ))} + +
+ ) +} diff --git a/apps/vendor/src/pages/orders/ui/orders.async.ts b/apps/vendor/src/pages/orders/ui/orders.async.ts new file mode 100644 index 00000000..8e35bd09 --- /dev/null +++ b/apps/vendor/src/pages/orders/ui/orders.async.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react' + +export const OrdersPageAsync = lazy(() => import('./orders-page')) diff --git a/apps/vendor/src/pages/register/ui.tsx b/apps/vendor/src/pages/register/ui.tsx index 00286f64..66d75d5a 100644 --- a/apps/vendor/src/pages/register/ui.tsx +++ b/apps/vendor/src/pages/register/ui.tsx @@ -4,7 +4,7 @@ import { Form, FormField, FormItem, - Text, + Typography, FormControl, FormMessage, Button @@ -74,12 +74,12 @@ const RegisterPage = () => {
- + Create account - - + + To continue, please enter your details below. - +
@@ -143,7 +143,7 @@ const RegisterPage = () => { Sign up
- + Already have an account?{' '} { > Login - +
diff --git a/apps/vendor/src/entities/auth/model.ts b/apps/vendor/src/shared/hooks/api/auth.ts similarity index 88% rename from apps/vendor/src/entities/auth/model.ts rename to apps/vendor/src/shared/hooks/api/auth.ts index 97b42077..86a44e5c 100644 --- a/apps/vendor/src/entities/auth/model.ts +++ b/apps/vendor/src/shared/hooks/api/auth.ts @@ -1,7 +1,8 @@ import { postSellerTypeAuthProviderRegister, postSellerTypeAuthProvider, - storePostSession + storePostSession, + storeDeleteSession } from '@mercurjs/http-client' import { AuthResponse, @@ -11,7 +12,7 @@ import { import { FetchError } from '@mercurjs/http-client/client' import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { queryClient } from '@/shared/lib' -import { sellerQueryKeys } from '../seller' +import { sellerQueryKeys } from './seller' export const useEmailpassRegister = ( options?: UseMutationOptions< @@ -64,3 +65,9 @@ export const useCreateSession = ( ...options }) } + +export const useLogout = () => { + return useMutation({ + mutationFn: () => storeDeleteSession().then(() => undefined) + }) +} diff --git a/apps/vendor/src/shared/hooks/api/index.ts b/apps/vendor/src/shared/hooks/api/index.ts new file mode 100644 index 00000000..e9becb94 --- /dev/null +++ b/apps/vendor/src/shared/hooks/api/index.ts @@ -0,0 +1,4 @@ +export * from './order' +export * from './auth' +export * from './invite' +export * from './seller' diff --git a/apps/vendor/src/entities/invite/model.ts b/apps/vendor/src/shared/hooks/api/invite.ts similarity index 100% rename from apps/vendor/src/entities/invite/model.ts rename to apps/vendor/src/shared/hooks/api/invite.ts diff --git a/apps/vendor/src/shared/hooks/api/order.ts b/apps/vendor/src/shared/hooks/api/order.ts new file mode 100644 index 00000000..03309b30 --- /dev/null +++ b/apps/vendor/src/shared/hooks/api/order.ts @@ -0,0 +1,49 @@ +import { queryKeysFactory } from '@/shared/lib' +import { vendorGetOrder, vendorListOrders } from '@mercurjs/http-client' +import { FetchError } from '@mercurjs/http-client/client' +import { + VendorListOrdersParams, + VendorListOrders200, + VendorGetOrder200 +} from '@mercurjs/http-client/types' +import { QueryKey, useQuery, UseQueryOptions } from '@tanstack/react-query' + +const ORDER_QUERY_KEY = 'order' +export const orderQueryKeys = queryKeysFactory(ORDER_QUERY_KEY) + +export const useOrders = ( + query?: VendorListOrdersParams, + options?: Omit< + UseQueryOptions< + VendorListOrdersParams, + FetchError, + VendorListOrders200, + QueryKey + >, + 'queryFn' | 'queryKey' + > +) => { + const { data, ...other } = useQuery({ + queryKey: orderQueryKeys.list(query), + queryFn: () => vendorListOrders(query).then((res) => res.data), + ...options + }) + + return { ...data, ...other } +} + +export const useOrder = ( + id: string, + options?: Omit< + UseQueryOptions, + 'queryFn' | 'queryKey' + > +) => { + const { data, ...other } = useQuery({ + queryKey: orderQueryKeys.detail(id), + queryFn: () => vendorGetOrder(id).then((res) => res.data), + ...options + }) + + return { ...data, ...other } +} diff --git a/apps/vendor/src/entities/seller/model.ts b/apps/vendor/src/shared/hooks/api/seller.ts similarity index 100% rename from apps/vendor/src/entities/seller/model.ts rename to apps/vendor/src/shared/hooks/api/seller.ts diff --git a/apps/vendor/src/shared/hooks/index.ts b/apps/vendor/src/shared/hooks/index.ts index 2d08aac6..cafbd5f6 100644 --- a/apps/vendor/src/shared/hooks/index.ts +++ b/apps/vendor/src/shared/hooks/index.ts @@ -1 +1,3 @@ export * from './use-mobile' +export * from './use-search-state' +export * from './use-toggle-state' diff --git a/apps/vendor/src/shared/hooks/use-search-state.ts b/apps/vendor/src/shared/hooks/use-search-state.ts new file mode 100644 index 00000000..652d535e --- /dev/null +++ b/apps/vendor/src/shared/hooks/use-search-state.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' +import { useSearch } from 'wouter' + +type UseSearchStateProps = { + name: string + serialize?: (value: Value) => string + deserialize?: (value: string | null) => Value + withPageParam?: boolean +} + +export const useSearchState = ({ + name, + serialize = String, + deserialize = (value) => value as Value, + withPageParam = false +}: UseSearchStateProps) => { + const searchString = useSearch() + + const [value, setValue] = useState(() => { + const searchParams = new URLSearchParams(searchString) + return deserialize(searchParams.get(name)) + }) + + const updateValue = (value: Value) => { + setValue(value) + const searchParams = new URLSearchParams(searchString) + + if (!value) { + searchParams.delete(name) + } else { + searchParams.set(name, serialize(value)) + } + if (!withPageParam) { + searchParams.delete('page') + } + + const search = searchParams.toString() + history.pushState( + null, + '', + search ? `?${search}` : window.location.pathname + ) + } + + useEffect(() => { + const searchParams = new URLSearchParams(searchString) + const newValue = deserialize(searchParams.get(name)) + + setValue(newValue) + }, [deserialize, name, searchString]) + + return [value, updateValue] as const +} diff --git a/apps/vendor/src/shared/hooks/use-toggle-state.ts b/apps/vendor/src/shared/hooks/use-toggle-state.ts new file mode 100644 index 00000000..1273c485 --- /dev/null +++ b/apps/vendor/src/shared/hooks/use-toggle-state.ts @@ -0,0 +1,31 @@ +import { useState } from 'react' + +type StateType = [boolean, () => void, () => void, () => void] & { + state: boolean + open: () => void + close: () => void + toggle: () => void +} + +export const useToggleState = (initial = false) => { + const [state, setState] = useState(initial) + + const close = () => { + setState(false) + } + + const open = () => { + setState(true) + } + + const toggle = () => { + setState((state) => !state) + } + + const hookData = [state, open, close, toggle] as StateType + hookData.state = state + hookData.open = open + hookData.close = close + hookData.toggle = toggle + return hookData +} diff --git a/apps/vendor/src/shared/lib/address.ts b/apps/vendor/src/shared/lib/address.ts new file mode 100644 index 00000000..e26d0d02 --- /dev/null +++ b/apps/vendor/src/shared/lib/address.ts @@ -0,0 +1,5 @@ +import { VendorOrderAddress } from '@mercurjs/http-client/types' + +export const formatAddress = (address: VendorOrderAddress) => { + return `${address.address_1} ${address.address_2} ${address.city} ${address.province} ${address.postal_code} ${address.country_code}` +} diff --git a/apps/vendor/src/shared/lib/date.ts b/apps/vendor/src/shared/lib/date.ts new file mode 100644 index 00000000..94fd0121 --- /dev/null +++ b/apps/vendor/src/shared/lib/date.ts @@ -0,0 +1,5 @@ +import dayjs from 'dayjs' + +export const formatDate = (date: string) => { + return dayjs(date).format('MMM DD, YYYY') +} diff --git a/apps/vendor/src/shared/lib/index.ts b/apps/vendor/src/shared/lib/index.ts index c1f6ae8f..c7f43dc5 100644 --- a/apps/vendor/src/shared/lib/index.ts +++ b/apps/vendor/src/shared/lib/index.ts @@ -1,2 +1,5 @@ export * from './classnames' export * from './queries' +export * from './money' +export * from './date' +export * from './address' diff --git a/apps/vendor/src/shared/lib/money.ts b/apps/vendor/src/shared/lib/money.ts new file mode 100644 index 00000000..58af0af8 --- /dev/null +++ b/apps/vendor/src/shared/lib/money.ts @@ -0,0 +1,6 @@ +export const formatMoney = (amount: number, currency: string) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency + }).format(amount) +} diff --git a/apps/vendor/src/shared/ui/action-menu.tsx b/apps/vendor/src/shared/ui/action-menu.tsx new file mode 100644 index 00000000..573f5b52 --- /dev/null +++ b/apps/vendor/src/shared/ui/action-menu.tsx @@ -0,0 +1,105 @@ +import { PropsWithChildren, ReactNode } from 'react' +import { Link } from 'react-router-dom' +import { Button } from './button' +import { Ellipsis } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from './dropdown-menu' +import { cn } from '../lib' + +export type Action = { + icon: ReactNode + label: string + disabled?: boolean +} & ( + | { + to: string + onClick?: never + } + | { + onClick: () => void + to?: never + } +) + +export type ActionGroup = { + actions: Action[] +} + +type ActionMenuProps = PropsWithChildren<{ + groups: ActionGroup[] +}> + +export const ActionMenu = ({ groups, children }: ActionMenuProps) => { + const inner = children ?? ( + + ) + + return ( + + {inner} + + {groups.map((group, index) => { + if (!group.actions.length) { + return null + } + + const isLast = index === groups.length - 1 + + return ( + + {group.actions.map((action) => { + if (action.onClick) { + return ( + { + e.stopPropagation() + action.onClick() + }} + className={cn( + '[&_svg]:text-ui-fg-subtle flex items-center gap-x-2', + { + '[&_svg]:text-ui-fg-disabled': action.disabled + } + )} + > + {action.icon} + {action.label} + + ) + } + + return ( + + e.stopPropagation()}> + {action.icon} + {action.label} + + + ) + })} + {!isLast && } + + ) + })} + + + ) +} diff --git a/apps/vendor/src/shared/ui/badge.tsx b/apps/vendor/src/shared/ui/badge.tsx index 8c955aab..ad5e5ea2 100644 --- a/apps/vendor/src/shared/ui/badge.tsx +++ b/apps/vendor/src/shared/ui/badge.tsx @@ -4,12 +4,13 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/shared/lib' const badgeVariants = cva( - 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'inline-flex items-center rounded-lg border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { variants: { variant: { default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + lime: 'border-lime-200 bg-lime-100 text-lime-700', secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', destructive: diff --git a/apps/vendor/src/shared/ui/button.tsx b/apps/vendor/src/shared/ui/button.tsx index 3fa0e86e..3cfdb624 100644 --- a/apps/vendor/src/shared/ui/button.tsx +++ b/apps/vendor/src/shared/ui/button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '../lib' const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { @@ -16,13 +16,14 @@ const buttonVariants = cva( secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', - link: 'text-primary underline-offset-4 hover:underline' + link: 'text-primary underline-offset-4 hover:underline', + transparent: 'bg-transparent' }, size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', - lg: 'h-11 rounded-md px-8', - icon: 'h-10 w-10' + default: 'h-8 px-4 py-2', + sm: 'h-8 px-3 py-2 ', + lg: 'h-10 px-8', + icon: 'h-7 w-7' } }, defaultVariants: { diff --git a/apps/vendor/src/shared/ui/chart.tsx b/apps/vendor/src/shared/ui/chart.tsx new file mode 100644 index 00000000..545e6bcf --- /dev/null +++ b/apps/vendor/src/shared/ui/chart.tsx @@ -0,0 +1,363 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/shared/lib" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +