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

View File

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

View File

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

View File

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

View File

@ -28,12 +28,21 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { apiGet, apiPost } from "@/lib/api"; 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 { ROOM_CATALOGUE } from "@/lib/constants";
import type { Room } from "@/lib/types"; import type { Room } from "@/lib/types";
import { formatMoney } from "@/lib/format"; import { formatMoney } from "@/lib/format";
import { useAuthStore } from "@/store/authStore";
export function RoomsPage() { export function RoomsPage() {
const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId);
const [rooms, setRooms] = useState<Room[]>([]); const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug); const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug);
@ -41,28 +50,43 @@ export function RoomsPage() {
const [baseRate, setBaseRate] = useState("120"); const [baseRate, setBaseRate] = useState("120");
function load() { 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(() => { useEffect(() => {
load(); load();
}, []); }, [selectedPropertyId]);
async function addRoom(e: React.FormEvent) { async function addRoom(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
await apiPost<Room>("/rooms", { setSubmitting(true);
name, try {
roomTypeSlug: slug, await apiPost<Room>("/rooms", {
maxGuests: Number(maxGuests), name,
baseRate: Number(baseRate), roomType: slug,
status: "available", maxGuests: Number(maxGuests),
floor: "", baseRate: Number(baseRate),
}); });
setOpen(false); setOpen(false);
setName(""); setName("");
load(); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -118,7 +142,7 @@ export function RoomsPage() {
/> />
</div> </div>
</div> </div>
<Button type="submit">Save</Button> <Button type="submit" loading={submitting}>Save</Button>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>