admin side for now | 🔄 started FE

This commit is contained in:
user
2025-10-21 13:11:31 +03:00
parent 3232542de1
commit 5f4e9fc7fc
65 changed files with 3605 additions and 1508 deletions

View File

@@ -1,200 +0,0 @@
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import type {
CouponModel,
CreateCouponPayload,
UpdateCouponPayload,
} from "./data";
import { DiscountType } from "./data";
export class CouponViewModel {
coupons = $state<CouponModel[]>([]);
loading = $state(false);
formLoading = $state(false);
// Selected coupon for edit/delete operations
selectedCoupon = $state<CouponModel | null>(null);
async fetchCoupons() {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.loading = true;
try {
const result = await api.coupon.getAllCoupons.query();
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
console.log(result);
this.coupons = result.data || [];
return true;
} catch (e) {
console.error(e);
toast.error("Failed to fetch coupons", {
description: "Please try again later",
});
return false;
} finally {
this.loading = false;
}
}
async createCoupon(payload: CreateCouponPayload) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.formLoading = true;
try {
const result = await api.coupon.createCoupon.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Coupon created successfully");
await this.fetchCoupons();
return true;
} catch (e) {
toast.error("Failed to create coupon", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
}
}
async updateCoupon(payload: UpdateCouponPayload) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.formLoading = true;
try {
const result = await api.coupon.updateCoupon.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Coupon updated successfully");
await this.fetchCoupons();
return true;
} catch (e) {
toast.error("Failed to update coupon", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
this.selectedCoupon = null;
}
}
async deleteCoupon(id: number) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.formLoading = true;
try {
const result = await api.coupon.deleteCoupon.mutate({ id });
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Coupon deleted successfully");
await this.fetchCoupons();
return true;
} catch (e) {
toast.error("Failed to delete coupon", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
this.selectedCoupon = null;
}
}
async toggleCouponStatus(id: number, isActive: boolean) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
try {
const result = await api.coupon.toggleCouponStatus.mutate({
id,
isActive,
});
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success(
`Coupon ${isActive ? "activated" : "deactivated"} successfully`,
);
await this.fetchCoupons();
return true;
} catch (e) {
toast.error(
`Failed to ${isActive ? "activate" : "deactivate"} coupon`,
{
description: "Please try again later",
},
);
return false;
}
}
selectCoupon(coupon: CouponModel) {
this.selectedCoupon = { ...coupon };
}
clearSelectedCoupon() {
this.selectedCoupon = null;
}
getDefaultCoupon(): CreateCouponPayload {
const today = new Date();
const nextMonth = new Date();
nextMonth.setMonth(today.getMonth() + 1);
return {
code: "",
description: "",
discountType: DiscountType.PERCENTAGE,
discountValue: 10,
maxUsageCount: null,
minOrderValue: null,
maxDiscountAmount: null,
startDate: today.toISOString().split("T")[0],
endDate: nextMonth.toISOString().split("T")[0],
isActive: true,
};
}
}
export const couponVM = new CouponViewModel();

View File

@@ -1 +0,0 @@
export * from "@pkg/logic/domains/coupon/data";

View File

@@ -1 +0,0 @@
export * from "@pkg/logic/domains/coupon/repository";

View File

