feat(dashboard,medusa,ui): Manual gift cards + cleanup (#6380)

**Dashboard**
- Adds different views for managing manual/custom gift cards (not associated with a product gift card).
- Cleans up several table implementations to use new DataTable component.
- Minor cleanup of translation file.

**Medusa**
- Adds missing query params for list endpoints in the following admin domains: /customers, /customer-groups, /collections, and /gift-cards.

**UI**
- Adds new sizes for Badge component.

**Note for review**
Since this PR contains updates to the translation keys, it touches a lot of files. For the review the parts that are relevant are: the /gift-cards domain of admin, the table overview of collections, customers, and customer groups. And the changes to the list endpoints in the core.
This commit is contained in:
Kasper Fabricius Kristensen
2024-02-12 14:47:37 +01:00
committed by GitHub
parent bc2a63782b
commit d37ff8024d
138 changed files with 3230 additions and 1399 deletions

View File

@@ -3,16 +3,7 @@
"general": {
"ascending": "Ascending",
"descending": "Descending",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"create": "Create",
"delete": "Delete",
"invite": "Invite",
"edit": "Edit",
"confirm": "Confirm",
"add": "Add",
"continue": "Continue",
"start": "Start",
"end": "End",
"apply": "Apply",
@@ -30,10 +21,9 @@
"details": "Details",
"enabled": "Enabled",
"disabled": "Disabled",
"expired": "Expired",
"active": "Active",
"revoke": "Revoke",
"revoked": "Revoked",
"remove": "Remove",
"admin": "Admin",
"store": "Store",
"items_one": "{{count}} item",
@@ -50,6 +40,16 @@
"unsavedChangesTitle": "Are you sure you want to leave this page?",
"unsavedChangesDescription": "You have unsaved changes that will be lost if you leave this page."
},
"actions": {
"create": "Create",
"delete": "Delete",
"remove": "Remove",
"revoke": "Revoke",
"cancel": "Cancel",
"save": "Save",
"continue": "Continue",
"edit": "Edit"
},
"products": {
"domain": "Products",
"variants": "Variants",
@@ -69,8 +69,7 @@
"createCollectionHint": "Create a new collection to organize your products.",
"editCollection": "Edit Collection",
"handleTooltip": "The handle is used to reference the collection in your storefront. If not specified, the handle will be generated from the collection title.",
"deleteWarning_one": "You are about to delete {{count}} collection. This action cannot be undone.",
"deleteWarning_other": "You are about to delete {{count}} collections. This action cannot be undone.",
"deleteWarning": "You are about to delete the collection {{title}}. This action cannot be undone.",
"removeSingleProductWarning": "You are about to remove the product {{title}} from the collection. This action cannot be undone.",
"removeProductsWarning_one": "You are about to remove {{count}} product from the collection. This action cannot be undone.",
"removeProductsWarning_other": "You are about to remove {{count}} products from the collection. This action cannot be undone."
@@ -82,7 +81,22 @@
"domain": "Inventory"
},
"giftCards": {
"domain": "Gift Cards"
"domain": "Gift Cards",
"editGiftCard": "Edit Gift Card",
"createGiftCard": "Create Gift Card",
"createGiftCardHint": "Manually create a gift card that can be used as a payment method in your store.",
"selectRegionFirst": "Select a region first",
"deleteGiftCardWarning": "You are about to delete the gift card {{code}}. This action cannot be undone.",
"balanceHigherThanValue": "The balance cannot be higher than the original amount.",
"balanceLowerThanZero": "The balance cannot be negative.",
"expiryDateHint": "Countries have different laws regarding gift card expiry dates. Make sure to check local regulations before setting an expiry date.",
"regionHint": "Changing the region of the gift card will also change its currency, potentially affecting its monetary value.",
"enabledHint": "Specify if the gift card is enabled or disabled.",
"balance": "Balance",
"currentBalance": "Current balance",
"initialBalance": "Initial balance",
"personalMessage": "Personal message",
"recipient": "Recipient"
},
"customers": {
"domain": "Customers",
@@ -96,7 +110,8 @@
"guest": "Guest",
"registered": "Registered",
"firstSeen": "First seen",
"viewOrder": "View order"
"viewOrder": "View order",
"groups": "Groups"
},
"customerGroups": {
"domain": "Customer Groups",
@@ -174,7 +189,9 @@
"admin": "Admin",
"developer": "Developer",
"member": "Member"
}
},
"deleteUserWarning": "You are about to delete the user {{name}}. This action cannot be undone.",
"invite": "Invite"
},
"store": {
"domain": "Store",
@@ -337,6 +354,17 @@
"salesChannel": "Sales Channel",
"region": "Region",
"role": "Role",
"sent": "Sent"
"sent": "Sent",
"salesChannels": "Sales Channels",
"product": "Product",
"createdAt": "Created at",
"updatedAt": "Updated at",
"true": "True",
"false": "False",
"giftCard": "Gift Card",
"tag": "Tag",
"dateIssued": "Date issued",
"issuedDate": "Issued date",
"expiryDate": "Expiry date"
}
}

View File

