feat(dashboard, js-sdk): customer page transfer order + cancel request in timeline (#10250)
**What** - request order transfer from admin customers details page - cancel transfer request from order timeline --- CLOSES CMRC-730
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export * from "./transfer-ownership-form"
|
||||
@@ -1,264 +0,0 @@
|
||||
import { Select, Text, clx } from "@medusajs/ui"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { format } from "date-fns"
|
||||
import { debounce } from "lodash"
|
||||
import { PropsWithChildren, useCallback, useEffect, useState } from "react"
|
||||
import { Control, useWatch } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useCustomer } from "../../../hooks/api/customers"
|
||||
import { sdk } from "../../../lib/client"
|
||||
import { getStylizedAmount } from "../../../lib/money-amount-helpers"
|
||||
import {
|
||||
getOrderFulfillmentStatus,
|
||||
getOrderPaymentStatus,
|
||||
} from "../../../lib/order-helpers"
|
||||
import { TransferOwnershipSchema } from "../../../lib/schemas"
|
||||
import { Form } from "../../common/form"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
import { Combobox } from "../../inputs/combobox"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type TransferOwnerShipFieldValues = z.infer<typeof TransferOwnershipSchema>
|
||||
|
||||
type TransferOwnerShipFormProps = {
|
||||
/**
|
||||
* The Order or DraftOrder to transfer ownership of.
|
||||
*/
|
||||
order: HttpTypes.AdminOrder
|
||||
/**
|
||||
* React Hook Form control object.
|
||||
*/
|
||||
control: Control<TransferOwnerShipFieldValues>
|
||||
}
|
||||
|
||||
const isOrder = (
|
||||
order: HttpTypes.AdminOrder
|
||||
): order is HttpTypes.AdminOrder => {
|
||||
return "customer" in order
|
||||
}
|
||||
|
||||
export const TransferOwnerShipForm = ({
|
||||
order,
|
||||
control,
|
||||
}: TransferOwnerShipFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
|
||||
const isOrderType = isOrder(order)
|
||||
const currentOwnerId = useWatch({
|
||||
control,
|
||||
name: "current_owner_id",
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((query) => setDebouncedQuery(query), 300),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdate(query)
|
||||
|
||||
return () => debouncedUpdate.cancel()
|
||||
}, [query, debouncedUpdate])
|
||||
|
||||
const {
|
||||
customer: owner,
|
||||
isLoading: isLoadingOwner,
|
||||
isError: isOwnerError,
|
||||
error: ownerError,
|
||||
} = useCustomer(currentOwnerId)
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
|
||||
queryKey: ["customers", debouncedQuery],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const res = await sdk.admin.customer.list({
|
||||
q: debouncedQuery,
|
||||
limit: 10,
|
||||
offset: pageParam,
|
||||
has_account: true, // Only show customers with confirmed accounts
|
||||
})
|
||||
return res
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreCustomersExist =
|
||||
lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreCustomersExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
})
|
||||
|
||||
const createLabel = (customer?: HttpTypes.AdminCustomer) => {
|
||||
if (!customer) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const { first_name, last_name, email } = customer
|
||||
|
||||
const name = [first_name, last_name].filter(Boolean).join(" ")
|
||||
|
||||
if (name) {
|
||||
return `${name} (${email})`
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
const ownerReady = !isLoadingOwner && owner
|
||||
|
||||
const options =
|
||||
data?.pages
|
||||
.map((p) =>
|
||||
p.customers.map((c) => ({
|
||||
label: createLabel(c),
|
||||
value: c.id,
|
||||
}))
|
||||
)
|
||||
.flat() || []
|
||||
|
||||
if (isOwnerError) {
|
||||
throw ownerError
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{isOrderType
|
||||
? t("transferOwnership.details.order")
|
||||
: t("transferOwnership.details.draft")}
|
||||
</Text>
|
||||
{isOrderType ? (
|
||||
<OrderDetailsTable order={order} />
|
||||
) : (
|
||||
<DraftOrderDetailsTable draft={order} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("transferOwnership.currentOwner.label")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("transferOwnership.currentOwner.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
{ownerReady ? (
|
||||
<Select defaultValue={owner.id} disabled>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={owner.id}>{createLabel(owner)}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
) : (
|
||||
<Skeleton className="h-8 w-full rounded-md" />
|
||||
)}
|
||||
</div>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="new_owner_id"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col">
|
||||
<Form.Label>{t("transferOwnership.newOwner.label")}</Form.Label>
|
||||
<Form.Hint>{t("transferOwnership.newOwner.hint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
searchValue={query}
|
||||
onSearchValueChange={setQuery}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
options={options}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OrderDetailsTable = ({ order }: { order: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { label: fulfillmentLabel } = getOrderFulfillmentStatus(
|
||||
t,
|
||||
order.fulfillment_status
|
||||
)
|
||||
|
||||
const { label: paymentLabel } = getOrderPaymentStatus(t, order.payment_status)
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Row label={t("fields.order")} value={`#${order.display_id}`} />
|
||||
<DateRow date={order.created_at} />
|
||||
<Row label={t("fields.fulfillment")} value={fulfillmentLabel} />
|
||||
<Row label={t("fields.payment")} value={paymentLabel} />
|
||||
<TotalRow total={order.total || 0} currencyCode={order.currency_code} />
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Create type for Draft Order when we have it
|
||||
const DraftOrderDetailsTable = ({ draft }: { draft: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Row label={t("fields.draft")} value={`#${draft.display_id}`} />
|
||||
<DateRow date={draft.created_at} />
|
||||
<Row
|
||||
label={t("fields.status")}
|
||||
value={t(`draftOrders.status.${draft.status}`)}
|
||||
/>
|
||||
|
||||
{/* TODO: This will likely change. We don't use carts for draft orders any longer. */}
|
||||
{/* <TotalRow
|
||||
total={draft.cart.total || 0}
|
||||
currencyCode={draft.cart.region.currency_code}
|
||||
/> */}
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const DateRow = ({ date }: { date: string | Date }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const formattedDate = format(new Date(date), "dd MMM yyyy")
|
||||
|
||||
return <Row label={t("fields.date")} value={formattedDate} />
|
||||
}
|
||||
|
||||
const TotalRow = ({
|
||||
total,
|
||||
currencyCode,
|
||||
}: {
|
||||
total: number
|
||||
currencyCode: string
|
||||
}) => {
|
||||
return <Row label="Total" value={getStylizedAmount(total, currencyCode)} />
|
||||
}
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string }) => {
|
||||
return (
|
||||
<div className="txt-compact-small grid grid-cols-2 divide-x">
|
||||
<div className="text-ui-fg-muted px-2 py-1.5">{label}</div>
|
||||
<div className="text-ui-fg-subtle px-2 py-1.5">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Table = ({ children }: PropsWithChildren) => {
|
||||
return <div className={clx("divide-y rounded-lg border")}>{children}</div>
|
||||
}
|
||||
@@ -286,6 +286,31 @@ export const useRequestTransferOrder = (
|
||||
queryKey: ordersQueryKeys.preview(orderId),
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersQueryKeys.changes(orderId),
|
||||
})
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useCancelOrderTransfer = (
|
||||
orderId: string,
|
||||
options?: UseMutationOptions<any, FetchError, void>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: () => sdk.admin.order.cancelTransfer(orderId),
|
||||
onSuccess: (data: any, variables: any, context: any) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersQueryKeys.preview(orderId),
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersQueryKeys.changes(orderId),
|
||||
})
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -515,6 +515,9 @@
|
||||
},
|
||||
"import": {
|
||||
"type": "string"
|
||||
},
|
||||
"cannotUndo": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -558,7 +561,8 @@
|
||||
"logout",
|
||||
"hide",
|
||||
"export",
|
||||
"import"
|
||||
"import",
|
||||
"cannotUndo"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -5327,11 +5331,15 @@
|
||||
},
|
||||
"confirmed": {
|
||||
"type": "string"
|
||||
},
|
||||
"declined": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"requested",
|
||||
"confirmed"
|
||||
"confirmed",
|
||||
"declined"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
||||
@@ -136,7 +136,8 @@
|
||||
"logout": "Logout",
|
||||
"hide": "Hide",
|
||||
"export": "Export",
|
||||
"import": "Import"
|
||||
"import": "Import",
|
||||
"cannotUndo": "This action cannot be undone"
|
||||
},
|
||||
"operators": {
|
||||
"in": "In"
|
||||
@@ -1293,7 +1294,8 @@
|
||||
},
|
||||
"transfer": {
|
||||
"requested": "Order transfer #{{transferId}} requested",
|
||||
"confirmed": "Order transfer #{{transferId}} confirmed"
|
||||
"confirmed": "Order transfer #{{transferId}} confirmed",
|
||||
"declined": "Order transfer #{{transferId}} declined"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -603,6 +603,11 @@ export const RouteMap: RouteObject[] = [
|
||||
"../../routes/customers/customers-add-customer-group"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ":order_id/transfer",
|
||||
lazy: () =>
|
||||
import("../../routes/orders/order-request-transfer"),
|
||||
},
|
||||
{
|
||||
path: "metadata/edit",
|
||||
lazy: () =>
|
||||
|
||||
@@ -120,11 +120,10 @@ const useColumns = () => {
|
||||
return useMemo(
|
||||
() => [
|
||||
...base,
|
||||
// TODO: REENABLE WHEN TRANSFER OWNERSHIP IS IMPLEMENTED
|
||||
// columnHelper.display({
|
||||
// id: "actions",
|
||||
// cell: ({ row }) => <CustomerOrderActions order={row.original} />,
|
||||
// }),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => <CustomerOrderActions order={row.original} />,
|
||||
}),
|
||||
],
|
||||
[base]
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./transfer-customer-order-ownership-form"
|
||||
@@ -1,72 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
import { TransferOwnerShipForm } from "../../../../../components/forms/transfer-ownership-form"
|
||||
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
|
||||
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||
import { TransferOwnershipSchema } from "../../../../../lib/schemas"
|
||||
|
||||
type TransferCustomerOrderOwnershipFormProps = {
|
||||
order: HttpTypes.AdminOrder
|
||||
}
|
||||
|
||||
export const TransferCustomerOrderOwnershipForm = ({
|
||||
order,
|
||||
}: TransferCustomerOrderOwnershipFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<z.infer<typeof TransferOwnershipSchema>>({
|
||||
defaultValues: {
|
||||
current_owner_id: order.customer_id ?? undefined,
|
||||
new_owner_id: "",
|
||||
},
|
||||
resolver: zodResolver(TransferOwnershipSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading } = {
|
||||
mutateAsync: async (args: any) => {},
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
mutateAsync(
|
||||
{
|
||||
customer_id: values.new_owner_id,
|
||||
}
|
||||
// {
|
||||
// onSuccess: () => {
|
||||
// handleSuccess()
|
||||
// },
|
||||
// }
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<KeyboundForm
|
||||
onSubmit={handleSubmit}
|
||||
className="flex size-full flex-col overflow-hidden"
|
||||
>
|
||||
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
|
||||
<TransferOwnerShipForm order={order} control={form.control} />
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteDrawer.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button type="submit" isLoading={isLoading} size="small">
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</KeyboundForm>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
|
||||
import { RouteDrawer } from "../../../components/modals"
|
||||
import { useOrder } from "../../../hooks/api/orders"
|
||||
import { TransferCustomerOrderOwnershipForm } from "./components/transfer-customer-order-ownership-form"
|
||||
|
||||
export const CustomerTransferOwnership = () => {
|
||||
const { t } = useTranslation()
|
||||
const { order_id } = useParams()
|
||||
|
||||
const { order, isLoading, isError, error } = useOrder(order_id!)
|
||||
|
||||
const ready = !isLoading && order
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>{t("transferOwnership.header")}</Heading>
|
||||
</RouteDrawer.Header>
|
||||
{ready && <TransferCustomerOrderOwnershipForm order={order} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { CustomerTransferOwnership as Component } from "./customer-transfer-ownership"
|
||||
@@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next"
|
||||
|
||||
import { AdminOrderLineItem } from "@medusajs/types"
|
||||
import {
|
||||
useCancelOrderTransfer,
|
||||
useCustomer,
|
||||
useOrderChanges,
|
||||
useOrderLineItems,
|
||||
@@ -407,6 +408,14 @@ const useActivityItems = (order: AdminOrder): Activity[] => {
|
||||
timestamp: transfer.confirmed_at,
|
||||
})
|
||||
}
|
||||
if (transfer.declined_at) {
|
||||
items.push({
|
||||
title: t(`orders.activity.events.transfer.declined`, {
|
||||
transferId: transfer.id.slice(-7),
|
||||
}),
|
||||
timestamp: transfer.declined_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// for (const note of notes || []) {
|
||||
@@ -896,11 +905,33 @@ const TransferOrderRequestBody = ({
|
||||
}: {
|
||||
transfer: AdminOrderChange
|
||||
}) => {
|
||||
const prompt = usePrompt()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const action = transfer.actions[0]
|
||||
const { customer } = useCustomer(action.reference_id)
|
||||
|
||||
const isCompleted = !!transfer.confirmed_at
|
||||
|
||||
const { mutateAsync: cancelTransfer } = useCancelOrderTransfer(
|
||||
transfer.order_id
|
||||
)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("actions.cannotUndo"),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await cancelTransfer()
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: change original_email to customer info when action details is changed
|
||||
*/
|
||||
@@ -917,6 +948,16 @@ const TransferOrderRequestBody = ({
|
||||
? `${customer?.first_name} ${customer?.last_name}`
|
||||
: customer?.email}
|
||||
</Text>
|
||||
{!isCompleted && (
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
className="text-ui-fg-subtle h-auto px-0 leading-none hover:bg-transparent"
|
||||
variant="transparent"
|
||||
size="small"
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ export function CreateOrderTransferForm({
|
||||
>
|
||||
<RouteDrawer.Body className="flex-1 overflow-auto">
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<TransferHeader />
|
||||
<div className="flex justify-center">
|
||||
<TransferHeader />
|
||||
</div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="current_customer_details"
|
||||
|
||||
@@ -10,17 +10,25 @@ import { CreateOrderTransferForm } from "./components/create-order-transfer-form
|
||||
export const OrderRequestTransfer = () => {
|
||||
const { t } = useTranslation()
|
||||
const params = useParams()
|
||||
const { order } = useOrder(params.id!, {
|
||||
|
||||
// Form is rendered bot on the order details page and customer page so we need to pick the correct param from URL
|
||||
const orderId = (params.order_id || params.id) as string
|
||||
|
||||
const { order, isPending, isError } = useOrder(orderId, {
|
||||
fields: DEFAULT_FIELDS,
|
||||
})
|
||||
|
||||
if (!isPending && isError) {
|
||||
throw new Error("Order not found")
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>{t("orders.transfer.title")}</Heading>
|
||||
</RouteDrawer.Header>
|
||||
|
||||
<CreateOrderTransferForm order={order} />
|
||||
{order && <CreateOrderTransferForm order={order} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -211,6 +211,31 @@ export class Order {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method cancels an order transfer request. It sends a request to the
|
||||
* [Cancel Order Transfer Request](https://docs.medusajs.com/api/admin#orders_postordersidcanceltransferrequest)
|
||||
* API route.
|
||||
*
|
||||
* @param id - The order's ID.
|
||||
* @param headers - Headers to pass in the request.
|
||||
* @returns The order's details.
|
||||
*
|
||||
* @example
|
||||
* sdk.admin.order.cancelTransfer("order_123")
|
||||
* .then(({ order }) => {
|
||||
* console.log(order)
|
||||
* })
|
||||
*/
|
||||
async cancelTransfer(id: string, headers?: ClientHeaders) {
|
||||
return await this.client.fetch<HttpTypes.AdminOrderResponse>(
|
||||
`/admin/orders/${id}/transfer/cancel`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method creates a fulfillment for an order. It sends a request to the
|
||||
* [Create Fulfillment](https://docs.medusajs.com/api/admin#orders_postordersidfulfillments)
|
||||
|
||||
Reference in New Issue
Block a user