discount, referrals and customers page

This commit is contained in:
brooktewabe 2026-04-01 11:29:45 +03:00
parent 4ed7161a33
commit 0f9d0b7e6f
5 changed files with 136 additions and 83 deletions

View File

@ -10,17 +10,19 @@ import {
TableRow,
} from "@/components/ui/table";
import { apiGet } from "@/lib/api";
import { useAuthStore } from "@/store/authStore";
import type { CustomerRow } from "@/lib/types";
import { formatDate } from "@/lib/format";
export function CustomersPage() {
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [rows, setRows] = useState<CustomerRow[]>([]);
useEffect(() => {
apiGet<{ data: CustomerRow[] }>("/customers").then((r) =>
setRows(r.data)
);
}, []);
}, [selectedPropertyId]);
return (
<div className="space-y-6">

View File

@ -29,7 +29,9 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAuth } from "@/context/AuthContext";
import { useAuthStore } from "@/store/authStore";
import { apiGet, apiPatch, apiPost } from "@/lib/api";
import { Spinner } from "@/components/ui/spinner";
import type { DiscountCode } from "@/lib/types";
function copy(s: string) {
@ -38,18 +40,21 @@ function copy(s: string) {
export function DiscountCodesPage() {
const { canManageCodes } = useAuth();
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [rows, setRows] = useState<DiscountCode[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [open, setOpen] = useState(false);
const [custom, setCustom] = useState("");
const [generate, setGenerate] = useState(true);
const [value, setValue] = useState("10");
const [dtype, setDtype] = useState<"percent" | "fixed_amount">("percent");
const load = useCallback(() => {
setLoading(true);
apiGet<{ data: DiscountCode[] }>("/discount-codes").then((r) =>
setRows(r.data)
);
}, []);
).finally(() => setLoading(false));
}, [selectedPropertyId]);
useEffect(() => {
load();
@ -57,14 +62,18 @@ export function DiscountCodesPage() {
async function create(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
await apiPost("/discount-codes", {
generate,
code: generate ? undefined : custom,
code: custom,
discountType: dtype,
value: Number(value),
});
setOpen(false);
load();
} finally {
setSubmitting(false);
}
}
async function toggle(dc: DiscountCode) {
@ -87,24 +96,13 @@ export function DiscountCodesPage() {
<DialogTitle>New discount code</DialogTitle>
</DialogHeader>
<form onSubmit={create} className="grid gap-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={generate}
onChange={(e) => setGenerate(e.target.checked)}
id="gen"
/>
<Label htmlFor="gen">Auto-generate code</Label>
</div>
{!generate && (
<div className="space-y-2">
<Label>Custom code</Label>
<Label>Code</Label>
<Input
value={custom}
onChange={(e) => setCustom(e.target.value)}
/>
</div>
)}
<div className="space-y-2">
<Label>Type</Label>
<Select
@ -128,7 +126,7 @@ export function DiscountCodesPage() {
onChange={(e) => setValue(e.target.value)}
/>
</div>
<Button type="submit">Create</Button>
<Button type="submit" loading={submitting}>Create</Button>
</form>
</DialogContent>
</Dialog>
@ -137,6 +135,11 @@ export function DiscountCodesPage() {
<Card className="rounded-2xl">
<CardContent className="pt-6">
{loading && rows.length === 0 ? (
<div className="flex min-h-[400px] items-center justify-center">
<Spinner size={32} />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
@ -194,6 +197,7 @@ export function DiscountCodesPage() {
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>

View File

@ -13,10 +13,18 @@ import {
SelectValue,
} from "@/components/ui/select";
import { apiGet, apiPost } from "@/lib/api";
import {
isLikelyApiHotelBooking,
isLikelyApiHotelRoom,
mapApiBookingToBooking,
mapApiRoomToRoom,
} from "@/lib/hotel-adapters";
import type { Booking, Room } from "@/lib/types";
import { useAuthStore } from "@/store/authStore";
export function NewBookingPage() {
const nav = useNavigate();
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [rooms, setRooms] = useState<Room[]>([]);
const [roomId, setRoomId] = useState("");
const [checkIn, setCheckIn] = useState("");
@ -30,21 +38,53 @@ export function NewBookingPage() {
const [arrival, setArrival] = useState("14:00");
const [coupon, setCoupon] = useState("");
const [referral, setReferral] = useState("");
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
apiGet<{ data: Room[] }>("/rooms")
apiGet<{ data: unknown[] }>("/rooms")
.then((r) => {
setRooms(r.data);
if (r.data[0]) setRoomId(r.data[0].id);
const mapped = r.data.map((row) =>
isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room)
);
setRooms(mapped);
if (mapped[0]) setRoomId(mapped[0].id);
})
.catch(console.error);
}, []);
}, [selectedPropertyId]);
async function submit(e: React.FormEvent) {
e.preventDefault();
setErr(null);
setSubmitting(true);
try {
if (selectedPropertyId) {
const customer = await apiPost<{ id: string }>("/customers", {
firstName,
lastName,
email: email || undefined,
phone: phone || undefined,
});
const raw = await apiPost<unknown>("/bookings", {
roomId,
customerId: customer.id,
checkIn,
checkOut,
guestCount: Number(guests),
status: "CONFIRMED",
payLaterHold: false,
discountCode: coupon.trim() || undefined,
referralCode: referral.trim() || undefined,
flightPnr: pnr.trim() || undefined,
arrivalTime: arrival || undefined,
});
const created = isLikelyApiHotelBooking(raw)
? mapApiBookingToBooking(raw)
: (raw as Booking);
nav(`/bookings/${created.id}`);
return;
}
const body: Partial<Booking> & Record<string, unknown> = {
guest: {
firstName,
@ -67,6 +107,8 @@ export function NewBookingPage() {
nav(`/bookings/${created.id}`);
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : "Failed");
} finally {
setSubmitting(false);
}
}
@ -196,7 +238,7 @@ export function NewBookingPage() {
</Card>
{err && <p className="text-sm text-destructive">{err}</p>}
<Button type="submit">Create booking</Button>
<Button type="submit" loading={submitting}>Create booking</Button>
</form>
</div>
);

View File

@ -23,6 +23,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAuth } from "@/context/AuthContext";
import { useAuthStore } from "@/store/authStore";
import { apiGet, apiPatch, apiPost } from "@/lib/api";
import type { ReferralCode } from "@/lib/types";
@ -32,17 +33,17 @@ function copy(s: string) {
export function ReferralCodesPage() {
const { canManageCodes } = useAuth();
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [rows, setRows] = useState<ReferralCode[]>([]);
const [open, setOpen] = useState(false);
const [label, setLabel] = useState("");
const [custom, setCustom] = useState("");
const [generate, setGenerate] = useState(true);
const [code, setCode] = useState("");
const [meta, setMeta] = useState("");
const load = useCallback(() => {
apiGet<{ data: ReferralCode[] }>("/referral-codes").then((r) =>
setRows(r.data)
);
}, []);
}, [selectedPropertyId]);
useEffect(() => {
load();
@ -51,12 +52,9 @@ export function ReferralCodesPage() {
async function create(e: React.FormEvent) {
e.preventDefault();
await apiPost("/referral-codes", {
generate,
code: generate ? undefined : custom,
label,
code,
});
setOpen(false);
setLabel("");
load();
}
@ -81,31 +79,19 @@ export function ReferralCodesPage() {
</DialogHeader>
<form onSubmit={create} className="grid gap-3">
<div className="space-y-2">
<Label>Campaign label</Label>
<Label>Enter name</Label>
<Input
required
value={label}
onChange={(e) => setLabel(e.target.value)}
value={meta}
onChange={(e) => setMeta(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={generate}
onChange={(e) => setGenerate(e.target.checked)}
id="rgen"
/>
<Label htmlFor="rgen">Auto-generate code</Label>
</div>
{!generate && (
<div className="space-y-2">
<Label>Custom code</Label>
<Label>Enter code</Label>
<Input
value={custom}
onChange={(e) => setCustom(e.target.value)}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
)}
<Button type="submit">Create</Button>
</form>
</DialogContent>
@ -120,7 +106,6 @@ export function ReferralCodesPage() {
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Label</TableHead>
<TableHead>Redemptions</TableHead>
<TableHead>Active</TableHead>
<TableHead />
</TableRow>
@ -129,11 +114,7 @@ export function ReferralCodesPage() {
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono font-medium">{r.code}</TableCell>
<TableCell>{r.label}</TableCell>
<TableCell>
{r.redemptionCount}
{r.maxRedemptions != null && ` / ${r.maxRedemptions}`}
</TableCell>
<TableCell className="font-mono font-medium">{r.meta}</TableCell>
<TableCell>
<Badge variant={r.isActive ? "success" : "secondary"}>
{r.isActive ? "Active" : "Off"}

View File

@ -28,12 +28,21 @@ import {
TableRow,
} from "@/components/ui/table";
import { apiGet, apiPost } from "@/lib/api";
import { Spinner } from "@/components/ui/spinner";
import {
isLikelyApiHotelRoom,
mapApiRoomToRoom,
} from "@/lib/hotel-adapters";
import { ROOM_CATALOGUE } from "@/lib/constants";
import type { Room } from "@/lib/types";
import { formatMoney } from "@/lib/format";
import { useAuthStore } from "@/store/authStore";
export function RoomsPage() {
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug);
@ -41,27 +50,42 @@ export function RoomsPage() {
const [baseRate, setBaseRate] = useState("120");
function load() {
apiGet<{ data: Room[] }>("/rooms").then((r) => setRooms(r.data));
setLoading(true);
apiGet<{ data: unknown[] }>("/rooms").then((r) => {
const mapped = r.data.map((row) =>
isLikelyApiHotelRoom(row) ? mapApiRoomToRoom(row) : (row as Room)
);
setRooms(mapped);
}).finally(() => setLoading(false));
}
useEffect(() => {
load();
}, []);
}, [selectedPropertyId]);
async function addRoom(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
await apiPost<Room>("/rooms", {
name,
roomTypeSlug: slug,
roomType: slug,
maxGuests: Number(maxGuests),
baseRate: Number(baseRate),
status: "available",
floor: "",
});
setOpen(false);
setName("");
load();
} finally {
setSubmitting(false);
}
}
if (loading && rooms.length === 0) return (
<div className="flex min-h-[400px] items-center justify-center">
<Spinner size={32} />
</div>
);
return (
<div className="space-y-6">
@ -118,7 +142,7 @@ export function RoomsPage() {
/>
</div>
</div>
<Button type="submit">Save</Button>
<Button type="submit" loading={submitting}>Save</Button>
</form>
</DialogContent>
</Dialog>