@@ -28,7 +28,7 @@ export const JsonViewSection = ({ data, root }: JsonViewProps) => {
<Container className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-4">
<Heading level="h2">JSON</Heading>
<Badge>{numberOfKeys} keys</Badge>
<Badge size="2xsmall">{numberOfKeys} keys</Badge>
</div>
<Drawer>
<Drawer.Trigger asChild>
@@ -44,7 +44,7 @@ export const JsonViewSection = ({ data, root }: JsonViewProps) => {
<div className="bg-ui-code-bg-header border-ui-code-border flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-x-4">
<Heading>JSON</Heading>
<Badge>{numberOfKeys} keys</Badge>
<Badge size="2xsmall">{numberOfKeys} keys</Badge>
</div>
<div className="flex items-center gap-x-2">
<Kbd>esc</Kbd>

View File

@@ -18,7 +18,7 @@ export const LocalizedDatePicker = ({
?.date_locale
const translations = {
cancel: t("general.cancel"),
cancel: t("actions.cancel"),
apply: t("general.apply"),
end: t("general.end"),
start: t("general.start"),

View File

@@ -13,10 +13,10 @@ import { NoResults } from "../../../common/empty-table-content"
type BulkCommand = {
label: string
shortcut: string
action: (selection: Record<string, boolean>) => void
action: (selection: Record<string, boolean>) => Promise<void>
}
export interface DataTableRootProps<TData, TValue> {
export interface DataTableRootProps<TData> {
/**
* The table instance to render
*/
@@ -24,7 +24,7 @@ export interface DataTableRootProps<TData, TValue> {
/**
* The columns to render
*/
columns: ColumnDef<TData, TValue>[]
columns: ColumnDef<TData, any>[]
/**
* Function to generate a link to navigate to when clicking on a row
*/
@@ -61,7 +61,7 @@ export interface DataTableRootProps<TData, TValue> {
/**
* Table component for rendering a table with pagination, filtering and ordering.
*/
export const DataTableRoot = <TData, TValue>({
export const DataTableRoot = <TData,>({
table,
columns,
pagination,
@@ -69,7 +69,7 @@ export const DataTableRoot = <TData, TValue>({
commands,
count = 0,
noResults = false,
}: DataTableRootProps<TData, TValue>) => {
}: DataTableRootProps<TData>) => {
const { t } = useTranslation()
const navigate = useNavigate()
const [showStickyBorder, setShowStickyBorder] = useState(false)
@@ -94,6 +94,12 @@ export const DataTableRoot = <TData, TValue>({
}
}
const handleAction = async (action: BulkCommand["action"]) => {
await action(rowSelection).then(() => {
table.resetRowSelection()
})
}
return (
<div className="w-full">
<div onScroll={handleHorizontalScroll} className="w-full overflow-x-auto">
@@ -159,6 +165,7 @@ export const DataTableRoot = <TData, TValue>({
return (
<Table.Row
key={row.id}
data-selected={row.getIsSelected()}
className={clx(
"transition-fg group/row [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
"[&:has(td_a:focus-visible)_td]:bg-ui-bg-base-pressed",
@@ -188,7 +195,7 @@ export const DataTableRoot = <TData, TValue>({
<Table.Cell
key={cell.id}
className={clx("has-[a]:cursor-pointer", {
"bg-ui-bg-base group-[:has(td_a:focus)]/row:bg-ui-bg-base-pressed group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
"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-[:has(td_a:focus)]/row:bg-ui-bg-base-pressed group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isStickyCell,
"after:bg-ui-border-base":
showStickyBorder && isStickyCell,
@@ -239,7 +246,7 @@ export const DataTableRoot = <TData, TValue>({
<CommandBar.Command
label={command.label}
shortcut={command.shortcut}
action={() => command.action(rowSelection)}
action={() => handleAction(command.action)}
/>
{index < commands.length - 1 && <CommandBar.Seperator />}
</Fragment>

View File

@@ -4,8 +4,8 @@ import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
import { DataTableRoot, DataTableRootProps } from "./data-table-root"
import { DataTableSkeleton } from "./data-table-skeleton"
interface DataTableProps<TData, TValue>
extends DataTableRootProps<TData, TValue>,
interface DataTableProps<TData>
extends DataTableRootProps<TData>,
DataTableQueryProps {
isLoading?: boolean
rowCount: number
@@ -15,7 +15,7 @@ interface DataTableProps<TData, TValue>
const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot
const MemoizedDataTableQuery = memo(DataTableQuery)
export const DataTable = <TData, TValue>({
export const DataTable = <TData,>({
table,
columns,
pagination,
@@ -29,7 +29,7 @@ export const DataTable = <TData, TValue>({
queryObject = {},
rowCount,
isLoading = false,
}: DataTableProps<TData, TValue>) => {
}: DataTableProps<TData>) => {
if (isLoading) {
return (
<DataTableSkeleton

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
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 = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.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,45 @@
import { clx } from "@medusajs/ui"
import { getPresentationalAmount } from "../../../../../lib/money-amount-helpers"
import { PlaceholderCell } from "../placeholder-cell"
type MoneyAmountCellProps = {
currencyCode: string
amount?: number | null
align?: "left" | "right"
}
export const MoneyAmountCell = ({
currencyCode,
amount,
align = "left",
}: MoneyAmountCellProps) => {
if (!amount) {
return <PlaceholderCell />
}
const formatted = new Intl.NumberFormat(undefined, {
style: "currency",
currency: currencyCode,
currencyDisplay: "narrowSymbol",
}).format(0)
const symbol = formatted.replace(/\d/g, "").replace(/[.,]/g, "").trim()
const presentationAmount = getPresentationalAmount(amount, currencyCode)
const formattedTotal = new Intl.NumberFormat(undefined, {
style: "decimal",
}).format(presentationAmount)
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",
})}
>
<span className="truncate">
{symbol} {formattedTotal} {currencyCode.toUpperCase()}
</span>
</div>
)
}

View File

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

View File

@@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next"
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 = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.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,25 @@
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../common/status-cell"
type AccountCellProps = {
hasAccount: boolean
}
export const AccountCell = ({ hasAccount }: AccountCellProps) => {
const { t } = useTranslation()
const color = hasAccount ? "green" : ("orange" as const)
const text = hasAccount ? t("customers.registered") : t("customers.guest")
return <StatusCell color={color}>{text}</StatusCell>
}
export const AccountHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.account")}</span>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { useTranslation } from "react-i18next"
import { DateCell } from "../../common/date-cell"
type FirstSeenCellProps = {
createdAt: Date
}
export const FirstSeenCell = ({ createdAt }: FirstSeenCellProps) => {
return <DateCell date={createdAt} />
}
export const FirstSeenHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("customers.firstSeen")}</span>
</div>
)
}

View File

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

View File

@@ -1,6 +1,11 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
export const DisplayIdCell = ({ displayId }: { displayId?: number | null }) => {
if (!displayId) {
return <PlaceholderCell />
}
export const DisplayIdCell = ({ displayId }: { displayId: number }) => {
return (
<div className="text-ui-fg-subtle txt-compact-small flex h-full w-full items-center overflow-hidden">
<span className="truncate">#{displayId}</span>

View File

@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next"
import { getPresentationalAmount } from "../../../../../lib/money-amount-helpers"
import { MoneyAmountCell } from "../../common/money-amount-cell"
import { PlaceholderCell } from "../../common/placeholder-cell"
type TotalCellProps = {
currencyCode: string
@@ -8,28 +9,11 @@ type TotalCellProps = {
export const TotalCell = ({ currencyCode, total }: TotalCellProps) => {
if (!total) {
return <span className="text-ui-fg-muted">-</span>
return <PlaceholderCell />
}
const formatted = new Intl.NumberFormat(undefined, {
style: "currency",
currency: currencyCode,
currencyDisplay: "narrowSymbol",
}).format(0)
const symbol = formatted.replace(/\d/g, "").replace(/[.,]/g, "").trim()
const presentationAmount = getPresentationalAmount(total, currencyCode)
const formattedTotal = new Intl.NumberFormat(undefined, {
style: "decimal",
}).format(presentationAmount)
return (
<div className="flex h-full w-full items-center justify-end overflow-hidden">
<span className="truncate">
{symbol} {formattedTotal} {currencyCode.toUpperCase()}
</span>
</div>
<MoneyAmountCell currencyCode={currencyCode} amount={total} align="right" />
)
}

View File

@@ -0,0 +1,30 @@
import type { ProductCollection } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type CollectionCellProps = {
collection?: ProductCollection | null
}
export const CollectionCell = ({ collection }: CollectionCellProps) => {
if (!collection) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{collection.title}</span>
</div>
)
}
export const CollectionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.collection")}</span>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import type { Product } from "@medusajs/medusa"
import type { PricedProduct } from "@medusajs/medusa/dist/types/pricing"
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../../../../common/thumbnail"
type ProductCellProps = {
product: Product | PricedProduct
}
export const ProductCell = ({ product }: ProductCellProps) => {
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
<div className="w-fit flex-shrink-0">
<Thumbnail src={product.thumbnail} />
</div>
<span className="truncate">{product.title}</span>
</div>
)
}
export const ProductHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.product")}</span>
</div>
)
}

View File

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

View File

@@ -0,0 +1,31 @@
import { ProductStatus } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../common/status-cell"
type ProductStatusCellProps = {
status: ProductStatus
}
export const ProductStatusCell = ({ status }: ProductStatusCellProps) => {
const { t } = useTranslation()
const [color, text] = {
draft: ["grey", t("products.productStatus.draft")],
proposed: ["orange", t("products.productStatus.proposed")],
published: ["green", t("products.productStatus.published")],
rejected: ["red", t("products.productStatus.rejected")],
}[status] as ["grey" | "orange" | "green" | "red", string]
return <StatusCell color={color}>{text}</StatusCell>
}
export const ProductStatusHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.status")}</span>
</div>
)
}

View File

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

View File

@@ -0,0 +1,65 @@
import type { SalesChannel } from "@medusajs/medusa"
import { Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type SalesChannelsCellProps = {
salesChannels?: SalesChannel[] | null
}
export const SalesChannelsCell = ({
salesChannels,
}: SalesChannelsCellProps) => {
const { t } = useTranslation()
if (!salesChannels || !salesChannels.length) {
return <PlaceholderCell />
}
if (salesChannels.length > 2) {
return (
<div className="flex h-full w-full items-center gap-x-1 overflow-hidden">
<span className="truncate">
{salesChannels
.slice(0, 2)
.map((sc) => sc.name)
.join(", ")}
</span>
<Tooltip
content={
<ul>
{salesChannels.slice(2).map((sc) => (
<li key={sc.id}>{sc.name}</li>
))}
</ul>
}
>
<span className="text-xs">
{t("general.plusCountMore", {
count: salesChannels.length - 2,
})}
</span>
</Tooltip>
</div>
)
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">
{salesChannels.map((sc) => sc.name).join(", ")}
</span>
</div>
)
}
export const SalesChannelHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.salesChannels")}</span>
</div>
)
}

View File

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

View File

@@ -0,0 +1,34 @@
import { ProductVariant } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type VariantCellProps = {
variants?: ProductVariant[] | null
}
export const VariantCell = ({ variants }: VariantCellProps) => {
const { t } = useTranslation()
if (!variants || !variants.length) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">
{t("products.variantCount", { count: variants.length })}
</span>
</div>
)
}
export const VariantHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.variants")}</span>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { Customer } from "@medusajs/medusa"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import {
EmailCell,
EmailHeader,
} from "../../../components/table/table-cells/common/email-cell"
import {
NameCell,
NameHeader,
} from "../../../components/table/table-cells/common/name-cell"
import {
AccountCell,
AccountHeader,
} from "../../../components/table/table-cells/customer/account-cell/account-cell"
import {
FirstSeenCell,
FirstSeenHeader,
} from "../../../components/table/table-cells/customer/first-seen-cell"
const columnHelper = createColumnHelper<Customer>()
export const useCustomerTableColumns = () => {
return useMemo(
() => [
columnHelper.accessor("email", {
header: () => <EmailHeader />,
cell: ({ getValue }) => <EmailCell email={getValue()} />,
}),
columnHelper.display({
id: "name",
header: () => <NameHeader />,
cell: ({
row: {
original: { first_name, last_name },
},
}) => <NameCell firstName={first_name} lastName={last_name} />,
}),
columnHelper.accessor("has_account", {
header: () => <AccountHeader />,
cell: ({ getValue }) => <AccountCell hasAccount={getValue()} />,
}),
columnHelper.accessor("created_at", {
header: () => <FirstSeenHeader />,
cell: ({ getValue }) => <FirstSeenCell createdAt={getValue()} />,
}),
],
[]
)
}

View File

@@ -0,0 +1,59 @@
import { Product } from "@medusajs/medusa"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import {
CollectionCell,
CollectionHeader,
} from "../../../components/table/table-cells/product/collection-cell/collection-cell"
import {
ProductCell,
ProductHeader,
} from "../../../components/table/table-cells/product/product-cell"
import {
ProductStatusCell,
ProductStatusHeader,
} from "../../../components/table/table-cells/product/product-status-cell"
import {
SalesChannelHeader,
SalesChannelsCell,
} from "../../../components/table/table-cells/product/sales-channels-cell"
import {
VariantCell,
VariantHeader,
} from "../../../components/table/table-cells/product/variant-cell"
const columnHelper = createColumnHelper<Product>()
export const useProductTableColumns = () => {
return useMemo(
() => [
columnHelper.display({
id: "product",
header: () => <ProductHeader />,
cell: ({ row }) => <ProductCell product={row.original} />,
}),
columnHelper.accessor("collection", {
header: () => <CollectionHeader />,
cell: ({ row }) => (
<CollectionCell collection={row.original.collection} />
),
}),
columnHelper.accessor("sales_channels", {
header: () => <SalesChannelHeader />,
cell: ({ row }) => (
<SalesChannelsCell salesChannels={row.original.sales_channels} />
),
}),
columnHelper.accessor("variants", {
header: () => <VariantHeader />,
cell: ({ row }) => <VariantCell variants={row.original.variants} />,
}),
columnHelper.accessor("status", {
header: () => <ProductStatusHeader />,
cell: ({ row }) => <ProductStatusCell status={row.original.status} />,
}),
],
[]
) as ColumnDef<Product>[]
}

View File

@@ -0,0 +1,69 @@
import { useAdminCustomerGroups } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Filter } from "../../../components/table/data-table"
const excludeableFields = ["groups"] as const
export const useCustomerTableFilters = (
exclude?: (typeof excludeableFields)[number][]
) => {
const { t } = useTranslation()
const isGroupsExcluded = exclude?.includes("groups")
const { customer_groups } = useAdminCustomerGroups(
{
limit: 1000,
expand: "",
},
{
enabled: !isGroupsExcluded,
}
)
let filters: Filter[] = []
if (customer_groups && !isGroupsExcluded) {
const customerGroupFilter: Filter = {
key: "groups",
label: t("customers.groups"),
type: "select",
multiple: true,
options: customer_groups.map((s) => ({
label: s.name,
value: s.id,
})),
}
filters = [...filters, customerGroupFilter]
}
const hasAccountFilter: Filter = {
key: "has_account",
label: t("fields.account"),
type: "select",
options: [
{
label: t("customers.registered"),
value: "true",
},
{
label: t("customers.guest"),
value: "false",
},
],
}
const dateFilters: Filter[] = [
{ label: t("fields.createdAt"), key: "created_at" },
{ label: t("fields.updatedAt"), key: "updated_at" },
].map((f) => ({
key: f.key,
label: f.label,
type: "date",
}))
filters = [...filters, hasAccountFilter, ...dateFilters]
return filters
}

View File

@@ -0,0 +1,190 @@
import {
useAdminCollections,
useAdminProductCategories,
useAdminProductTags,
useAdminProductTypes,
useAdminSalesChannels,
} from "medusa-react"
import { useTranslation } from "react-i18next"
import { Filter } from "../../../components/table/data-table"
const excludeableFields = ["sales_channel_id", "collections"] as const
export const useProductTableFilters = (
exclude?: (typeof excludeableFields)[number][]
) => {
const { t } = useTranslation()
const { product_types } = useAdminProductTypes({
limit: 1000,
offset: 0,
})
const { product_tags } = useAdminProductTags({
limit: 1000,
offset: 0,
})
const isSalesChannelExcluded = exclude?.includes("sales_channel_id")
const { sales_channels } = useAdminSalesChannels(
{
limit: 1000,
fields: "id,name",
expand: "",
},
{
enabled: !isSalesChannelExcluded,
}
)
const { product_categories } = useAdminProductCategories({
limit: 1000,
offset: 0,
fields: "id,name",
expand: "",
})
const isCollectionExcluded = exclude?.includes("collections")
const { collections } = useAdminCollections(
{
limit: 1000,
offset: 0,
},
{
enabled: !isCollectionExcluded,
}
)
let filters: Filter[] = []
if (product_types) {
const typeFilter: Filter = {
key: "type_id",
label: t("fields.type"),
type: "select",
multiple: true,
options: product_types.map((t) => ({
label: t.value,
value: t.id,
})),
}
filters = [...filters, typeFilter]
}
if (product_tags) {
const tagFilter: Filter = {
key: "tags",
label: t("fields.tag"),
type: "select",
multiple: true,
options: product_tags.map((t) => ({
label: t.value,
value: t.id,
})),
}
filters = [...filters, tagFilter]
}
if (sales_channels) {
const salesChannelFilter: Filter = {
key: "sales_channel_id",
label: t("fields.salesChannel"),
type: "select",
multiple: true,
options: sales_channels.map((s) => ({
label: s.name,
value: s.id,
})),
}
filters = [...filters, salesChannelFilter]
}
if (product_categories) {
const categoryFilter: Filter = {
key: "category_id",
label: t("fields.category"),
type: "select",
multiple: true,
options: product_categories.map((c) => ({
label: c.name,
value: c.id,
})),
}
filters = [...filters, categoryFilter]
}
if (collections) {
const collectionFilter: Filter = {
key: "collection_id",
label: t("fields.collection"),
type: "select",
multiple: true,
options: collections.map((c) => ({
label: c.title,
value: c.id,
})),
}
filters = [...filters, collectionFilter]
}
const giftCardFilter: Filter = {
key: "is_giftcard",
label: t("fields.giftCard"),
type: "select",
options: [
{
label: t("fields.true"),
value: "true",
},
{
label: t("fields.false"),
value: "false",
},
],
}
const statusFilter: Filter = {
key: "status",
label: t("fields.status"),
type: "select",
multiple: true,
options: [
{
label: t("products.productStatus.draft"),
value: "draft",
},
{
label: t("products.productStatus.proposed"),
value: "proposed",
},
{
label: t("products.productStatus.published"),
value: "published",
},
{
label: t("products.productStatus.rejected"),
value: "rejected",
},
],
}
const dateFilters: Filter[] = [
{ label: t("fields.createdAt"), key: "created_at" },
{ label: t("fields.updatedAt"), key: "updated_at" },
].map((f) => ({
key: f.key,
label: f.label,
type: "date",
}))
filters = [...filters, statusFilter, giftCardFilter, ...dateFilters]
return filters
}

View File

@@ -0,0 +1,47 @@
import { AdminGetCustomersParams } from "@medusajs/medusa"
import { useQueryParams } from "../../use-query-params"
type UseCustomerTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useCustomerTableQuery = ({
prefix,
pageSize = 20,
}: UseCustomerTableQueryProps) => {
const queryObject = useQueryParams(
[
"offset",
"q",
"has_account",
"groups",
"order",
"created_at",
"updated_at",
],
prefix
)
const { offset, groups, has_account, q, order } = queryObject
const searchParams: AdminGetCustomersParams = {
limit: pageSize,
offset: offset ? Number(offset) : 0,
groups: groups?.split(","),
has_account: has_account ? has_account === "true" : undefined,
order,
created_at: queryObject.created_at
? JSON.parse(queryObject.created_at)
: undefined,
updated_at: queryObject.updated_at
? JSON.parse(queryObject.updated_at)
: undefined,
q,
}
return {
searchParams,
raw: queryObject,
}
}

View File

@@ -6,13 +6,9 @@ type UseOrderTableQueryProps = {
pageSize?: number
}
/**
* TODO: Enable `order` query param when staging is updated
*/
export const useOrderTableQuery = ({
prefix,
pageSize = 50,
pageSize = 20,
}: UseOrderTableQueryProps) => {
const queryObject = useQueryParams(
[
@@ -24,6 +20,7 @@ export const useOrderTableQuery = ({
"sales_channel_id",
"payment_status",
"fulfillment_status",
"order",
],
prefix
)

View File

@@ -0,0 +1,68 @@
import { AdminGetProductsParams } from "@medusajs/medusa"
import { ProductStatus } from "@medusajs/types"
import { useQueryParams } from "../../use-query-params"
type UseProductTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useProductTableQuery = ({
prefix,
pageSize = 20,
}: UseProductTableQueryProps) => {
const queryObject = useQueryParams(
[
"offset",
"order",
"q",
"created_at",
"updated_at",
"sales_channel_id",
"category_id",
"collection_id",
"is_giftcard",
"tags",
"type_id",
"status",
],
prefix
)
const {
offset,
sales_channel_id,
created_at,
updated_at,
category_id,
collection_id,
tags,
type_id,
is_giftcard,
status,
order,
q,
} = queryObject
const searchParams: AdminGetProductsParams = {
limit: pageSize,
offset: offset ? Number(offset) : 0,
sales_channel_id: sales_channel_id?.split(","),
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
category_id: category_id?.split(","),
collection_id: collection_id?.split(","),
is_giftcard: is_giftcard ? is_giftcard === "true" : undefined,
order: order,
tags: tags?.split(","),
type_id: type_id?.split(","),
status: status?.split(",") as ProductStatus[],
q,
}
return {
searchParams,
raw: queryObject,
}
}

View File

@@ -10,27 +10,29 @@ import {
import { useEffect, useMemo, useState } from "react"
import { useSearchParams } from "react-router-dom"
type UseDataTableProps<TData, TValue> = {
type UseDataTableProps<TData> = {
data?: TData[]
columns: ColumnDef<TData, TValue>[]
columns: ColumnDef<TData, any>[]
count?: number
pageSize?: number
enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
enablePagination?: boolean
getRowId?: (original: TData, index: number) => string
meta?: Record<string, unknown>
prefix?: string
}
export const useDataTable = <TData, TValue>({
export const useDataTable = <TData,>({
data = [],
columns,
count = 0,
pageSize: _pageSize = 50,
pageSize: _pageSize = 20,
enablePagination = true,
enableRowSelection = false,
getRowId,
meta,
prefix,
}: UseDataTableProps<TData, TValue>) => {
}: UseDataTableProps<TData>) => {
const [searchParams, setSearchParams] = useSearchParams()
const offsetKey = `${prefix ? `${prefix}_` : ""}offset`
const offset = searchParams.get(offsetKey)
@@ -106,6 +108,7 @@ export const useDataTable = <TData, TValue>({
? getPaginationRowModel()
: undefined,
manualPagination: enablePagination ? true : undefined,
meta,
})
return { table }

View File

@@ -8,8 +8,8 @@ export const useFormPrompt = () => {
const promptValues = {
title: t("general.unsavedChangesTitle"),
description: t("general.unsavedChangesDescription"),
cancelText: t("general.cancel"),
confirmText: t("general.continue"),
cancelText: t("actions.cancel"),
confirmText: t("actions.continue"),
}
const prompt = async () => {

View File

@@ -3,13 +3,6 @@ import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
type Prompt = {
title: string
description: string
cancelText: string
confirmText: string
}
/**
* Hook for managing the state of route modals.
*/
@@ -30,11 +23,12 @@ export const useRouteModalState = (): [
const prompt = usePrompt()
const { t } = useTranslation()
let promptValues: Prompt = {
const promptValues = {
title: t("general.unsavedChangesTitle"),
description: t("general.unsavedChangesDescription"),
cancelText: t("general.cancel"),
confirmText: t("general.continue"),
cancelText: t("actions.cancel"),
confirmText: t("actions.continue"),
variant: "confirmation" as const,
}
useEffect(() => {

View File

@@ -6,22 +6,27 @@ import { initReactI18next } from "react-i18next"
import { Language } from "./types"
i18n
void i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init<HttpBackendOptions>({
fallbackLng: "en",
fallbackLng: "en-US",
load: "languageOnly",
debug: process.env.NODE_ENV === "development",
interpolation: {
escapeValue: false,
},
backend: {
// for all available options read the backend's repository readme file
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
})
export const languages: Language[] = [
{
code: "en",
display_name: "English",
code: "en-US",
display_name: "English (US)",
ltr: true,
date_locale: enUS,
},

View File

@@ -1,5 +1,5 @@
import type { Locale } from "date-fns"
import en from "../../public/locales/en/translation.json"
import en from "../../public/locales/en-US/translation.json"
const resources = {
translation: en,

View File

@@ -1,7 +1,7 @@
import { Resources } from "./i18n/types";
import { Resources } from "./i18n/types"
declare module "i18next" {
interface CustomTypeOptions {
resources: Resources;
resources: Resources
}
}

View File

@@ -2,6 +2,7 @@ import type {
AdminCollectionsRes,
AdminCustomerGroupsRes,
AdminCustomersRes,
AdminGiftCardsRes,
AdminProductsRes,
AdminPublishableApiKeysRes,
AdminRegionsRes,
@@ -273,12 +274,29 @@ const router = createBrowserRouter([
},
children: [
{
index: true,
lazy: () => import("../../routes/gift-cards/list"),
path: "",
lazy: () => import("../../routes/gift-cards/gift-card-list"),
children: [
{
path: "create",
lazy: () =>
import("../../routes/gift-cards/gift-card-create"),
},
],
},
{
path: ":id",
lazy: () => import("../../routes/gift-cards/details"),
lazy: () => import("../../routes/gift-cards/gift-card-detail"),
handle: {
crumb: (data: AdminGiftCardsRes) => data.gift_card.code,
},
children: [
{
path: "edit",
lazy: () =>
import("../../routes/gift-cards/gift-card-edit"),
},
],
},
],
},

View File

@@ -156,16 +156,16 @@ export const AddSalesChannelsToApiKeyForm = ({
)}
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button size="small" type="submit" isLoading={isMutating}>
{t("general.save")}
{t("actions.save")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto divide-y">
<div className="flex items-center justify-between w-full px-6 py-4">
<FocusModal.Body className="flex h-full w-full flex-col items-center divide-y overflow-y-auto">
<div className="flex w-full items-center justify-between px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />

View File

@@ -57,14 +57,14 @@ export const CreatePublishableApiKeyForm = ({
onSubmit={handleSubmit}
>
<FocusModal.Header>
<div className="flex items-center gap-x-2 justify-end">
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("general.save")}
{t("actions.save")}
</Button>
</div>
</FocusModal.Header>

View File

@@ -39,8 +39,8 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
description: t("apiKeyManagement.deleteKeyWarning", {
title: apiKey.title,
}),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -57,7 +57,7 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
title: apiKey.title,
}),
confirmText: t("apiKeyManagement.revoke"),
cancelText: t("general.cancel"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -70,7 +70,7 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
const dangerousActions = [
{
icon: <Trash />,
label: t("general.delete"),
label: t("actions.delete"),
onClick: handleDelete,
},
]
@@ -84,8 +84,8 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
}
return (
<Container className="p-0 divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{apiKey.title}</Heading>
<div className="flex items-center gap-x-2">
<StatusBadge color={apiKey.revoked_at ? "red" : "green"}>
@@ -96,7 +96,7 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
{
actions: [
{
label: t("general.edit"),
label: t("actions.edit"),
icon: <PencilSquare />,
to: `/settings/api-key-management/${apiKey.id}/edit`,
},
@@ -109,11 +109,11 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
/>
</div>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.key")}
</Text>
<div className="bg-ui-bg-subtle border border-ui-border-base flex items-center gap-x-0.5 w-fit rounded-full pl-2 pr-1 box-border cursor-default overflow-hidden">
<div className="bg-ui-bg-subtle border-ui-border-base box-border flex w-fit cursor-default items-center gap-x-0.5 overflow-hidden rounded-full border pl-2 pr-1">
<Text size="xsmall" leading="compact" className="truncate">
{apiKey.id}
</Text>
@@ -124,14 +124,14 @@ export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => {
/>
</div>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("apiKeyManagement.createdBy")}
</Text>
<ActionBy userId={apiKey.created_by} />
</div>
{apiKey.revoked_at && (
<div className="grid grid-cols-2 px-6 py-4 items-center">
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("apiKeyManagement.revokedBy")}
</Text>
@@ -162,8 +162,8 @@ const ActionBy = ({ userId }: { userId: string | null }) => {
if (isLoading) {
return (
<div className="grid grid-cols-[20px_1fr]">
<Skeleton className="w-5 h-5 rounded-full" />
<Skeleton className="max-w-[220px] w-full" />
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="w-full max-w-[220px]" />
</div>
)
}

View File

@@ -109,8 +109,8 @@ export const ApiKeySalesChannelSection = ({
description: t("apiKeyManagement.removeSalesChannelsWarning", {
count: keys.length,
}),
confirmText: t("general.continue"),
cancelText: t("general.cancel"),
confirmText: t("actions.continue"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -136,15 +136,15 @@ export const ApiKeySalesChannelSection = ({
}
return (
<Container className="p-0 divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("salesChannels.domain")}</Heading>
<Button variant="secondary" size="small" asChild>
<Link to="add-sales-channels">{t("general.add")}</Link>
</Button>
</div>
{!noRecords && (
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center justify-between px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
@@ -162,7 +162,7 @@ export const ApiKeySalesChannelSection = ({
return (
<Table.Row
key={headerGroup.id}
className="[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/3"
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{headerGroup.headers.map((header) => {
return (
@@ -229,7 +229,7 @@ export const ApiKeySalesChannelSection = ({
<CommandBar.Command
action={handleRemove}
shortcut="r"
label={t("general.remove")}
label={t("actions.remove")}
/>
</CommandBar.Bar>
</CommandBar>
@@ -255,8 +255,8 @@ const SalesChannelActions = ({
const res = await prompt({
title: t("general.areYouSure"),
description: t("apiKeyManagement.removeSalesChannelWarning"),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -275,7 +275,7 @@ const SalesChannelActions = ({
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
label: t("actions.edit"),
to: `/settings/sales-channels/${salesChannel.id}/edit`,
},
],
@@ -284,7 +284,7 @@ const SalesChannelActions = ({
actions: [
{
icon: <Trash />,
label: t("general.delete"),
label: t("actions.delete"),
onClick: handleDelete,
},
],

View File

@@ -76,11 +76,11 @@ export const EditApiKeyForm = ({
<div className="flex items-center gap-x-2">
<Drawer.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</Drawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("general.save")}
{t("actions.save")}
</Button>
</div>
</Drawer.Footer>

View File

@@ -161,7 +161,7 @@ const CreatePublishableApiKey = (props: CreatePublishableApiKeyProps) => {
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button size="small" type="submit">

View File

@@ -82,12 +82,12 @@ export const ApiKeyManagementListTable = () => {
}
return (
<Container className="p-0 divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("apiKeyManagement.domain")}</Heading>
<Link to="create">
<Button variant="secondary" size="small">
{t("general.create")}
{t("actions.create")}
</Button>
</Link>
</div>
@@ -186,8 +186,8 @@ const KeyActions = ({ apiKey }: { apiKey: PublishableApiKey }) => {
description: t("apiKeyManagement.deleteKeyWarning", {
title: apiKey.title,
}),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -204,7 +204,7 @@ const KeyActions = ({ apiKey }: { apiKey: PublishableApiKey }) => {
title: apiKey.title,
}),
confirmText: t("apiKeyManagement.revoke"),
cancelText: t("general.cancel"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -221,7 +221,7 @@ const KeyActions = ({ apiKey }: { apiKey: PublishableApiKey }) => {
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
label: t("actions.edit"),
to: `/settings/api-key-management/${apiKey.id}`,
},
],
@@ -235,7 +235,7 @@ const KeyActions = ({ apiKey }: { apiKey: PublishableApiKey }) => {
},
{
icon: <Trash />,
label: t("general.delete"),
label: t("actions.delete"),
onClick: handleDelete,
},
],
@@ -263,7 +263,7 @@ const useColumns = () => {
return (
<div
className="bg-ui-bg-subtle border border-ui-border-base flex items-center gap-x-0.5 w-fit max-w-[220px] rounded-full pl-2 pr-1 box-border cursor-default overflow-hidden"
className="bg-ui-bg-subtle border-ui-border-base box-border flex w-fit max-w-[220px] cursor-default items-center gap-x-0.5 overflow-hidden rounded-full border pl-2 pr-1"
onClick={(e) => e.stopPropagation()}
>
<Text size="xsmall" leading="compact" className="truncate">

View File

@@ -183,17 +183,17 @@ export const AddProductsToCollectionForm = ({
)}
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button size="small" type="submit" isLoading={isMutating}>
{t("general.save")}
{t("actions.save")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto divide-y">
<FocusModal.Body className="flex h-full w-full flex-col items-center divide-y overflow-y-auto">
{!noRecords && (
<div className="flex items-center justify-between w-full px-6 py-4">
<div className="flex w-full items-center justify-between px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
@@ -209,7 +209,7 @@ export const AddProductsToCollectionForm = ({
onScroll={handleScroll}
>
{!isLoading && !products?.length ? (
<div className="flex-1 flex items-center justify-center h-full">
<div className="flex h-full flex-1 items-center justify-center">
<NoResults />
</div>
) : (
@@ -286,7 +286,7 @@ export const AddProductsToCollectionForm = ({
</div>
</Fragment>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-1 items-center justify-center">
<NoRecords />
{/* TODO: fix this, and add NoRecords as well */}
</div>

View File

@@ -56,7 +56,7 @@ export const CreateCollectionForm = ({
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button
@@ -65,12 +65,12 @@ export const CreateCollectionForm = ({
type="submit"
isLoading={isLoading}
>
{t("general.create")}
{t("actions.create")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex flex-col items-center py-16">
<div className="w-full max-w-[720px] flex flex-col gap-y-8">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("collections.createCollection")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
@@ -107,7 +107,7 @@ export const CreateCollectionForm = ({
</Form.Label>
<Form.Control>
<div className="relative">
<div className="absolute left-0 inset-y-0 w-8 border-r z-10 flex items-center justify-center">
<div className="absolute inset-y-0 left-0 z-10 flex w-8 items-center justify-center border-r">
<Text
className="text-ui-fg-muted"
size="small"

View File

@@ -33,7 +33,7 @@ export const CollectionGeneralSection = ({
}
return (
<Container className="p-0 divide-y">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{collection.title}</Heading>
<ActionMenu
@@ -42,7 +42,7 @@ export const CollectionGeneralSection = ({
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
label: t("actions.edit"),
to: `/collections/${collection.id}/edit`,
},
],
@@ -51,7 +51,7 @@ export const CollectionGeneralSection = ({
actions: [
{
icon: <Trash />,
label: t("general.delete"),
label: t("actions.delete"),
onClick: handleDelete,
},
],
@@ -59,18 +59,12 @@ export const CollectionGeneralSection = ({
]}
/>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.handle")}
</Text>
<Text size="small">/{collection.handle}</Text>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("fields.products")}
</Text>
<Text size="small">{collection.products?.length || "-"}</Text>
</div>
</Container>
)
}

View File

@@ -1,47 +1,21 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import type { Product, ProductCollection } from "@medusajs/medusa"
import {
Button,
Checkbox,
CommandBar,
Container,
Heading,
Table,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Button, Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import {
adminProductKeys,
useAdminProducts,
useAdminRemoveProductsFromCollection,
} from "medusa-react"
import { useMemo, useState } from "react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import {
ProductAvailabilityCell,
ProductCollectionCell,
ProductStatusCell,
ProductTitleCell,
ProductVariantCell,
} from "../../../../../components/common/product-table-cells"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { DataTable } from "../../../../../components/table/data-table"
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { queryClient } from "../../../../../lib/medusa"
type CollectionProductSectionProps = {
@@ -54,51 +28,30 @@ export const CollectionProductSection = ({
collection,
}: CollectionProductSectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const params = useQueryParams(["q", "order"])
const { searchParams, raw } = useProductTableQuery({ pageSize: PAGE_SIZE })
const { products, count, isLoading, isError, error } = useAdminProducts(
{
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...searchParams,
collection_id: [collection.id],
...params,
},
{
keepPreviousData: true,
}
)
const filters = useProductTableFilters(["collections"])
const columns = useColumns()
const table = useReactTable({
const { table } = useDataTable({
data: (products ?? []) as Product[],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
getRowId: (row) => row.id,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
count,
enablePagination: true,
enableRowSelection: true,
pageSize: PAGE_SIZE,
meta: {
collectionId: collection.id,
},
@@ -107,16 +60,16 @@ export const CollectionProductSection = ({
const prompt = usePrompt()
const { mutateAsync } = useAdminRemoveProductsFromCollection(collection.id)
const handleRemove = async () => {
const ids = Object.keys(rowSelection)
const handleRemove = async (selection: Record<string, boolean>) => {
const ids = Object.keys(selection)
const res = await prompt({
title: t("general.areYouSure"),
description: t("collections.removeProductsWarning", {
count: ids.length,
}),
confirmText: t("general.confirm"),
cancelText: t("general.cancel"),
confirmText: t("actions.remove"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -130,23 +83,17 @@ export const CollectionProductSection = ({
{
onSuccess: () => {
queryClient.invalidateQueries(adminProductKeys.lists())
setRowSelection({})
},
}
)
}
const noRecords =
!isLoading &&
products?.length === 0 &&
!Object.values(params).filter((v) => v).length
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("products.domain")}</Heading>
<Link to={`/collections/${collection.id}/add-products`}>
@@ -155,99 +102,26 @@ export const CollectionProductSection = ({
</Button>
</Link>
</div>
{!noRecords && (
<div className="flex items-center justify-between px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
<OrderBy keys={["title", "status", "created_at", "updated_at"]} />
</div>
</div>
)}
{noRecords ? (
<NoRecords />
) : (
<div>
{!isLoading && !products?.length ? (
<div className="border-b">
<NoResults />
</div>
) : (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/5"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap [&_td:first-of-type]:w-[1%] [&_td:first-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() => navigate(`/products/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
)}
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
<CommandBar open={!!Object.keys(rowSelection).length}>
<CommandBar.Bar>
<CommandBar.Value>
{t("general.countSelected", {
count: Object.keys(rowSelection).length,
})}
</CommandBar.Value>
<CommandBar.Seperator />
<CommandBar.Command
action={handleRemove}
shortcut="r"
label={t("general.remove")}
/>
</CommandBar.Bar>
</CommandBar>
</div>
)}
<DataTable
table={table}
columns={columns}
search
pagination
rowCount={PAGE_SIZE}
navigateTo={({ original }) => `/products/${original.id}`}
count={count}
filters={filters}
isLoading={isLoading}
orderBy={["title", "created_at", "updated_at"]}
queryObject={raw}
commands={[
{
action: handleRemove,
label: t("actions.remove"),
shortcut: "r",
},
]}
/>
</Container>
)
}
@@ -269,8 +143,8 @@ const ProductActions = ({
description: t("collections.removeSingleProductWarning", {
title: product.title,
}),
confirmText: t("general.confirm"),
cancelText: t("general.cancel"),
confirmText: t("actions.remove"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -289,7 +163,7 @@ const ProductActions = ({
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
label: t("actions.edit"),
to: `/products/${product.id}/edit`,
},
],
@@ -298,7 +172,7 @@ const ProductActions = ({
actions: [
{
icon: <Trash />,
label: t("general.remove"),
label: t("actions.remove"),
onClick: handleRemove,
},
],
@@ -311,7 +185,7 @@ const ProductActions = ({
const columnHelper = createColumnHelper<Product>()
const useColumns = () => {
const { t } = useTranslation()
const columns = useProductTableColumns()
return useMemo(
() => [
@@ -343,44 +217,7 @@ const useColumns = () => {
)
},
}),
columnHelper.accessor("title", {
header: t("fields.title"),
cell: ({ row }) => {
return <ProductTitleCell product={row.original} />
},
}),
columnHelper.accessor("collection", {
header: t("fields.collection"),
cell: (cell) => {
const collection = cell.getValue()
return <ProductCollectionCell collection={collection} />
},
}),
columnHelper.accessor("sales_channels", {
header: t("fields.availability"),
cell: (cell) => {
const salesChannels = cell.getValue()
return <ProductAvailabilityCell salesChannels={salesChannels ?? []} />
},
}),
columnHelper.accessor("variants", {
header: t("fields.variants"),
cell: (cell) => {
const variants = cell.getValue()
return <ProductVariantCell variants={variants} />
},
}),
columnHelper.accessor("status", {
header: t("fields.status"),
cell: (cell) => {
const value = cell.getValue()
return <ProductStatusCell status={value} />
},
}),
...columns,
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
@@ -397,6 +234,6 @@ const useColumns = () => {
},
}),
],
[t]
[columns]
)
}

View File

@@ -83,7 +83,7 @@ export const EditCollectionForm = ({
</Form.Label>
<Form.Control>
<div className="relative">
<div className="absolute left-0 inset-y-0 w-8 border-r z-10 flex items-center justify-center">
<div className="absolute inset-y-0 left-0 z-10 flex w-8 items-center justify-center border-r">
<Text
className="text-ui-fg-muted"
size="small"
@@ -107,11 +107,11 @@ export const EditCollectionForm = ({
<div className="flex items-center gap-x-2">
<Drawer.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</Drawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("general.save")}
{t("actions.save")}
</Button>
</div>
</Drawer.Footer>

View File

@@ -1,239 +1,66 @@
import { ProductCollection } from "@medusajs/medusa"
import { Button, Container, Heading, Table, clx, usePrompt } from "@medusajs/ui"
import {
PaginationState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useAdminCollections, useAdminDeleteCollection } from "medusa-react"
import { useMemo, useState } from "react"
import { Button, Container, Heading } from "@medusajs/ui"
import { useAdminCollections } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { Link } from "react-router-dom"
import { PencilSquare, Trash } from "@medusajs/icons"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useCollectionTableColumns } from "./use-collection-table-columns"
import { useCollectionTableFilters } from "./use-collection-table-filters"
import { useCollectionTableQuery } from "./use-collection-table-query"
const PAGE_SIZE = 50
const PAGE_SIZE = 20
export const CollectionListTable = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const params = useQueryParams(["q"])
const { searchParams, raw } = useCollectionTableQuery({ pageSize: PAGE_SIZE })
const { collections, count, isError, error, isLoading } = useAdminCollections(
{
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
...searchParams,
},
{
keepPreviousData: true,
}
)
const columns = useColumns()
const filters = useCollectionTableFilters()
const columns = useCollectionTableColumns()
const table = useReactTable({
const { table } = useDataTable({
data: collections ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
const noRecords =
!isLoading &&
(!collections || collections.length === 0) &&
!Object.values(params).filter(Boolean).length
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("collections.domain")}</Heading>
<Link to="/collections/create">
<Button size="small" variant="secondary">
{t("general.create")}
{t("actions.create")}
</Button>
</Link>
</div>
{!noRecords && (
<div className="px-6 py-4 flex items-center justify-between">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
</div>
</div>
)}
{noRecords ? (
<NoRecords />
) : (
<div>
{!isLoading && !collections?.length ? (
<div className="border-b">
<NoResults />
</div>
) : (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() => navigate(`/collections/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
)}
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
)}
<DataTable
table={table}
columns={columns}
rowCount={PAGE_SIZE}
count={count}
filters={filters}
orderBy={["title", "handle", "created_at", "updated_at"]}
search
navigateTo={(row) => `/collections/${row.original.id}`}
queryObject={raw}
isLoading={isLoading}
/>
</Container>
)
}
const CollectionActions = ({
collection,
}: {
collection: ProductCollection
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteCollection(collection.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("collections.deleteWarning", {
count: 1,
}),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/collections/${collection.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<ProductCollection>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("title", {
header: t("fields.title"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <CollectionActions collection={row.original} />,
}),
],
[t]
)
}

View File

@@ -0,0 +1,94 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { ProductCollection } from "@medusajs/medusa"
import { usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteCollection } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
const columnHelper = createColumnHelper<ProductCollection>()
export const useCollectionTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("title", {
header: t("fields.title"),
}),
columnHelper.accessor("handle", {
header: t("fields.handle"),
cell: ({ getValue }) => `/${getValue()}`,
}),
columnHelper.accessor("products", {
header: t("fields.products"),
cell: ({ getValue }) => {
const count = getValue()?.length
return <span>{count || "-"}</span>
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <CollectionActions collection={row.original} />,
}),
],
[t]
)
}
const CollectionActions = ({
collection,
}: {
collection: ProductCollection
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteCollection(collection.id)
const handleDeleteCollection = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("collections.deleteWarning", {
title: collection.title,
}),
verificationText: collection.title,
verificationInstruction: t("general.typeToConfirm"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `/collections/${collection.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDeleteCollection,
icon: <Trash />,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useCollectionTableFilters = () => {
const { t } = useTranslation()
let filters: Filter[] = []
const dateFilters: Filter[] = [
{ label: t("fields.createdAt"), key: "created_at" },
{ label: t("fields.updatedAt"), key: "updated_at" },
].map((f) => ({
key: f.key,
label: f.label,
type: "date",
}))
filters = [...filters, ...dateFilters]
return filters
}

View File

@@ -0,0 +1,33 @@
import { AdminGetCollectionsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
type UseCollectionTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useCollectionTableQuery = ({
prefix,
pageSize = 20,
}: UseCollectionTableQueryProps) => {
const queryObject = useQueryParams(
["offset", "q", "order", "created_at", "updated_at"],
prefix
)
const { offset, created_at, updated_at, q, order } = queryObject
const searchParams: AdminGetCollectionsParams = {
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

@@ -161,7 +161,7 @@ export const AddCustomersForm = ({
)}
<FocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button
@@ -174,14 +174,14 @@ export const AddCustomersForm = ({
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto divide-y">
<FocusModal.Body className="flex h-full w-full flex-col items-center divide-y overflow-y-auto">
{noRecords ? (
<div className="w-full flex-1 flex items-center justify-center">
<div className="flex w-full flex-1 items-center justify-center">
<NoRecords />
</div>
) : (
<div className="divide-y w-full flex-1 flex flex-col">
<div className="flex items-center justify-between w-full px-6 py-4">
<div className="flex w-full flex-1 flex-col divide-y">
<div className="flex w-full items-center justify-between px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
@@ -242,7 +242,7 @@ export const AddCustomersForm = ({
</Table.Body>
</Table>
) : (
<div className="flex-1 flex items-center justify-center min-h-full">
<div className="flex min-h-full flex-1 items-center justify-center">
<NoResults />
</div>
)}

View File

@@ -56,10 +56,10 @@ export const CreateCustomerGroupForm = ({
<Form {...form}>
<form onSubmit={handleSubmit}>
<FocusModal.Header>
<div className="flex items-center gap-x-2 justify-end">
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button
@@ -68,12 +68,12 @@ export const CreateCustomerGroupForm = ({
size="small"
isLoading={isLoading}
>
{t("general.create")}
{t("actions.create")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex flex-col items-center pt-[72px]">
<div className="w-full max-w-[720px] flex flex-col gap-y-8">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("customerGroups.createCustomerGroup")}</Heading>
<Text size="small" className="text-ui-fg-subtle">

View File

@@ -1,39 +1,20 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { Customer, CustomerGroup } from "@medusajs/medusa"
import {
Button,
Checkbox,
CommandBar,
Container,
Heading,
StatusBadge,
Table,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Button, Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import {
useAdminCustomerGroupCustomers,
useAdminRemoveCustomersFromCustomerGroup,
} from "medusa-react"
import { useMemo, useState } from "react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { DataTable } from "../../../../../components/table/data-table"
import { useCustomerTableColumns } from "../../../../../hooks/table/columns/use-customer-table-columns"
import { useCustomerTableFilters } from "../../../../../hooks/table/filters/use-customer-table-filters"
import { useCustomerTableQuery } from "../../../../../hooks/table/query/use-customer-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
type CustomerGroupCustomerSectionProps = {
group: CustomerGroup
@@ -44,53 +25,31 @@ const PAGE_SIZE = 10
export const CustomerGroupCustomerSection = ({
group,
}: CustomerGroupCustomerSectionProps) => {
const navigate = useNavigate()
const { t } = useTranslation()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const params = useQueryParams(["q"])
const { searchParams, raw } = useCustomerTableQuery({ pageSize: PAGE_SIZE })
const { customers, count, isLoading, isError, error } =
useAdminCustomerGroupCustomers(
group.id,
{
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
...searchParams,
},
{
keepPreviousData: true,
}
)
const filters = useCustomerTableFilters(["groups"])
const columns = useColumns()
const table = useReactTable({
const { table } = useDataTable({
data: customers ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
count,
getRowId: (row) => row.id,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
enablePagination: true,
enableRowSelection: true,
pageSize: PAGE_SIZE,
meta: {
customerGroupId: group.id,
},
@@ -99,46 +58,34 @@ export const CustomerGroupCustomerSection = ({
const { mutateAsync } = useAdminRemoveCustomersFromCustomerGroup(group.id)
const prompt = usePrompt()
const handleRemoveCustomers = async () => {
const selected = Object.keys(rowSelection).filter((k) => rowSelection[k])
const handleRemoveCustomers = async (selection: Record<string, boolean>) => {
const selected = Object.keys(selection).filter((k) => selection[k])
const res = await prompt({
title: t("general.areYouSure"),
description: t("customerGroups.removeCustomersWarning", {
count: selected.length,
}),
confirmText: t("general.continue"),
cancelText: t("general.cancel"),
confirmText: t("actions.continue"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync(
{
customer_ids: selected.map((s) => ({ id: s })),
},
{
onSuccess: () => {
setRowSelection({})
},
}
)
await mutateAsync({
customer_ids: selected.map((s) => ({ id: s })),
})
}
const noRecords =
!isLoading &&
!customers?.length &&
!Object.values(params).filter(Boolean).length
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("customers.domain")}</Heading>
<Link to={`/customer-groups/${group.id}/add-customers`}>
<Button variant="secondary" size="small">
@@ -146,102 +93,33 @@ export const CustomerGroupCustomerSection = ({
</Button>
</Link>
</div>
<div>
{noRecords ? (
<NoRecords />
) : (
<div className="divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
</div>
</div>
<div>
{(customers?.length || 0) > 0 ? (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap [&_td:first-of-type]:w-[1%] [&_td:first-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() =>
navigate(`/customers/${row.original.id}`)
}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<div className="border-b">
<NoResults />
</div>
)}
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
<CommandBar open={!!Object.keys(rowSelection).length}>
<CommandBar.Bar>
<CommandBar.Value>
{t("general.countSelected", {
count: Object.keys(rowSelection).length,
})}
</CommandBar.Value>
<CommandBar.Seperator />
<CommandBar.Command
action={handleRemoveCustomers}
shortcut="r"
label={t("general.remove")}
/>
</CommandBar.Bar>
</CommandBar>
</div>
</div>
)}
</div>
<DataTable
table={table}
columns={columns}
rowCount={PAGE_SIZE}
isLoading={isLoading}
count={count}
navigateTo={(row) => `/customers/${row.id}`}
filters={filters}
search
pagination
orderBy={[
"email",
"first_name",
"last_name",
"has_account",
"created_at",
"updated_at",
]}
commands={[
{
action: handleRemoveCustomers,
label: t("actions.remove"),
shortcut: "r",
},
]}
queryObject={raw}
/>
</Container>
)
}
@@ -265,8 +143,8 @@ const CustomerActions = ({
description: t("customerGroups.removeCustomersWarning", {
count: 1,
}),
confirmText: t("general.continue"),
cancelText: t("general.cancel"),
confirmText: t("actions.continue"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -285,7 +163,7 @@ const CustomerActions = ({
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
label: t("actions.edit"),
to: `/customers/${customer.id}/edit`,
},
],
@@ -294,7 +172,7 @@ const CustomerActions = ({
actions: [
{
icon: <Trash />,
label: t("general.remove"),
label: t("actions.remove"),
onClick: handleRemove,
},
],
@@ -307,7 +185,7 @@ const CustomerActions = ({
const columnHelper = createColumnHelper<Customer>()
const useColumns = () => {
const { t } = useTranslation()
const columns = useCustomerTableColumns()
return useMemo(
() => [
@@ -339,33 +217,7 @@ const useColumns = () => {
)
},
}),
columnHelper.accessor("email", {
header: t("fields.email"),
cell: ({ getValue }) => <span>{getValue()}</span>,
}),
columnHelper.display({
id: "name",
header: t("fields.name"),
cell: ({ row }) => {
const name = [row.original.first_name, row.original.last_name]
.filter(Boolean)
.join(" ")
return name || "-"
},
}),
columnHelper.accessor("has_account", {
header: t("fields.account"),
cell: ({ getValue }) => {
const hasAccount = getValue()
return (
<StatusBadge color={hasAccount ? "green" : "blue"}>
{hasAccount ? t("customers.registered") : t("customers.guest")}
</StatusBadge>
)
},
}),
...columns,
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
@@ -382,6 +234,6 @@ const useColumns = () => {
},
}),
],
[t]
[columns]
)
}

View File

@@ -27,7 +27,7 @@ export const CustomerGroupGeneralSection = ({
}
return (
<Container className="px-6 py-4 flex items-center justify-between">
<Container className="flex items-center justify-between px-6 py-4">
<Heading>{group.name}</Heading>
<ActionMenu
groups={[
@@ -35,7 +35,7 @@ export const CustomerGroupGeneralSection = ({
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
label: t("actions.edit"),
to: `/customer-groups/${group.id}/edit`,
},
],
@@ -44,7 +44,7 @@ export const CustomerGroupGeneralSection = ({
actions: [
{
icon: <Trash />,
label: t("general.delete"),
label: t("actions.delete"),
onClick: handleDelete,
},
],

View File

@@ -54,9 +54,9 @@ export const EditCustomerGroupForm = ({
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex flex-col overflow-hidden flex-1"
className="flex flex-1 flex-col overflow-hidden"
>
<Drawer.Body className="flex flex-col gap-y-8 overflow-y-auto flex-1 max-w-full">
<Drawer.Body className="flex max-w-full flex-1 flex-col gap-y-8 overflow-y-auto">
<Form.Field
control={form.control}
name="name"
@@ -77,11 +77,11 @@ export const EditCustomerGroupForm = ({
<div className="flex items-center justify-end gap-x-2">
<Drawer.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</Drawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("general.save")}
{t("actions.save")}
</Button>
</div>
</Drawer.Footer>

View File

@@ -1,251 +1,72 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { CustomerGroup } from "@medusajs/medusa"
import { Button, Container, Heading, Table, clx, usePrompt } from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
useAdminCustomerGroups,
useAdminDeleteCustomerGroup,
} from "medusa-react"
import { useMemo, useState } from "react"
import { Button, Container, Heading } from "@medusajs/ui"
import { useAdminCustomerGroups } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { Link } from "react-router-dom"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useCustomerGroupTableColumns } from "./use-customer-group-table-columns"
import { useCustomerGroupTableFilters } from "./use-customer-group-table-filters"
import { useCustomerGroupTableQuery } from "./use-customer-group-table-query"
const PAGE_SIZE = 50
const PAGE_SIZE = 20
export const CustomerGroupListTable = () => {
const navigate = useNavigate()
const { t } = useTranslation()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
const { searchParams, raw } = useCustomerGroupTableQuery({
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const params = useQueryParams(["q", "order"])
const { customer_groups, count, isLoading, isError, error } =
useAdminCustomerGroups({
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
})
useAdminCustomerGroups(
{
...searchParams,
expand: "customers",
fields: "id,name,customers,customers.id",
},
{
keepPreviousData: true,
}
)
const columns = useColumns()
const filters = useCustomerGroupTableFilters()
const columns = useCustomerGroupTableColumns()
const table = useReactTable({
const { table } = useDataTable({
data: customer_groups ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
enablePagination: true,
count,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
const noRecords =
!isLoading &&
!customer_groups?.length &&
!Object.values(params).filter(Boolean).length
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("customerGroups.domain")}</Heading>
<Link to="/customer-groups/create">
<Button size="small" variant="secondary">
{t("general.create")}
{t("actions.create")}
</Button>
</Link>
</div>
<div>
{noRecords ? (
<NoRecords
action={{
label: t("customerGroups.createGroup"),
to: "/customer-groups/create",
}}
/>
) : (
<div className="divide-y">
<div className="flex items-center px-6 py-2 justify-between">
<div className="flex items-center gap-x-2"></div>
<div className="flex items-center gap-x-2">
<Query />
<OrderBy keys={["name", "created_at", "updated_at"]} />
</div>
</div>
<div>
{(customer_groups?.length || 0) > 0 ? (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() =>
navigate(`/customer-groups/${row.original.id}`)
}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<div className="border-b">
<NoResults />
</div>
)}
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
</div>
)}
</div>
<DataTable
table={table}
columns={columns}
rowCount={PAGE_SIZE}
count={count}
filters={filters}
search
pagination
navigateTo={(row) => `/customer-groups/${row.original.id}`}
orderBy={["name", "created_at", "updated_at"]}
queryObject={raw}
isLoading={isLoading}
/>
</Container>
)
}
const CustomerGroupActions = ({ group }: { group: CustomerGroup }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteCustomerGroup(group.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("customerGroups.deleteCustomerGroupWarning", {
name: group.name,
}),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync(undefined)
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/customer-groups/${group.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<CustomerGroup>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <CustomerGroupActions group={row.original} />,
}),
],
[t]
)
}

View File

@@ -0,0 +1,85 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { CustomerGroup } from "@medusajs/medusa"
import { usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteCustomerGroup } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
const columnHelper = createColumnHelper<CustomerGroup>()
export const useCustomerGroupTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.accessor("customers", {
header: t("customers.domain"),
cell: ({ getValue }) => {
const count = getValue()?.length ?? 0
return count
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <CustomerGroupActions group={row.original} />,
}),
],
[t]
)
}
const CustomerGroupActions = ({ group }: { group: CustomerGroup }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteCustomerGroup(group.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("customerGroups.deleteCustomerGroupWarning", {
name: group.name,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `/customer-groups/${group.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useCustomerGroupTableFilters = () => {
const { t } = useTranslation()
let filters: Filter[] = []
const dateFilters: Filter[] = [
{ label: t("fields.createdAt"), key: "created_at" },
{ label: t("fields.updatedAt"), key: "updated_at" },
].map((f) => ({
key: f.key,
label: f.label,
type: "date",
}))
filters = [...filters, ...dateFilters]
return filters
}

View File

@@ -0,0 +1,33 @@
import { AdminGetCustomerGroupsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
type UseCustomerGroupTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useCustomerGroupTableQuery = ({
prefix,
pageSize = 20,
}: UseCustomerGroupTableQueryProps) => {
const queryObject = useQueryParams(
["offset", "q", "order", "created_at", "updated_at"],
prefix
)
const { offset, created_at, updated_at, q, order } = queryObject
const searchParams: AdminGetCustomerGroupsParams = {
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

@@ -80,7 +80,7 @@ export const CreateCustomerForm = ({ subscribe }: CreateCustomerFormProps) => {
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button
@@ -89,12 +89,12 @@ export const CreateCustomerForm = ({ subscribe }: CreateCustomerFormProps) => {
type="submit"
isLoading={isLoading}
>
{t("general.create")}
{t("actions.create")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex flex-col items-center py-16">
<div className="w-full max-w-[720px] flex flex-col gap-y-8">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("customers.createCustomer")}</Heading>
<Text size="small" className="text-ui-fg-subtle">

View File

@@ -1,9 +1,8 @@
import { PencilSquare } from "@medusajs/icons"
import { Customer } from "@medusajs/medusa"
import { Button, Container, Heading, Text } from "@medusajs/ui"
import format from "date-fns/format"
import { ReactNode } from "react"
import { Container, Heading, StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
type CustomerGeneralSectionProps = {
customer: Customer
@@ -14,60 +13,52 @@ export const CustomerGeneralSection = ({
}: CustomerGeneralSectionProps) => {
const { t } = useTranslation()
const name = [customer.first_name, customer.last_name]
.filter(Boolean)
.join(" ")
const statusColor = customer.has_account ? "green" : "orange"
const statusText = customer.has_account
? t("customers.registered")
: t("customers.guest")
return (
<Container className="px-6 py-4 flex flex-col gap-y-3">
<div className="flex items-center justify-between">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{customer.email}</Heading>
<Link to={`/customers/${customer.id}/edit`}>
<Button size="small" variant="secondary">
{t("general.edit")}
</Button>
</Link>
<div className="flex items-center gap-x-2">
<StatusBadge color={statusColor}>{statusText}</StatusBadge>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
icon: <PencilSquare />,
to: "edit",
},
],
},
]}
/>
</div>
</div>
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<Bulletpoint
title={t("fields.name")}
value={
customer.first_name && customer.last_name
? `${customer.first_name} ${customer.last_name}`
: customer.last_name
? customer.last_name
: customer.first_name
? customer.first_name
: null
}
/>
<Bulletpoint
title={t("customers.firstSeen")}
value={format(new Date(customer.created_at), "MMM d, yyyy")}
/>
<Bulletpoint title="Phone" value={customer.phone} />
<Bulletpoint title="Orders" value={customer.orders.length} />
<Bulletpoint
title={t("fields.account")}
value={
customer.has_account
? t("customers.registered")
: t("customers.guest")
}
/>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.name")}
</Text>
<Text size="small" leading="compact">
{name ?? "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.phone")}
</Text>
<Text size="small" leading="compact">
{customer.phone ?? "-"}
</Text>
</div>
</Container>
)
}
const Bulletpoint = ({ title, value }: { title: string; value: ReactNode }) => {
return (
<div className="flex flex-col flex-1">
<Text
size="small"
weight="plus"
leading="compact"
className="text-ui-fg-muted"
>
{title}
</Text>
<div className="text-ui-fg-subtle txt-small-plus">{value ?? "-"}</div>
</div>
)
}

View File

@@ -60,7 +60,7 @@ export const CustomerOrderSection = ({
<Heading level="h2">{t("orders.domain")}</Heading>
<div className="flex items-center gap-x-2">
<Button size="small" variant="secondary">
{t("general.create")}
{t("actions.create")}
</Button>
</div>
</div>

View File

@@ -135,7 +135,7 @@ export const EditCustomerForm = ({
<div className="flex items-center justify-end gap-x-2">
<Drawer.Close asChild>
<Button variant="secondary" size="small">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</Drawer.Close>
<Button
@@ -144,7 +144,7 @@ export const EditCustomerForm = ({
variant="primary"
size="small"
>
{t("general.save")}
{t("actions.save")}
</Button>
</div>
</Drawer.Footer>

View File

@@ -1,158 +1,79 @@
import { PencilSquare } from "@medusajs/icons"
import { Customer } from "@medusajs/medusa"
import {
Button,
Container,
Heading,
StatusBadge,
Table,
clx,
} from "@medusajs/ui"
import {
PaginationState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Button, Container, Heading } from "@medusajs/ui"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useAdminCustomers } from "medusa-react"
import { useMemo, useState } from "react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { NoRecords } from "../../../../../components/common/empty-table-content"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { Link } from "react-router-dom"
const PAGE_SIZE = 50
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useCustomerTableColumns } from "../../../../../hooks/table/columns/use-customer-table-columns"
import { useCustomerTableFilters } from "../../../../../hooks/table/filters/use-customer-table-filters"
import { useCustomerTableQuery } from "../../../../../hooks/table/query/use-customer-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
const PAGE_SIZE = 20
export const CustomerListTable = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
const { searchParams, raw } = useCustomerTableQuery({ pageSize: PAGE_SIZE })
const { customers, count, isLoading, isError, error } = useAdminCustomers(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const params = useQueryParams(["q"])
const { customers, count, isLoading, isError, error } = useAdminCustomers({
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
})
const filters = useCustomerTableFilters()
const columns = useColumns()
const table = useReactTable({
const { table } = useDataTable({
data: customers ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
const noRecords =
!isLoading &&
(!customers || customers.length === 0) &&
!Object.values(params).filter(Boolean).length
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("customers.domain")}</Heading>
<Link to="/customers/create">
<Button size="small" variant="secondary">
{t("general.create")}
{t("actions.create")}
</Button>
</Link>
</div>
<div className="px-6 py-4 flex items-center justify-between">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
</div>
</div>
{noRecords ? (
<NoRecords />
) : (
<div>
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() => navigate(`/customers/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
)}
<DataTable
table={table}
columns={columns}
rowCount={PAGE_SIZE}
count={count}
filters={filters}
orderBy={[
"email",
"first_name",
"last_name",
"has_account",
"created_at",
"updated_at",
]}
isLoading={isLoading}
navigateTo={(row) => row.original.id}
search
queryObject={raw}
/>
</Container>
)
}
@@ -167,7 +88,7 @@ const CustomerActions = ({ customer }: { customer: Customer }) => {
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
label: t("actions.edit"),
to: `/customers/${customer.id}/edit`,
},
],
@@ -180,42 +101,16 @@ const CustomerActions = ({ customer }: { customer: Customer }) => {
const columnHelper = createColumnHelper<Customer>()
const useColumns = () => {
const { t } = useTranslation()
const columns = useCustomerTableColumns()
return useMemo(
() => [
columnHelper.accessor("email", {
header: t("fields.email"),
cell: ({ getValue }) => <span>{getValue()}</span>,
}),
columnHelper.display({
id: "name",
header: t("fields.name"),
cell: ({ row }) => {
const name = [row.original.first_name, row.original.last_name]
.filter(Boolean)
.join(" ")
return name || "-"
},
}),
columnHelper.accessor("has_account", {
header: t("fields.account"),
cell: ({ getValue }) => {
const hasAccount = getValue()
return (
<StatusBadge color={hasAccount ? "green" : "blue"}>
{hasAccount ? t("customers.registered") : t("customers.guest")}
</StatusBadge>
)
},
}),
...columns,
columnHelper.display({
id: "actions",
cell: ({ row }) => <CustomerActions customer={row.original} />,
}),
],
[t]
)
[columns]
) as ColumnDef<Customer>[]
}

View File

@@ -1,3 +0,0 @@
export const GiftCardDetails = () => {
return <div>Gift Card Details</div>;
};

View File

@@ -1 +0,0 @@
export { GiftCardDetails as Component } from "./details";

View File

@@ -0,0 +1,311 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
Button,
CurrencyInput,
DatePicker,
FocusModal,
Heading,
Input,
Select,
Switch,
Text,
Textarea,
Tooltip,
} from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useAdminCreateGiftCard, useAdminRegions } from "medusa-react"
import { useEffect, useState } from "react"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { useNavigate } from "react-router-dom"
import { Form } from "../../../../../components/common/form"
import { currencies } from "../../../../../lib/currencies"
import { getDbAmount } from "../../../../../lib/money-amount-helpers"
type CreateGiftCardFormProps = {
subscribe: (state: boolean) => void
}
const CreateGiftCardSchema = zod.object({
region_id: zod.string(),
value: zod.string(),
is_enabled: zod.boolean(),
ends_at: zod.date().nullable(),
email: zod.string().email(),
personal_message: zod.string().optional(),
})
export const CreateGiftCardForm = ({ subscribe }: CreateGiftCardFormProps) => {
const [showDateFields, setShowDateFields] = useState(false)
const { regions } = useAdminRegions({
limit: 1000,
fields: "id,name,currency_code",
})
const { t } = useTranslation()
const navigate = useNavigate()
const form = useForm<zod.infer<typeof CreateGiftCardSchema>>({
defaultValues: {
region_id: regions?.[0]?.id ?? "",
value: "",
is_enabled: true,
ends_at: null,
email: "",
personal_message: "",
},
resolver: zodResolver(CreateGiftCardSchema),
})
const {
formState: { isDirty },
setValue,
setError,
} = form
const regionId = useWatch({
control: form.control,
name: "region_id",
})
const currencyCode = regions?.find((r) => r.id === regionId)?.currency_code
const nativeSymbol = currencyCode
? currencies[currencyCode.toUpperCase()].symbol_native
: undefined
useEffect(() => {
subscribe(isDirty)
}, [isDirty, subscribe])
const { mutateAsync, isLoading } = useAdminCreateGiftCard()
const handleOpenChange = (open: boolean) => {
if (!open) {
setValue("ends_at", null, {
shouldDirty: true,
})
}
setShowDateFields(open)
}
const handleSubmit = form.handleSubmit(async (data) => {
if (!currencyCode) {
setError("region_id", {
type: "manual",
message: "Region not found",
})
return
}
await mutateAsync(
{
region_id: data.region_id,
value: getDbAmount(parseFloat(data.value), currencyCode),
is_disabled: !data.is_enabled,
ends_at: data.ends_at ?? undefined,
metadata: {
email: data.email,
personal_message: data.personal_message,
},
},
{
onSuccess: ({ gift_card }) => {
navigate(`../${gift_card.id}`, { replace: true })
},
}
)
})
return (
<Form {...form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto py-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("giftCards.createGiftCard")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("giftCards.createGiftCardHint")}
</Text>
</div>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="region_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.region")}</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{regions?.map((region) => (
<Select.Item key={region.id} value={region.id}>
{region.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="value"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("giftCards.balance")}</Form.Label>
<Form.Control>
{!currencyCode || !nativeSymbol ? (
<Tooltip content={t("giftCards.selectRegionFirst")}>
<Input disabled />
</Tooltip>
) : (
<CurrencyInput
code={currencyCode.toUpperCase()}
symbol={nativeSymbol}
min={0}
onValueChange={onChange}
{...field}
/>
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="is_enabled"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label>{t("general.enabled")}</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
</div>
<Form.Hint className="!mt-1">
{t("giftCards.enabledHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="ends_at"
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
return (
<Form.Item>
<Collapsible.Root open={showDateFields}>
<div className="flex flex-col gap-y-1">
<div className="flex items-center justify-between">
<Form.Label optional>
{t("fields.expiryDate")}
</Form.Label>
<Switch
checked={showDateFields}
onCheckedChange={handleOpenChange}
/>
</div>
<Text
size="small"
className="text-ui-fg-subtle max-w-[85%] text-pretty"
>
{t("giftCards.expiryDateHint")}
</Text>
</div>
<Collapsible.Content>
<div className="grid grid-cols-2 pt-4">
<Form.Control>
<DatePicker
value={value ?? undefined}
onChange={(v) => {
onChange(v ?? null)
}}
{...field}
/>
</Form.Control>
</div>
</Collapsible.Content>
</Collapsible.Root>
</Form.Item>
)
}}
/>
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-2">
<Form.Field
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Input type="email" autoComplete="off" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="personal_message"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("giftCards.personalMessage")}
</Form.Label>
<Form.Control>
<Textarea {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</FocusModal.Body>
</form>
</Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-gift-card-form"

View File

@@ -0,0 +1,15 @@
import { FocusModal } from "@medusajs/ui"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { CreateGiftCardForm } from "./components/create-gift-card-form"
export const GiftCardCreate = () => {
const [open, onOpenChange, subscribe] = useRouteModalState()
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content>
<CreateGiftCardForm subscribe={subscribe} />
</FocusModal.Content>
</FocusModal>
)
}

View File

@@ -0,0 +1 @@
export { GiftCardCreate as Component } from "./gift-card-create"

View File

@@ -0,0 +1,194 @@
import { ArrowUpRightOnBox, PencilSquare, Trash } from "@medusajs/icons"
import { GiftCard } from "@medusajs/medusa"
import {
Container,
Copy,
Heading,
StatusBadge,
Text,
usePrompt,
} from "@medusajs/ui"
import format from "date-fns/format"
import { useAdminDeleteGiftCard } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { currencies } from "../../../../../lib/currencies"
import { getPresentationalAmount } from "../../../../../lib/money-amount-helpers"
type GiftCardGeneralSectionProps = {
giftCard: GiftCard
}
export const GiftCardGeneralSection = ({
giftCard,
}: GiftCardGeneralSectionProps) => {
const { t } = useTranslation()
const prompt = usePrompt()
const navigate = useNavigate()
const { mutateAsync } = useAdminDeleteGiftCard(giftCard.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("giftCards.deleteGiftCardWarning", {
code: giftCard.code,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync(undefined, {
onSuccess: () => {
navigate("..")
},
})
}
let color: "green" | "red"
if (
giftCard.is_disabled ||
(giftCard.ends_at && new Date(giftCard.ends_at) < new Date())
) {
color = "red"
} else {
color = "green"
}
let text: string = t("general.enabled")
if (giftCard.is_disabled) {
text = t("general.disabled")
}
if (giftCard.ends_at && new Date(giftCard.ends_at) < new Date()) {
text = t("general.expired")
}
const recipient = giftCard.metadata?.email as string | undefined
const currencyCode = giftCard.region.currency_code.toUpperCase()
const nativeSymbol = currencies[currencyCode].symbol_native
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-2">
<Heading>{giftCard.code}</Heading>
<Copy content={giftCard.code} className="text-ui-fg-muted" />
</div>
<div className="flex items-center gap-x-2">
<StatusBadge color={color}>{text}</StatusBadge>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: "edit",
},
],
},
{
actions: [
{
label: t("actions.delete"),
icon: <Trash />,
onClick: handleDelete,
},
],
},
]}
/>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("giftCards.currentBalance")}
</Text>
<Text size="small" leading="compact">
{`${nativeSymbol} ${getPresentationalAmount(
giftCard.balance,
currencyCode
)} ${currencyCode}`}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("giftCards.initialBalance")}
</Text>
<Text size="small" leading="compact">
{`${nativeSymbol} ${getPresentationalAmount(
giftCard.value,
currencyCode
)} ${currencyCode}`}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("giftCards.recipient")}
</Text>
<Text size="small" leading="compact">
{recipient ?? "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.region")}
</Text>
<Link
to={`/settings/regions/${giftCard.region.id}`}
className="txt-compact-small text-ui-fg-interactive hover:text-ui-fg-interactive-hover transition-fg focus:shadow-borders-focus flex w-fit items-center gap-x-1.5 rounded-[4px] outline-none"
>
<span>{giftCard.region.name}</span>
<ArrowUpRightOnBox />
</Link>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.order")}
</Text>
{giftCard.order ? (
<Link
to={`/orders/${giftCard.order.id}`}
className="txt-compact-small text-ui-fg-interactive hover:text-ui-fg-interactive-hover transition-fg focus:shadow-borders-focus flex w-fit items-center gap-x-1.5 rounded-[4px] outline-none"
>
<span>{`#${giftCard.order.display_id}`}</span>
<ArrowUpRightOnBox />
</Link>
) : (
<Text size="small" leading="compact">
-
</Text>
)}
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.issuedDate")}
</Text>
<Text size="small" leading="compact">
{format(new Date(giftCard.created_at), "dd MMM yyyy")}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.expiryDate")}
</Text>
<Text size="small" leading="compact">
{giftCard.ends_at
? format(new Date(giftCard.ends_at), "dd MMM yyyy")
: "-"}
</Text>
</div>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./gift-card-general-section"

View File

@@ -0,0 +1,33 @@
import { useAdminGiftCard } from "medusa-react"
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { GiftCardGeneralSection } from "./components/gift-card-general-section"
import { giftCardLoader } from "./loader"
export const GiftCardDetail = () => {
const { id } = useParams()
const initialData = useLoaderData() as Awaited<
ReturnType<typeof giftCardLoader>
>
const { gift_card, isLoading, isError, error } = useAdminGiftCard(id!, {
initialData,
})
if (isLoading || !gift_card) {
return null
}
if (isError) {
throw error
}
return (
<div className="flex flex-col gap-y-2">
<GiftCardGeneralSection giftCard={gift_card} />
<JsonViewSection data={gift_card} />
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { GiftCardDetail as Component } from "./gift-card-detail"
export { giftCardLoader as loader } from "./loader"

View File

@@ -0,0 +1,21 @@
import { AdminGiftCardsRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { adminProductKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
const giftCardDetailQuery = (id: string) => ({
queryKey: adminProductKeys.detail(id),
queryFn: async () => medusa.admin.giftCards.retrieve(id),
})
export const giftCardLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = giftCardDetailQuery(id!)
return (
queryClient.getQueryData<Response<AdminGiftCardsRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,277 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { GiftCard } from "@medusajs/medusa"
import {
Button,
CurrencyInput,
DatePicker,
Drawer,
Select,
Switch,
Text,
} from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useAdminRegions, useAdminUpdateGiftCard } from "medusa-react"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import { currencies } from "../../../../../lib/currencies"
import { isAxiosError } from "../../../../../lib/is-axios-error"
import {
getDbAmount,
getPresentationalAmount,
} from "../../../../../lib/money-amount-helpers"
type EditGiftCardFormProps = {
giftCard: GiftCard
onSuccessfulSubmit: () => void
subscribe: (state: boolean) => void
}
const EditGiftCardSchema = zod.object({
region_id: zod.string(),
balance: zod.string(),
is_enabled: zod.boolean(),
ends_at: zod.date().nullable(),
})
export const EditGiftCardForm = ({
giftCard,
onSuccessfulSubmit,
subscribe,
}: EditGiftCardFormProps) => {
const { t } = useTranslation()
const [showDateFields, setShowDateFields] = useState(!!giftCard.ends_at)
const form = useForm<zod.infer<typeof EditGiftCardSchema>>({
defaultValues: {
region_id: giftCard.region_id,
balance: getPresentationalAmount(
giftCard.balance,
giftCard.region.currency_code
).toString(),
is_enabled: !giftCard.is_disabled,
ends_at: giftCard.ends_at ? new Date(giftCard.ends_at) : null,
},
resolver: zodResolver(EditGiftCardSchema),
})
const {
formState: { isDirty },
setValue,
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty, subscribe])
const { regions } = useAdminRegions({
limit: 1000,
fields: "id,name",
})
const { mutateAsync, isLoading } = useAdminUpdateGiftCard(giftCard.id)
const handleOpenChange = (open: boolean) => {
if (!open) {
setValue("ends_at", null, {
shouldDirty: true,
})
}
setShowDateFields(open)
}
const handleSubmit = form.handleSubmit(async (data) => {
const newBalance = getDbAmount(
parseFloat(data.balance),
giftCard.region.currency_code
)
if (newBalance > giftCard.value) {
form.setError("balance", {
type: "manual",
message: t("giftCards.balanceHigherThanValue"),
})
return
}
if (newBalance < 0) {
form.setError("balance", {
type: "manual",
message: t("giftCards.balanceLowerThanZero"),
})
return
}
await mutateAsync(
{
region_id: data.region_id,
balance: getDbAmount(
parseFloat(data.balance),
giftCard.region.currency_code
),
is_disabled: !data.is_enabled,
ends_at: data.ends_at,
},
{
onSuccess: () => {
onSuccessfulSubmit()
},
onError: (error) => {
if (isAxiosError(error)) {
form.setError("balance", {
type: "manual",
message: error.response?.data.message,
})
}
},
}
)
})
return (
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<Drawer.Body className="flex flex-1 flex-col gap-y-8 overflow-auto">
<Form.Field
control={form.control}
name="balance"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.balance")}</Form.Label>
<Form.Control>
<CurrencyInput
code={giftCard.region.currency_code.toUpperCase()}
symbol={`/ ${getPresentationalAmount(
giftCard.value,
giftCard.region.currency_code
)} ${
currencies[giftCard.region.currency_code.toUpperCase()]
.symbol_native
}`}
min={0}
onValueChange={onChange}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="region_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.region")}</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{regions?.map((region) => (
<Select.Item key={region.id} value={region.id}>
{region.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.Hint>{t("giftCards.regionHint")}</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="is_enabled"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label>{t("general.enabled")}</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
</div>
<Form.Hint className="!mt-1">
{t("giftCards.enabledHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="ends_at"
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
return (
<Form.Item>
<Collapsible.Root open={showDateFields}>
<div className="flex flex-col gap-y-1">
<div className="flex items-center justify-between">
<Form.Label optional>
{t("fields.expiryDate")}
</Form.Label>
<Switch
checked={showDateFields}
onCheckedChange={handleOpenChange}
/>
</div>
<Text
size="small"
className="text-ui-fg-subtle text-pretty"
>
{t("giftCards.expiryDateHint")}
</Text>
</div>
<Collapsible.Content>
<div className="pt-4">
<Form.Control>
<DatePicker
value={value ?? undefined}
onChange={(v) => {
onChange(v ?? null)
}}
{...field}
/>
</Form.Control>
</div>
</Collapsible.Content>
</Collapsible.Root>
</Form.Item>
)
}}
/>
</Drawer.Body>
<Drawer.Footer>
<div className="flex items-center gap-x-2">
<Drawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</Drawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</Drawer.Footer>
</form>
</Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-gift-card-form"

View File

@@ -0,0 +1,39 @@
import { Drawer, Heading } from "@medusajs/ui"
import { useAdminGiftCard } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { EditGiftCardForm } from "./components/edit-gift-card-form"
export const GiftCardEdit = () => {
const { id } = useParams()
const { t } = useTranslation()
const [open, onOpenChange, subscribe] = useRouteModalState()
const { gift_card, isLoading, isError, error } = useAdminGiftCard(id!)
const handleSuccessfulSubmit = () => {
onOpenChange(false, true)
}
if (isError) {
throw error
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<Drawer.Content>
<Drawer.Header>
<Heading>{t("giftCards.editGiftCard")}</Heading>
</Drawer.Header>
{!isLoading && gift_card && (
<EditGiftCardForm
giftCard={gift_card}
onSuccessfulSubmit={handleSuccessfulSubmit}
subscribe={subscribe}
/>
)}
</Drawer.Content>
</Drawer>
)
}

View File

@@ -0,0 +1 @@
export { GiftCardEdit as Component } from "./gift-card-edit"

View File

@@ -0,0 +1,68 @@
import { Button, Container, Heading } from "@medusajs/ui"
import { useAdminGiftCards } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useGiftCardTableColumns } from "./use-gift-card-table-columns"
import { useGiftCardTableFilters } from "./use-gift-card-table-filters"
import { useGiftCardTableQuery } from "./use-gift-card-table-query"
const PAGE_SIZE = 20
export const GiftCardListTable = () => {
const { t } = useTranslation()
const { searchParams, raw } = useGiftCardTableQuery({
pageSize: PAGE_SIZE,
})
const { gift_cards, count, isError, error, isLoading } = useAdminGiftCards(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const filters = useGiftCardTableFilters()
const columns = useGiftCardTableColumns()
const { table } = useDataTable({
data: gift_cards ?? [],
columns,
enablePagination: true,
count,
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("giftCards.domain")}</Heading>
<div className="flex items-center gap-x-2">
<Button size="small" variant="secondary" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
</div>
</div>
<DataTable
columns={columns}
table={table}
pagination
navigateTo={(row) => `/gift-cards/${row.original.id}`}
filters={filters}
count={count}
search
isLoading={isLoading}
rowCount={PAGE_SIZE}
orderBy={["created_at", "updated_at"]}
queryObject={raw}
/>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./gift-card-list-table"

View File

@@ -0,0 +1,136 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GiftCard } from "@medusajs/medusa"
import { Badge, usePrompt } from "@medusajs/ui"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteGiftCard } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DateCell } from "../../../../../components/table/table-cells/common/date-cell"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
import { DisplayIdCell } from "../../../../../components/table/table-cells/order/display-id-cell"
const columnHelper = createColumnHelper<GiftCard>()
export const useGiftCardTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("code", {
header: t("fields.code"),
cell: ({ getValue }) => {
return (
<Badge size="2xsmall" className="overflow-hidden truncate">
{getValue()}
</Badge>
)
},
}),
columnHelper.accessor("order", {
header: t("fields.order"),
cell: ({ getValue }) => {
const order = getValue()
return <DisplayIdCell displayId={order?.display_id} />
},
}),
columnHelper.accessor("region", {
header: t("fields.region"),
cell: ({ row }) => {
return row.original.region.name
},
}),
columnHelper.accessor("is_disabled", {
header: t("fields.status"),
cell: ({ getValue }) => {
const isDisabled = getValue()
return (
<StatusCell color={isDisabled ? "red" : "green"}>
{isDisabled ? t("general.disabled") : t("general.enabled")}
</StatusCell>
)
},
}),
columnHelper.accessor("created_at", {
header: t("fields.dateIssued"),
cell: ({ getValue }) => {
return <DateCell date={getValue()} />
},
}),
columnHelper.accessor("value", {
header: t("giftCards.initialBalance"),
cell: ({ getValue, row }) => {
const currencyCode = row.original.region.currency_code
const value = getValue()
return <MoneyAmountCell amount={value} currencyCode={currencyCode} />
},
}),
columnHelper.accessor("balance", {
header: t("giftCards.currentBalance"),
cell: ({ getValue, row }) => {
const currencyCode = row.original.region.currency_code
const value = getValue()
return <MoneyAmountCell amount={value} currencyCode={currencyCode} />
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <GiftCardActions giftCard={row.original} />,
}),
],
[t]
) as ColumnDef<GiftCard>[]
}
const GiftCardActions = ({ giftCard }: { giftCard: GiftCard }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteGiftCard(giftCard.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("giftCards.deleteGiftCardWarning", {
code: giftCard.code,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/gift-cards/${giftCard.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useGiftCardTableFilters = () => {
const { t } = useTranslation()
let filters: Filter[] = []
const dateFilters: Filter[] = [
{ label: t("fields.createdAt"), key: "created_at" },
{ label: t("fields.updatedAt"), key: "updated_at" },
].map((f) => ({
key: f.key,
label: f.label,
type: "date",
}))
filters = [...filters, ...dateFilters]
return filters
}

View File

@@ -0,0 +1,19 @@
import { AdminGetGiftCardsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useGiftCardTableQuery = ({ pageSize }: { pageSize: number }) => {
const queryObject = useQueryParams(["offset", "q"])
const { offset, q } = queryObject
const searchParams: AdminGetGiftCardsParams = {
limit: pageSize,
offset: offset ? Number(offset) : 0,
q,
}
return {
searchParams,
raw: queryObject,
}
}

View File

@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom"
import { GiftCardListTable } from "./components/gift-card-list-table"
export const GiftCardList = () => {
return (
<div className="flex flex-col gap-y-2">
<GiftCardListTable />
<Outlet />
</div>
)
}

View File

@@ -0,0 +1 @@
export { GiftCardList as Component } from "./gift-card-list"

View File

@@ -1 +0,0 @@
export { GiftCardList as Component } from "./list";

View File

@@ -1,9 +0,0 @@
import { Container, Heading } from "@medusajs/ui";
export const GiftCardList = () => {
return (
<Container>
<Heading>Gift Card List</Heading>
</Container>
);
};

View File

@@ -65,11 +65,11 @@ export const CreateLocationForm = () => {
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</FocusModal.Close>
<Button type="submit" size="small" isLoading={isLoading}>
{t("general.save")}
{t("actions.save")}
</Button>
</div>
</FocusModal.Header>

View File

@@ -163,8 +163,8 @@ const SalesChannelActions = ({
const res = await prompt({
title: t("general.areYouSure"),
description: t("locations.removeSalesChannelsWarning", { count: 1 }),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -184,12 +184,12 @@ const SalesChannelActions = ({
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
label: t("actions.edit"),
to: `/settings/sales-channels/${salesChannel.id}/edit`,
},
{
icon: <Trash />,
label: t("general.delete"),
label: t("actions.delete"),
onClick: handleDelete,
},
],

View File

@@ -206,10 +206,10 @@ export const EditLocationForm = ({ location }: EditLocationFormProps) => {
<div className="flex items-center justify-end gap-x-2">
<Drawer.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
{t("actions.cancel")}
</Button>
</Drawer.Close>
<Button size="small">{t("general.save")}</Button>
<Button size="small">{t("actions.save")}</Button>
</div>
</Drawer.Footer>
</form>

View File

@@ -85,7 +85,7 @@ export const LocationsListTable = () => {
<div>
<Link to="create">
<Button size="small" variant="secondary">
{t("general.create")}
{t("actions.create")}
</Button>
</Link>
</div>
@@ -176,8 +176,8 @@ const LocationActions = ({
}),
verificationText: location.name,
verificationInstruction: t("general.typeToConfirm"),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
@@ -194,12 +194,12 @@ const LocationActions = ({
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
label: t("actions.edit"),
to: `/settings/locations/${location.id}/edit`,
},
{
icon: <Trash />,
label: t("general.delete"),
label: t("actions.delete"),
onClick: handleDelete,
},
],

Some files were not shown because too many files have changed in this diff Show More