Function bodies 369 total
OptionButton function · typescript · L14-L66 (53 LOC)src/components/ui/OptionButton.tsx
function OptionButton(
{
selected = false,
disabled = false,
description,
children,
className = "",
...rest
},
ref,
) {
const stateClass = disabled
? "bg-bg-warm2 border-disable-alt text-disable-strong cursor-not-allowed"
: selected
? "bg-brand-50 border-brand-400 text-brand-700"
: "bg-white border-border text-text-primary hover:border-brand-300";
const classes = [
"flex flex-col items-start gap-1 w-full",
"p-4 border rounded-md",
"transition-all duration-200",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-400",
"cursor-pointer",
stateClass,
className,
]
.filter(Boolean)
.join(" ");
return (
<button
ref={ref}
type="button"
disabled={disabled}
role="option"
aria-selected={selected}
className={classes}
{...rest}
>
<span classNRadio function · typescript · L14-L79 (66 LOC)src/components/ui/Radio.tsx
function Radio(
{ checked = false, disabled = false, error = false, label, className = "", ...rest },
ref,
) {
/* Outer circle color */
const circleColor = (() => {
if (disabled) return "border-disable-normal bg-disable-assistive";
if (error) return "border-semantic-red";
if (checked) return "border-brand-400";
return "border-border-strong";
})();
return (
<label
className={[
"inline-flex items-center gap-2",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
]
.filter(Boolean)
.join(" ")}
>
{/* Hidden native radio */}
<input
ref={ref}
type="radio"
checked={checked}
disabled={disabled}
className="sr-only peer"
{...rest}
/>
{/* Custom radio circle */}
<span
className={[
"relative flex shrink-0 items-center justify-center",
ScrollReveal function · typescript · L12-L26 (15 LOC)src/components/ui/ScrollReveal.tsx
export function ScrollReveal({ children, delay = 0, className = "" }: Props) {
const { ref, visible } = useScrollReveal();
return (
<div
ref={ref}
className={`transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
} ${className}`}
style={{ transitionDelay: `${delay}s` }}
>
{children}
</div>
);
}SectionHeader function · typescript · L8-L24 (17 LOC)src/components/ui/SectionHeader.tsx
export function SectionHeader({ tag, title, desc, center }: Props) {
return (
<div className={`mb-16 ${center ? "text-center" : ""}`}>
{tag && (
<span className="inline-flex items-center h-7 px-3 rounded-full bg-primary-tint text-primary text-[13px] font-semibold mb-4">
{tag}
</span>
)}
<h2 className="text-[40px] font-extrabold tracking-[-1.5px] mb-3.5 leading-[1.15] max-lg:text-[32px] max-md:text-[28px]">
{title}
</h2>
{desc && (
<p className="text-[17px] text-text-sub leading-relaxed">{desc}</p>
)}
</div>
);
}SegmentedField function · typescript · L21-L69 (49 LOC)src/components/ui/SegmentedField.tsx
function SegmentedField(
{ options, value, onChange, disabled = false, className = "", ...rest },
ref,
) {
return (
<div
ref={ref}
className={[
"inline-flex gap-1 rounded-md bg-fill-tint p-1",
disabled ? "opacity-60 pointer-events-none" : "",
className,
]
.filter(Boolean)
.join(" ")}
role="radiogroup"
{...rest}
>
{options.map((option) => {
const isActive = option.value === value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
disabled={disabled}
onClick={() => onChange(option.value)}
className={[
"flex-1 flex items-center justify-center",
"h-9 rounded-sm px-3",
"text-sm leading-[22px]",
"transition-all duration-200",
TextArea function · typescript · L16-L107 (92 LOC)src/components/ui/TextArea.tsx
function TextArea(
{
label,
error = false,
helperText,
disabled = false,
rows = 4,
maxLength,
value,
className = "",
...rest
},
ref,
) {
const [focused, setFocused] = useState(false);
/* Border color by state */
const borderColor = (() => {
if (disabled) return "border-disable-alt";
if (error) return "border-semantic-red";
if (focused) return "border-brand-400 ring-1 ring-brand-400";
return "border-border";
})();
/* Textarea classes */
const textareaClasses = [
"w-full rounded-md px-4 py-3 text-base leading-6",
"outline-none transition-all duration-200 resize-y",
"placeholder:text-text-muted",
"border",
borderColor,
disabled
? "bg-disable-assistive text-disable-strong cursor-not-allowed"
: "bg-white text-text-primary",
className,
]
.filter(Boolean)
.join(" ");
/* Character count */
consTextField function · typescript · L25-L120 (96 LOC)src/components/ui/TextField.tsx
function TextField(
{
label,
error = false,
helperText,
disabled = false,
size = "md",
maxLength,
value,
className = "",
required,
...rest
},
ref,
) {
const [focused, setFocused] = useState(false);
/* Border color by state */
const borderColor = (() => {
if (disabled) return "border-disable-alt";
if (error) return "border-semantic-red";
if (focused) return "border-brand-400 ring-1 ring-brand-400";
return "border-border";
})();
/* Input classes */
const inputClasses = [
"w-full rounded-md px-4 text-base leading-6",
"outline-none transition-all duration-200",
"placeholder:text-text-muted",
sizeStyles[size],
borderColor,
"border",
disabled
? "bg-disable-assistive text-disable-strong cursor-not-allowed"
: "bg-white text-text-primary",
className,
]
.filter(Boolean)
.join(" ");
/* ChaRepobility analyzer · published findings · https://repobility.com
getActiveExperiment function · typescript · L31-L33 (3 LOC)src/config/experiments.ts
export function getActiveExperiment(): Experiment | null {
return EXPERIMENTS.find((e) => e.enabled) || null;
}getActiveExperiments function · typescript · L36-L38 (3 LOC)src/config/experiments.ts
export function getActiveExperiments(): Experiment[] {
return EXPERIMENTS.filter((e) => e.enabled);
}assignVariant function · typescript · L41-L49 (9 LOC)src/config/experiments.ts
export function assignVariant(exp: Experiment): string {
const rand = Math.random() * 100;
let cumulative = 0;
for (let i = 0; i < exp.variants.length; i++) {
cumulative += exp.weights[i];
if (rand < cumulative) return exp.variants[i];
}
return exp.variants[0];
}ExperimentProvider function · typescript · L28-L59 (32 LOC)src/contexts/ExperimentContext.tsx
export function ExperimentProvider({ children }: { children: React.ReactNode }) {
const [experiments, setExperiments] = useState<ExperimentMap>(new Map());
useEffect(() => {
const activeExps = getActiveExperiments();
if (activeExps.length === 0) return;
const map = new Map<string, string>();
for (const exp of activeExps) {
const variant = Cookies.get(`ab_${exp.name}`);
if (variant) {
map.set(exp.name, variant);
}
}
setExperiments(map);
}, []);
// 하위 호환: 첫 번째 실험 정보
const firstEntry = experiments.entries().next().value;
const experimentName = firstEntry ? firstEntry[0] : null;
const variant = firstEntry ? firstEntry[1] : null;
const getVariant = (name: string): string | null => {
return experiments.get(name) || null;
};
return (
<ExperimentContext.Provider value={{ experimentName, variant, experiments, getVariant }}>
{children}
</ExperimentContext.Provider>
);
}useExperiment function · typescript · L61-L63 (3 LOC)src/contexts/ExperimentContext.tsx
export function useExperiment() {
return useContext(ExperimentContext);
}detectAreaFromAddress function · typescript · L16-L44 (29 LOC)src/data/spot-areas.ts
export function detectAreaFromAddress(
sigungu: string,
sido: string,
): SpotArea | null {
// 1. 서울 25개 구: sigungu 직접 매칭
const directMatch = SPOT_AREAS.find((a) => a.name === sigungu);
if (directMatch) return directMatch;
// 2. 경기도 시 매칭: "김포시" → "김포", "고양시 덕양구" → "고양"
if (sido.startsWith("경기")) {
const cityName = sigungu.split("시")[0]; // "고양시 덕양구" → "고양"
const cityMatch = SPOT_AREAS.find((a) => a.name === cityName);
if (cityMatch) return cityMatch;
}
// 3. 인천광역시 → "인천"
if (sido.startsWith("인천")) {
return SPOT_AREAS.find((a) => a.name === "인천") || null;
}
// 4. 충청남도 시 매칭: "천안시 동남구" → "천안", "아산시" → "아산"
if (sido.startsWith("충청남")) {
const cityName = sigungu.split("시")[0];
const cityMatch = SPOT_AREAS.find((a) => a.name === cityName);
if (cityMatch) return cityMatch;
}
return null;
}useCarousel function · typescript · L11-L193 (183 LOC)src/hooks/useCarousel.ts
export function useCarousel({
totalItems,
cardWidth = 320,
autoplayInterval = 3500,
}: UseCarouselOptions) {
const trackRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const autoplayRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [currentPage, setCurrentPage] = useState(0);
const [totalPages, setTotalPages] = useState(1);
// Calculate pages
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const visibleCards = Math.max(Math.floor(wrapper.offsetWidth / cardWidth), 1);
setTotalPages(Math.ceil(totalItems / visibleCards));
}, [totalItems, cardWidth]);
const goTo = useCallback(
(page: number) => {
const track = trackRef.current;
const wrapper = wrapperRef.current;
if (!track || !wrapper) return;
const clamped = Math.max(0, Math.min(page, totalPages - 1));
setCurrentPage(clamped);
const visibleCards = Math.max(
Math.floor(getCurrentOffset function · typescript · L106-L109 (4 LOC)src/hooks/useCarousel.ts
function getCurrentOffset(): number {
const m = track!.style.transform.match(/translateX\((-?[\d.]+)px\)/);
return m ? parseFloat(m[1]) : 0;
}Repobility (the analyzer behind this table) · https://repobility.com
onMouseDown function · typescript · L111-L118 (8 LOC)src/hooks/useCarousel.ts
function onMouseDown(e: MouseEvent) {
isDragging = true;
startX = e.pageX;
startOffset = getCurrentOffset();
track!.style.transition = "none";
stopAutoplay();
e.preventDefault();
}onMouseMove function · typescript · L120-L126 (7 LOC)src/hooks/useCarousel.ts
function onMouseMove(e: MouseEvent) {
if (!isDragging) return;
const dx = e.pageX - startX;
const maxOff = track!.scrollWidth - wrapper!.offsetWidth;
const newOff = Math.max(-maxOff - 40, Math.min(40, startOffset + dx));
track!.style.transform = `translateX(${newOff}px)`;
}onMouseUp function · typescript · L128-L139 (12 LOC)src/hooks/useCarousel.ts
function onMouseUp(e: MouseEvent) {
if (!isDragging) return;
isDragging = false;
const dx = e.pageX - startX;
if (Math.abs(dx) > 50) {
if (dx < 0) goTo(currentPage + 1);
else goTo(currentPage - 1);
} else {
goTo(currentPage);
}
startAutoplay();
}onTouchStart function · typescript · L159-L162 (4 LOC)src/hooks/useCarousel.ts
function onTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].pageX;
stopAutoplay();
}onTouchEnd function · typescript · L164-L171 (8 LOC)src/hooks/useCarousel.ts
function onTouchEnd(e: TouchEvent) {
const diff = e.changedTouches[0].pageX - touchStartX;
if (Math.abs(diff) > 60) {
if (diff < 0) goTo(currentPage + 1);
else goTo(currentPage - 1);
}
startAutoplay();
}usePWAInstall function · typescript · L10-L53 (44 LOC)src/hooks/usePWAInstall.ts
export function usePWAInstall() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
// SW 등록
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
// 모바일 판별 (UA + 터치 + 화면폭)
const mobile =
/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
("ontouchstart" in window && window.innerWidth < 768);
setIsMobile(mobile);
// 설치 프롬프트 캐치
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const install = useCallback(async () => {
if (!deferredPrompt) return false;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (useScrollPosition function · typescript · L5-L25 (21 LOC)src/hooks/useScrollPosition.ts
export function useScrollPosition() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
let ticking = false;
const handle = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
setScrollY(window.scrollY);
ticking = false;
});
};
window.addEventListener("scroll", handle, { passive: true });
return () => window.removeEventListener("scroll", handle);
}, []);
return scrollY;
}useScrollReveal function · typescript · L5-L35 (31 LOC)src/hooks/useScrollReveal.ts
export function useScrollReveal(threshold = 0.1, initialVisible = false) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(initialVisible);
useEffect(() => {
if (initialVisible) return;
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.unobserve(el);
}
},
{ threshold, rootMargin: "0px 0px -40px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold, initialVisible]);
return { ref, visible };
}All rows scored by the Repobility analyzer (https://repobility.com)
hasPermission function · typescript · L15-L17 (3 LOC)src/lib/admin-roles.ts
export function hasPermission(role: AdminRole, action: string): boolean {
return ROLE_PERMISSIONS[role]?.includes(action) || role === "admin";
}getExperimentVariant function · typescript · L53-L61 (9 LOC)src/lib/analytics.ts
function getExperimentVariant(): Record<string, string> {
if (typeof document === "undefined") return {};
const result: Record<string, string> = {};
const matches = document.cookie.matchAll(/ab_([^=]+)=([^;]+)/g);
for (const match of matches) {
result[`experiment_${match[1]}`] = match[2];
}
return result;
}track function · typescript · L63-L90 (28 LOC)src/lib/analytics.ts
export function track<T extends EventName>(
event: T,
properties?: T extends keyof EventProps ? EventProps[T] : never
) {
if (typeof window === "undefined") return;
const props = {
...properties,
...getExperimentVariant(),
timestamp: Date.now(),
url: window.location.href,
};
// Mixpanel
if (window.mixpanel) {
window.mixpanel.track(`[Spot] ${event}`, props);
}
// Airbridge
if (window.airbridge) {
window.airbridge.events.send(event, { customAttributes: props });
}
// GA4
if (window.gtag) {
window.gtag("event", event, props);
}
}getSecret function · typescript · L3-L9 (7 LOC)src/lib/booking-token.ts
function getSecret(): string {
const secret = process.env.ADMIN_PASSWORD;
if (!secret) {
throw new Error("ADMIN_PASSWORD 환경변수가 설정되지 않았습니다");
}
return secret;
}getWindowIndex function · typescript · L15-L17 (3 LOC)src/lib/booking-token.ts
function getWindowIndex(offset = 0): number {
return Math.floor(Date.now() / (1000 * 60 * 60 * 24 * 30)) + offset;
}computeToken function · typescript · L19-L25 (7 LOC)src/lib/booking-token.ts
function computeToken(payload: string): string {
return crypto
.createHmac("sha256", getSecret())
.update(payload)
.digest("hex")
.slice(0, 32);
}generateBookingToken function · typescript · L28-L31 (4 LOC)src/lib/booking-token.ts
export function generateBookingToken(phone: string): string {
const digits = phone.replace(/[^\d]/g, "");
return computeToken(`${digits}:${getWindowIndex()}`);
}getKSTNow function · typescript · L8-L12 (5 LOC)src/lib/booking-utils.ts
function getKSTNow(): Date {
return new Date(
new Date().toLocaleString("en-US", { timeZone: "Asia/Seoul" }),
);
}Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
getEarliestBookableDate function · typescript · L15-L22 (8 LOC)src/lib/booking-utils.ts
export function getEarliestBookableDate(): string {
const kst = getKSTNow();
// 12시 이전 → 내일부터, 12시 이후 → 모레부터
const daysToAdd = kst.getHours() < 12 ? 1 : 2;
const earliest = new Date(kst);
earliest.setDate(earliest.getDate() + daysToAdd);
return `${earliest.getFullYear()}-${String(earliest.getMonth() + 1).padStart(2, "0")}-${String(earliest.getDate()).padStart(2, "0")}`;
}isDateBookable function · typescript · L25-L27 (3 LOC)src/lib/booking-utils.ts
export function isDateBookable(dateStr: string): boolean {
return dateStr >= getEarliestBookableDate();
}getCustomerDeadline function · typescript · L33-L36 (4 LOC)src/lib/booking-utils.ts
export function getCustomerDeadline(bookingDate: string): Date {
const pickupDate = new Date(bookingDate + "T00:00:00+09:00");
return new Date(pickupDate.getTime() - 2 * 60 * 60 * 1000);
}isBeforeDeadline function · typescript · L39-L41 (3 LOC)src/lib/booking-utils.ts
export function isBeforeDeadline(bookingDate: string): boolean {
return new Date() < getCustomerDeadline(bookingDate);
}calcCrewSize function · typescript · L11-L16 (6 LOC)src/lib/crew-utils.ts
export function calcCrewSize(totalLoadingCube: number): number {
for (const t of CREW_THRESHOLDS) {
if (totalLoadingCube >= t.minCube) return t.crew;
}
return 1;
}rowToBooking function · typescript · L76-L120 (45 LOC)src/lib/db.ts
function rowToBooking(row: Record<string, unknown>): Booking {
return {
id: row.id as string,
date: row.date as string,
timeSlot: row.time_slot as string,
area: row.area as string,
items: (row.items as Booking["items"]) || [],
totalPrice: (row.total_price as number) || 0,
crewSize: (row.crew_size as number) || 1,
needLadder: (row.need_ladder as boolean) || false,
ladderType: (row.ladder_type as string) || undefined,
ladderHours:
row.ladder_hours != null ? (row.ladder_hours as number) : undefined,
ladderPrice: (row.ladder_price as number) || 0,
customerName: row.customer_name as string,
phone: row.phone as string,
address: row.address as string,
addressDetail: (row.address_detail as string) || "",
memo: (row.memo as string) || "",
status: (row.status as Booking["status"]) || "pending",
createdAt: row.created_at as string,
updatedAt: row.updated_at as string,
hasElevator: (row.has_elevator as boolean)bookingToRow function · typescript · L122-L163 (42 LOC)src/lib/db.ts
function bookingToRow(b: Booking) {
return {
id: b.id,
date: b.date,
time_slot: b.timeSlot,
area: b.area,
items: b.items,
total_price: b.totalPrice,
crew_size: b.crewSize,
need_ladder: b.needLadder,
ladder_type: b.ladderType || null,
ladder_hours: b.ladderHours ?? null,
ladder_price: b.ladderPrice,
customer_name: b.customerName,
phone: b.phone,
address: b.address,
address_detail: b.addressDetail,
memo: b.memo,
status: b.status,
created_at: b.createdAt,
updated_at: b.updatedAt,
has_elevator: b.hasElevator,
has_parking: b.hasParking,
has_ground_access: b.hasGroundAccess,
estimate_min: b.estimateMin,
estimate_max: b.estimateMax,
final_price: b.finalPrice,
photos: b.photos,
admin_memo: b.adminMemo,
confirmed_time: b.confirmedTime ?? null,
confirmed_duration: b.confirmedDuration ?? null,
completion_photos: b.completionPhotos || [],
slack_thread_ts: b.slackThreadTs ??partialToRow function · typescript · L165-L172 (8 LOC)src/lib/db.ts
function partialToRow(updates: Partial<Booking>): Record<string, unknown> {
const row: Record<string, unknown> = {};
for (const [key, val] of Object.entries(updates)) {
const dbKey = FIELD_MAP[key];
if (dbKey) row[dbKey] = val;
}
return row;
}Repobility analyzer · published findings · https://repobility.com
getBookings function · typescript · L176-L188 (13 LOC)src/lib/db.ts
export async function getBookings(date?: string): Promise<Booking[]> {
let query = supabase
.from("bookings")
.select("*")
.neq("status", "cancelled")
.order("created_at", { ascending: false });
if (date) query = query.eq("date", date);
const { data, error } = await query;
if (error) throw error;
return (data || []).map(rowToBooking);
}getBookingById function · typescript · L190-L205 (16 LOC)src/lib/db.ts
export async function getBookingById(
id: string,
): Promise<Booking | null> {
const { data, error } = await supabase
.from("bookings")
.select("*")
.eq("id", id)
.neq("status", "cancelled")
.single();
if (error) {
if (error.code === "PGRST116") return null;
throw error;
}
return data ? rowToBooking(data) : null;
}getBookingByIdAdmin function · typescript · L208-L222 (15 LOC)src/lib/db.ts
export async function getBookingByIdAdmin(
id: string,
): Promise<Booking | null> {
const { data, error } = await supabase
.from("bookings")
.select("*")
.eq("id", id)
.single();
if (error) {
if (error.code === "PGRST116") return null;
throw error;
}
return data ? rowToBooking(data) : null;
}getBookingsByPhone function · typescript · L224-L244 (21 LOC)src/lib/db.ts
export async function getBookingsByPhone(
phone: string,
): Promise<Booking[]> {
// 하이픈 포함/미포함 모두 검색 (포맷 불일치 방어)
const digits = phone.replace(/[^\d]/g, "");
const formatted =
digits.length >= 10
? `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`
: phone;
// .or() 문자열 직접 삽입 대신 .in() 사용 (PostgREST 주입 방지)
const phoneVariants = [...new Set([formatted, digits])];
const { data, error } = await supabase
.from("bookings")
.select("*")
.in("phone", phoneVariants)
.order("created_at", { ascending: false });
if (error) throw error;
return (data || []).map(rowToBooking);
}getAllBookings function · typescript · L246-L254 (9 LOC)src/lib/db.ts
export async function getAllBookings(): Promise<Booking[]> {
const { data, error } = await supabase
.from("bookings")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return (data || []).map(rowToBooking);
}getBookingStatusCounts function · typescript · L260-L275 (16 LOC)src/lib/db.ts
export async function getBookingStatusCounts(): Promise<Record<string, number>> {
// limit 10000: Supabase 기본 1000행 제한 우회 (대시보드 탭 배지 정확도 보장)
const { data, error } = await supabase
.from("bookings")
.select("status")
.limit(10000);
if (error) throw error;
const counts: Record<string, number> = {};
for (const row of data || []) {
const s = row.status as string;
counts[s] = (counts[s] || 0) + 1;
}
return counts;
}getBookingsByStatus function · typescript · L277-L288 (12 LOC)src/lib/db.ts
export async function getBookingsByStatus(
status: string,
): Promise<Booking[]> {
const { data, error } = await supabase
.from("bookings")
.select("*")
.eq("status", status)
.order("created_at", { ascending: false });
if (error) throw error;
return (data || []).map(rowToBooking);
}createBooking function · typescript · L290-L299 (10 LOC)src/lib/db.ts
export async function createBooking(booking: Booking): Promise<Booking> {
const { data, error } = await supabase
.from("bookings")
.insert(bookingToRow(booking))
.select()
.single();
if (error) throw error;
return rowToBooking(data);
}Repobility (the analyzer behind this table) · https://repobility.com
updateBooking function · typescript · L301-L328 (28 LOC)src/lib/db.ts
export async function updateBooking(
id: string,
updates: Partial<Booking>,
expectedUpdatedAt?: string,
): Promise<Booking | null> {
const row = {
...partialToRow(updates),
updated_at: new Date().toISOString(),
};
let query = supabase
.from("bookings")
.update(row)
.eq("id", id);
// Optimistic locking: only update if updated_at matches
if (expectedUpdatedAt) {
query = query.eq("updated_at", expectedUpdatedAt);
}
const { data, error } = await query.select().single();
if (error) {
if (error.code === "PGRST116") return null; // No rows matched
throw error;
}
return data ? rowToBooking(data) : null;
}getBookingsPaginated function · typescript · L330-L337 (8 LOC)src/lib/db.ts
export async function getBookingsPaginated(params: {
status?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
}): Promise<{ bookings: Booking[]; total: number }> {deleteBooking function · typescript · L377-L380 (4 LOC)src/lib/db.ts
export async function deleteBooking(id: string): Promise<boolean> {
const result = await updateBooking(id, { status: "cancelled" });
return result !== null;
}