@@ -1,52 +0,0 @@
import { createTRPCRouter } from "$lib/trpc/t";
import { z } from "zod";
import { getCouponUseCases } from "./usecases";
import { createCouponPayload, updateCouponPayload } from "./data";
import { protectedProcedure } from "$lib/server/trpc/t";
export const couponRouter = createTRPCRouter({
getAllCoupons: protectedProcedure.query(async ({}) => {
const controller = getCouponUseCases();
return controller.getAllCoupons();
}),
getCouponById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const controller = getCouponUseCases();
return controller.getCouponById(input.id);
}),
createCoupon: protectedProcedure
.input(createCouponPayload)
.mutation(async ({ input, ctx }) => {
const controller = getCouponUseCases();
return controller.createCoupon(ctx.user, input);
}),
updateCoupon: protectedProcedure
.input(updateCouponPayload)
.mutation(async ({ input }) => {
const controller = getCouponUseCases();
return controller.updateCoupon(input);
}),
deleteCoupon: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const controller = getCouponUseCases();
return controller.deleteCoupon(input.id);
}),
toggleCouponStatus: protectedProcedure
.input(z.object({ id: z.number(), isActive: z.boolean() }))
.mutation(async ({ input }) => {
const controller = getCouponUseCases();
return controller.toggleCouponStatus(input.id, input.isActive);
}),
getActiveCoupons: protectedProcedure.query(async ({}) => {
const controller = getCouponUseCases();
return controller.getActiveCoupons();
}),
});

View File

@@ -1 +0,0 @@
export * from "@pkg/logic/domains/coupon/usecases";

View File

