refactor: more in checkout and ckflow

This commit is contained in:
user
2025-10-21 15:54:44 +03:00
parent c0df8cae57
commit c3650f6d5e
6 changed files with 3 additions and 666 deletions

View File

@@ -1,9 +1,9 @@
import { env } from "$env/dynamic/public";
import IconLinkedInLogo from "~icons/basil/linkedin-outline";
import PlaneIcon from "~icons/hugeicons/airplane-02";
import IconInstagramLogo from "~icons/mdi/instagram";
import DocumentTextIcon from "~icons/solar/document-linear";
import HomeIcon from "~icons/solar/home-angle-2-linear";
import PlaneIcon from "~icons/hugeicons/airplane-02";
export const TRANSITION_COLORS = "transition-colors duration-150 ease-in-out";
export const TRANSITION_ALL = "transition-all duration-150 ease-in-out";

View File

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

View File

@@ -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";

View File

@@ -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();

View File

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

View File

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