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

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