@@ -1,187 +0,0 @@
<script lang="ts">
import Input from "$lib/components/ui/input/input.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import {
type CreateCouponPayload,
type UpdateCouponPayload,
DiscountType,
} from "$lib/domains/coupon/data";
import { Textarea } from "$lib/components/ui/textarea";
export let formData: CreateCouponPayload | UpdateCouponPayload;
export let loading = false;
export let onSubmit: () => void;
export let onCancel: () => void;
const isNewCoupon = !("id" in formData);
const discountTypes = [
{ value: DiscountType.PERCENTAGE, label: "Percentage" },
{ value: DiscountType.FIXED, label: "Fixed Amount" },
];
</script>
<form on:submit|preventDefault={onSubmit} class="space-y-6">
<div class="grid grid-cols-2 gap-4">
<!-- Code -->
<div class="space-y-2">
<Label for="code">Coupon Code</Label>
<Input
id="code"
bind:value={formData.code}
placeholder="e.g. SUMMER20"
required
/>
</div>
<!-- Discount Type -->
<div class="space-y-2">
<Label for="discountType">Discount Type</Label>
<Select.Root
type="single"
required
bind:value={formData.discountType}
>
<Select.Trigger class="w-full">
{discountTypes.find((t) => t.value === formData.discountType)
?.label || "Select type"}
</Select.Trigger>
<Select.Content>
{#each discountTypes as type}
<Select.Item value={type.value}>{type.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<!-- Discount Value -->
<div class="space-y-2">
<Label for="discountValue">
{formData.discountType === DiscountType.PERCENTAGE
? "Discount Percentage"
: "Discount Amount"}
</Label>
<div class="relative">
<Input
id="discountValue"
type="number"
min={0}
max={formData.discountType === DiscountType.PERCENTAGE
? 100
: undefined}
step="0.01"
bind:value={formData.discountValue}
required
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<span class="text-gray-500">
{formData.discountType === DiscountType.PERCENTAGE
? "%"
: "$"}
</span>
</div>
</div>
</div>
<!-- Description -->
<div class="space-y-2">
<Label for="description">Description</Label>
<Textarea
id="description"
bind:value={formData.description}
placeholder="Describe what this coupon is for"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Start Date -->
<div class="space-y-2">
<Label for="startDate">Start Date</Label>
<Input
id="startDate"
type="date"
bind:value={formData.startDate}
required
/>
</div>
<!-- End Date -->
<div class="space-y-2">
<Label for="endDate">End Date (Optional)</Label>
<Input id="endDate" type="date" bind:value={formData.endDate} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Max Usage Count -->
<div class="space-y-2">
<Label for="maxUsageCount">Max Usage Count (Optional)</Label>
<Input
id="maxUsageCount"
type="number"
min={1}
step={1}
bind:value={formData.maxUsageCount}
placeholder="Leave empty for unlimited"
/>
</div>
<!-- Max Discount Amount -->
<div class="space-y-2">
<Label for="maxDiscountAmount">Max Discount Amount (Optional)</Label>
<div class="relative">
<Input
id="maxDiscountAmount"
type="number"
min={0}
step="0.01"
bind:value={formData.maxDiscountAmount}
placeholder="Leave empty for no limit"
/>
<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>
<!-- Min Order Value -->
<div class="space-y-2">
<Label for="minOrderValue">Minimum Order Value (Optional)</Label>
<div class="relative">
<Input
id="minOrderValue"
type="number"
min={0}
step="0.01"
bind:value={formData.minOrderValue}
placeholder="Leave empty for no minimum"
/>
<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>
<!-- 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}
{isNewCoupon ? "Create Coupon" : "Update Coupon"}
{/if}
</Button>
</div>
</form>

View File

@@ -1,297 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { couponVM } from "$lib/domains/coupon/coupon.vm.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index";
import { Badge } from "$lib/components/ui/badge";
import CouponForm from "./coupon-form.svelte";
import Title from "$lib/components/atoms/title.svelte";
import {
PlusIcon,
Pencil,
Trash2,
CheckCircle,
XCircle,
} from "@lucide/svelte";
import Loader from "$lib/components/atoms/loader.svelte";
import { DiscountType, type CreateCouponPayload } from "../data";
let createDialogOpen = $state(false);
let editDialogOpen = $state(false);
let deleteDialogOpen = $state(false);
let newCouponData = $state<CreateCouponPayload>(couponVM.getDefaultCoupon());
async function handleCreateCoupon() {
const success = await couponVM.createCoupon(newCouponData);
if (success) {
createDialogOpen = false;
newCouponData = couponVM.getDefaultCoupon();
}
}
async function handleUpdateCoupon() {
if (!couponVM.selectedCoupon) return;
const success = await couponVM.updateCoupon({
id: couponVM.selectedCoupon.id! ?? -1,
...couponVM.selectedCoupon,
});
if (success) {
editDialogOpen = false;
}
}
async function handleDeleteCoupon() {
if (!couponVM.selectedCoupon) return;
const success = await couponVM.deleteCoupon(couponVM.selectedCoupon.id!);
if (success) {
deleteDialogOpen = false;
}
}
function formatDate(dateString: string | null | undefined) {
if (!dateString) return "None";
return new Date(dateString).toLocaleDateString();
}
function formatDiscountValue(type: DiscountType, value: number) {
if (type === DiscountType.PERCENTAGE) {
return `${value}%`;
} else {
return `$${value.toFixed(2)}`;
}
}
onMount(() => {
setTimeout(async () => {
couponVM.fetchCoupons();
}, 1000);
});
</script>
<div class="mb-6 flex items-center justify-between">
<Title size="h3" weight="semibold">Coupons</Title>
<Button onclick={() => (createDialogOpen = true)}>
<PlusIcon class="mr-2 h-4 w-4" />
New Coupon
</Button>
</div>
{#if couponVM.loading}
<div class="flex items-center justify-center py-20">
<Loader />
</div>
{:else if couponVM.coupons.length === 0}
<div class="rounded-lg border py-10 text-center">
<p class="text-gray-500">
No coupons found. Create your first coupon 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"
>Code</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Discount</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Valid Period</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Status</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 couponVM.coupons as coupon}
<tr>
<td class="whitespace-nowrap px-6 py-4">
<div class="font-medium text-gray-900">
{coupon.code}
</div>
{#if coupon.description}
<div class="text-sm text-gray-500">
{coupon.description}
</div>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4">
<Badge
>{formatDiscountValue(
coupon.discountType,
coupon.discountValue,
)}</Badge
>
{#if coupon.maxDiscountAmount}
<div class="mt-1 text-xs text-gray-500">
Max: ${coupon.maxDiscountAmount}
</div>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4">
<div>From: {formatDate(coupon.startDate)}</div>
<div>To: {formatDate(coupon.endDate)}</div>
</td>
<td class="whitespace-nowrap px-6 py-4">
{#if coupon.isActive}
<Badge
variant="success"
class="flex items-center gap-1"
>
<CheckCircle class="h-3 w-3" />
Active
</Badge>
{:else}
<Badge
variant="destructive"
class="flex items-center gap-1"
>
<XCircle class="h-3 w-3" />
Inactive
</Badge>
{/if}
</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={() => {
couponVM.selectCoupon(coupon);
editDialogOpen = true;
}}
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => {
couponVM.selectCoupon(coupon);
deleteDialogOpen = true;
}}
>
<Trash2 class="h-4 w-4" />
</Button>
<Button
variant={coupon.isActive
? "destructive"
: "default"}
size="sm"
onclick={() =>
couponVM.toggleCouponStatus(
coupon.id!,
!coupon.isActive,
)}
>
{coupon.isActive ? "Deactivate" : "Activate"}
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Create Coupon Dialog -->
<Dialog.Root bind:open={createDialogOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create New Coupon</Dialog.Title>
<Dialog.Description>
Create a new coupon to offer discounts on flight tickets.
</Dialog.Description>
</Dialog.Header>
<CouponForm
formData={newCouponData}
loading={couponVM.formLoading}
onSubmit={handleCreateCoupon}
onCancel={() => (createDialogOpen = false)}
/>
</Dialog.Content>
</Dialog.Root>
<!-- Edit Coupon Dialog -->
<Dialog.Root bind:open={editDialogOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit Coupon</Dialog.Title>
<Dialog.Description>Update this coupon's details.</Dialog.Description>
</Dialog.Header>
{#if couponVM.selectedCoupon}
<CouponForm
formData={couponVM.selectedCoupon}
loading={couponVM.formLoading}
onSubmit={handleUpdateCoupon}
onCancel={() => (editDialogOpen = false)}
/>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Coupon</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete this coupon? This action cannot be
undone.
</AlertDialog.Description>
</AlertDialog.Header>
{#if couponVM.selectedCoupon}
<div class="mb-4 rounded-md bg-gray-100 p-4">
<div class="font-bold">{couponVM.selectedCoupon.code}</div>
{#if couponVM.selectedCoupon.description}
<div class="text-sm text-gray-600">
{couponVM.selectedCoupon.description}
</div>
{/if}
<div class="mt-1">
Discount: {formatDiscountValue(
couponVM.selectedCoupon.discountType,
couponVM.selectedCoupon.discountValue,
)}
</div>
</div>
{/if}
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (deleteDialogOpen = false)}>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleDeleteCoupon}
disabled={couponVM.formLoading}
>
{couponVM.formLoading ? "Deleting..." : "Delete"}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -1,6 +1,5 @@
import { authRouter } from "$lib/domains/auth/domain/router";
import { ckflowRouter } from "$lib/domains/ckflow/router";
import { couponRouter } from "$lib/domains/coupon/router";
import { customerInfoRouter } from "$lib/domains/customerinfo/router";
import { orderRouter } from "$lib/domains/order/domain/router";
import { productRouter } from "$lib/domains/product/router";
@@ -12,7 +11,6 @@ export const router = createTRPCRouter({
user: userRouter,
order: orderRouter,
ckflow: ckflowRouter,
coupon: couponRouter,
product: productRouter,
customerInfo: customerInfoRouter,
});

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import { pageTitle } from "$lib/hooks/page-title.svelte";
import CouponList from "$lib/domains/coupon/view/coupon-list.svelte";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "$lib/components/ui/tabs/index";
pageTitle.set("Settings");
</script>
<div class="container mx-auto py-8">
<!-- <Tabs value="coupons" class="w-full">
<TabsList class="mb-6">
<TabsTrigger value="coupons">Coupon Management</TabsTrigger>
<!-- Add more tabs here as needed -->
<!-- </TabsList> -->
<!-- <TabsContent value="coupons"> -->
<CouponList />
<!-- </TabsContent> -->
<!-- Add more tab content here as needed -->
<!-- </Tabs> -->
</div>