refactor: more in checkout and ckflow
This commit is contained in:
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
||||
import { flightTicketVM } from "$lib/domains/ticket/view/ticket.vm.svelte";
|
||||
|
||||
async function onPriceUpdateConfirm() {
|
||||
if (!ckFlowVM.updatedPrices) {
|
||||
return;
|
||||
}
|
||||
await flightTicketVM.updateTicketPrices(ckFlowVM.updatedPrices);
|
||||
ckFlowVM.clearUpdatedPrices();
|
||||
}
|
||||
|
||||
function cancelBooking() {
|
||||
window.location.replace("/search");
|
||||
}
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
open = !!ckFlowVM.updatedPrices;
|
||||
});
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root bind:open>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>The price has changed!</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Ticket prices change throughout the day and, unfortunately, the
|
||||
price has been changed since last we had checked. You can continue
|
||||
with the new price or check out alternative trips.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<Title size="h5" color="black">New Price</Title>
|
||||
|
||||
<Title size="h4" color="black" weight="semibold">
|
||||
{convertAndFormatCurrency(
|
||||
ckFlowVM.updatedPrices?.displayPrice ?? 0,
|
||||
)}
|
||||
</Title>
|
||||
</div>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel
|
||||
disabled={flightTicketVM.updatingPrices}
|
||||
onclick={() => cancelBooking()}
|
||||
>
|
||||
Go Back
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
disabled={flightTicketVM.updatingPrices}
|
||||
onclick={() => onPriceUpdateConfirm()}
|
||||
>
|
||||
<ButtonLoadableText
|
||||
loading={flightTicketVM.updatingPrices}
|
||||
text={"Continue"}
|
||||
loadingText={"Updating..."}
|
||||
/>
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
customerInfoModel,
|
||||
type CustomerInfoModel,
|
||||
} from "$lib/domains/passengerinfo/data/entities";
|
||||
} from "$lib/domains/customerinfo/data";
|
||||
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||
import {
|
||||
paymentInfoPayloadModel,
|
||||
type PaymentInfoPayload,
|
||||
} from "$lib/domains/paymentinfo/data/entities";
|
||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
||||
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { PUBLIC_FRONTEND_URL } from "$lib/core/constants";
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { get } from "svelte/store";
|
||||
import type {
|
||||
CreateProductPayload,
|
||||
ProductModel,
|
||||
UpdateProductPayload,
|
||||
} from "./data";
|
||||
|
||||
export class ProductViewModel {
|
||||
products = $state<ProductModel[]>([]);
|
||||
loading = $state(false);
|
||||
formLoading = $state(false);
|
||||
|
||||
// Selected product for edit/delete operations
|
||||
selectedProduct = $state<ProductModel | null>(null);
|
||||
|
||||
async fetchProducts() {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const result = await api.product.getAllProducts.query();
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
this.products = result.data || [];
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Failed to fetch products", {
|
||||
description: "Please try again later",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createProduct(payload: CreateProductPayload) {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
this.formLoading = true;
|
||||
try {
|
||||
const result = await api.product.createProduct.mutate(payload);
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success("Product created successfully");
|
||||
await this.fetchProducts();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error("Failed to create product", {
|
||||
description: "Please try again later",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.formLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateProduct(payload: UpdateProductPayload) {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
this.formLoading = true;
|
||||
try {
|
||||
const result = await api.product.updateProduct.mutate(payload);
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success("Product updated successfully");
|
||||
await this.fetchProducts();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error("Failed to update product", {
|
||||
description: "Please try again later",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.formLoading = false;
|
||||
this.selectedProduct = null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteProduct(id: number) {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
this.formLoading = true;
|
||||
try {
|
||||
const result = await api.product.deleteProduct.mutate({ id });
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success("Product deleted successfully");
|
||||
await this.fetchProducts();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error("Failed to delete product", {
|
||||
description: "Please try again later",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.formLoading = false;
|
||||
this.selectedProduct = null;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProductLinkId(id: number) {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.product.refreshProductLinkId.mutate({ id });
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
toast.success("Link ID refreshed successfully");
|
||||
await this.fetchProducts();
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
toast.error("Failed to refresh link ID", {
|
||||
description: "Please try again later",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
selectProduct(product: ProductModel) {
|
||||
this.selectedProduct = { ...product };
|
||||
}
|
||||
|
||||
clearSelectedProduct() {
|
||||
this.selectedProduct = null;
|
||||
}
|
||||
|
||||
getDefaultProduct(): CreateProductPayload {
|
||||
return {
|
||||
title: "",
|
||||
description: "",
|
||||
longDescription: "",
|
||||
price: 0,
|
||||
discountPrice: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async copyLinkToClipboard(linkId: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
`${PUBLIC_FRONTEND_URL}/${linkId}`,
|
||||
);
|
||||
toast.success("Frontend link copied to clipboard");
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error("Failed to copy link ID", {
|
||||
description: "Please try copying manually",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const productVM = new ProductViewModel();
|
||||
@@ -1,111 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import Input from "$lib/components/ui/input/input.svelte";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import type {
|
||||
CreateProductPayload,
|
||||
UpdateProductPayload,
|
||||
} from "$lib/domains/product/data";
|
||||
|
||||
export let formData: CreateProductPayload | UpdateProductPayload;
|
||||
export let loading = false;
|
||||
export let onSubmit: () => void;
|
||||
export let onCancel: () => void;
|
||||
|
||||
const isNewProduct = !("id" in formData);
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={onSubmit} class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="space-y-2">
|
||||
<Label for="title">Product Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={formData.title}
|
||||
placeholder="e.g. Premium Flight Package"
|
||||
maxlength={64}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={formData.description}
|
||||
placeholder="Brief description of the product"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Long Description -->
|
||||
<div class="space-y-2">
|
||||
<Label for="longDescription">Long Description</Label>
|
||||
<Textarea
|
||||
id="longDescription"
|
||||
bind:value={formData.longDescription}
|
||||
placeholder="Detailed description of the product"
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Price -->
|
||||
<div class="space-y-2">
|
||||
<Label for="price">Price</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
bind:value={formData.price}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<span class="text-gray-500">$</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discount Price -->
|
||||
<div class="space-y-2">
|
||||
<Label for="discountPrice">Discount Price</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="discountPrice"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
bind:value={formData.discountPrice}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<span class="text-gray-500">$</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end space-x-2">
|
||||
<Button variant="outline" onclick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
Processing...
|
||||
{:else}
|
||||
{isNewProduct ? "Create Product" : "Update Product"}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,288 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Loader from "$lib/components/atoms/loader.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { productVM } from "$lib/domains/product/product.vm.svelte";
|
||||
import { Copy, Pencil, PlusIcon, RefreshCw, Trash2 } from "@lucide/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import type { CreateProductPayload } from "../data";
|
||||
import ProductForm from "./product-form.svelte";
|
||||
|
||||
let createDialogOpen = $state(false);
|
||||
let editDialogOpen = $state(false);
|
||||
let deleteDialogOpen = $state(false);
|
||||
|
||||
let newProductData = $state<CreateProductPayload>(
|
||||
productVM.getDefaultProduct(),
|
||||
);
|
||||
|
||||
async function handleCreateProduct() {
|
||||
const success = await productVM.createProduct(newProductData);
|
||||
if (success) {
|
||||
createDialogOpen = false;
|
||||
newProductData = productVM.getDefaultProduct();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateProduct() {
|
||||
if (!productVM.selectedProduct) return;
|
||||
|
||||
const success = await productVM.updateProduct({
|
||||
id: productVM.selectedProduct.id! ?? -1,
|
||||
...productVM.selectedProduct,
|
||||
});
|
||||
if (success) {
|
||||
editDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteProduct() {
|
||||
if (!productVM.selectedProduct) return;
|
||||
|
||||
const success = await productVM.deleteProduct(
|
||||
productVM.selectedProduct.id!,
|
||||
);
|
||||
if (success) {
|
||||
deleteDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshLinkId(id: number) {
|
||||
await productVM.refreshProductLinkId(id);
|
||||
}
|
||||
|
||||
async function handleCopyLink(linkId: string) {
|
||||
await productVM.copyLinkToClipboard(linkId);
|
||||
}
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(async () => {
|
||||
productVM.fetchProducts();
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<Title size="h3" weight="semibold">Products</Title>
|
||||
<Button onclick={() => (createDialogOpen = true)}>
|
||||
<PlusIcon class="mr-2 h-4 w-4" />
|
||||
New Product
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if productVM.loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<Loader />
|
||||
</div>
|
||||
{:else if productVM.products.length === 0}
|
||||
<div class="rounded-lg border py-10 text-center">
|
||||
<p class="text-gray-500">
|
||||
No products found. Create your first product to get started.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>Product</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>Pricing</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>Link ID</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each productVM.products as product}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<div class="font-medium text-gray-900">
|
||||
{product.title}
|
||||
</div>
|
||||
<div
|
||||
class="max-w-48 overflow-hidden text-ellipsis text-sm text-gray-500"
|
||||
>
|
||||
{product.description}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm">
|
||||
<span class="text-gray-500">Price:</span>
|
||||
<span class="ml-1 font-medium"
|
||||
>{formatPrice(product.price)}</span
|
||||
>
|
||||
</div>
|
||||
{#if product.discountPrice > 0 && product.discountPrice < product.price}
|
||||
<Badge variant="success" class="w-fit">
|
||||
Discount: {formatPrice(
|
||||
product.discountPrice,
|
||||
)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{product.linkId}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => handleCopyLink(product.linkId)}
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="flex w-full items-center justify-end gap-2 whitespace-nowrap px-6 py-4 text-right"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => handleRefreshLinkId(product.id!)}
|
||||
title="Refresh Link ID"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
productVM.selectProduct(product);
|
||||
editDialogOpen = true;
|
||||
}}
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
productVM.selectProduct(product);
|
||||
deleteDialogOpen = true;
|
||||
}}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Create Product Dialog -->
|
||||
<Dialog.Root bind:open={createDialogOpen}>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create New Product</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Create a new product for your catalog.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<ProductForm
|
||||
formData={newProductData}
|
||||
loading={productVM.formLoading}
|
||||
onSubmit={handleCreateProduct}
|
||||
onCancel={() => (createDialogOpen = false)}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Edit Product Dialog -->
|
||||
<Dialog.Root bind:open={editDialogOpen}>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit Product</Dialog.Title>
|
||||
<Dialog.Description>Update this product's details.</Dialog.Description
|
||||
>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if productVM.selectedProduct}
|
||||
<ProductForm
|
||||
formData={productVM.selectedProduct}
|
||||
loading={productVM.formLoading}
|
||||
onSubmit={handleUpdateProduct}
|
||||
onCancel={() => (editDialogOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={deleteDialogOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Product</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to delete this product? This action cannot
|
||||
be undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
{#if productVM.selectedProduct}
|
||||
<div class="mb-4 rounded-md bg-gray-100 p-4">
|
||||
<div class="font-bold">{productVM.selectedProduct.title}</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{productVM.selectedProduct.description}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<Badge
|
||||
>Price: {formatPrice(
|
||||
productVM.selectedProduct.price,
|
||||
)}</Badge
|
||||
>
|
||||
{#if productVM.selectedProduct.discountPrice > 0}
|
||||
<Badge variant="success"
|
||||
>Discount: {formatPrice(
|
||||
productVM.selectedProduct.discountPrice,
|
||||
)}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={() => (deleteDialogOpen = false)}>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={handleDeleteProduct}
|
||||
disabled={productVM.formLoading}
|
||||
>
|
||||
{productVM.formLoading ? "Deleting..." : "Delete"}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
Reference in New Issue
Block a user