Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae873215c0 | |||
| 9d37053580 | |||
| fdaf4db0d5 |
@@ -16,9 +16,8 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
|
||||||
|
|
||||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
export const appointmentGroupsRouter = new Hono();
|
||||||
|
|
||||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,8 +49,6 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
const clientId = c.req.query("clientId");
|
const clientId = c.req.query("clientId");
|
||||||
const from = c.req.query("from");
|
const from = c.req.query("from");
|
||||||
const to = c.req.query("to");
|
const to = c.req.query("to");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const groupConditions = clientId
|
const groupConditions = clientId
|
||||||
? [eq(appointmentGroups.clientId, clientId)]
|
? [eq(appointmentGroups.clientId, clientId)]
|
||||||
@@ -91,16 +88,6 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
}))
|
}))
|
||||||
.filter((g) => !from || g.appointments.length > 0);
|
.filter((g) => !from || g.appointments.length > 0);
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
return c.json(
|
|
||||||
result.filter((g) =>
|
|
||||||
g.appointments.some(
|
|
||||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,8 +96,6 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
appointmentGroupsRouter.get("/:id", async (c) => {
|
appointmentGroupsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -126,7 +111,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
serviceId: appointments.serviceId,
|
serviceId: appointments.serviceId,
|
||||||
serviceName: services.name,
|
serviceName: services.name,
|
||||||
staffId: appointments.staffId,
|
staffId: appointments.staffId,
|
||||||
batherStaffId: appointments.batherStaffId,
|
|
||||||
staffName: staff.name,
|
staffName: staff.name,
|
||||||
status: appointments.status,
|
status: appointments.status,
|
||||||
startTime: appointments.startTime,
|
startTime: appointments.startTime,
|
||||||
@@ -141,15 +125,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
.where(eq(appointments.groupId, id))
|
.where(eq(appointments.groupId, id))
|
||||||
.orderBy(appointments.startTime);
|
.orderBy(appointments.startTime);
|
||||||
|
|
||||||
if (
|
|
||||||
isGroomer &&
|
|
||||||
!groupAppts.some(
|
|
||||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
.select({ name: clients.name, email: clients.email })
|
.select({ name: clients.name, email: clients.email })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -165,13 +140,6 @@ appointmentGroupsRouter.post(
|
|||||||
zValidator("json", createGroupSchema),
|
zValidator("json", createGroupSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const staffRow = c.get("staff");
|
|
||||||
if (staffRow?.role === "groomer") {
|
|
||||||
return c.json(
|
|
||||||
{ error: "Forbidden: groomers cannot create group bookings" },
|
|
||||||
403
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const startTime = new Date(body.startTime);
|
const startTime = new Date(body.startTime);
|
||||||
|
|
||||||
@@ -276,28 +244,6 @@ appointmentGroupsRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const [group] = await db
|
|
||||||
.select({ id: appointmentGroups.id })
|
|
||||||
.from(appointmentGroups)
|
|
||||||
.where(eq(appointmentGroups.id, id));
|
|
||||||
if (!group) return c.json({ error: "Not found" }, 404);
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const groupAppts = await db
|
|
||||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.groupId, id));
|
|
||||||
if (
|
|
||||||
!groupAppts.some(
|
|
||||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(appointmentGroups)
|
.update(appointmentGroups)
|
||||||
@@ -315,8 +261,6 @@ appointmentGroupsRouter.patch(
|
|||||||
appointmentGroupsRouter.delete("/:id", async (c) => {
|
appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select({ id: appointmentGroups.id })
|
.select({ id: appointmentGroups.id })
|
||||||
@@ -324,20 +268,6 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
|
|||||||
.where(eq(appointmentGroups.id, id));
|
.where(eq(appointmentGroups.id, id));
|
||||||
if (!group) return c.json({ error: "Not found" }, 404);
|
if (!group) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const groupAppts = await db
|
|
||||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.groupId, id));
|
|
||||||
if (
|
|
||||||
!groupAppts.some(
|
|
||||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ const createAppointmentSchema = z.object({
|
|||||||
frequencyWeeks: z.number().int().min(1).max(52),
|
frequencyWeeks: z.number().int().min(1).max(52),
|
||||||
count: z.number().int().min(2).max(52),
|
count: z.number().int().min(2).max(52),
|
||||||
})
|
})
|
||||||
.refine(
|
|
||||||
(r) => r.frequencyWeeks * r.count <= 52,
|
|
||||||
{ message: "Recurrence series must not exceed 1 year" }
|
|
||||||
)
|
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,29 +163,6 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check batherStaffId conflicts if set
|
|
||||||
if (apptFields.batherStaffId) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, apptFields.batherStaffId),
|
|
||||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
|
||||||
),
|
|
||||||
lt(appointments.startTime, end),
|
|
||||||
gte(appointments.endTime, start),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!recurrence) {
|
if (!recurrence) {
|
||||||
// Single appointment
|
// Single appointment
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
@@ -488,34 +461,6 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check batherStaffId conflicts if being updated or already set
|
|
||||||
const batherStaffId =
|
|
||||||
updateFields.batherStaffId !== undefined
|
|
||||||
? updateFields.batherStaffId
|
|
||||||
: current.batherStaffId;
|
|
||||||
if (batherStaffId) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, batherStaffId),
|
|
||||||
eq(appointments.batherStaffId, batherStaffId)
|
|
||||||
),
|
|
||||||
lt(appointments.startTime, end),
|
|
||||||
gte(appointments.endTime, start),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
ne(appointments.id, id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set(update)
|
.set(update)
|
||||||
|
|||||||
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
|
|
||||||
const bookingSchema = z.object({
|
const bookingSchema = z.object({
|
||||||
serviceId: z.string().uuid(),
|
serviceId: z.string().uuid(),
|
||||||
startTime: z.string().datetime().refine(
|
startTime: z.string().datetime(),
|
||||||
(dt) => new Date(dt) > new Date(),
|
|
||||||
{ message: "Appointment must be in the future" }
|
|
||||||
),
|
|
||||||
clientName: z.string().min(1).max(200),
|
clientName: z.string().min(1).max(200),
|
||||||
clientEmail: z.string().email(),
|
clientEmail: z.string().email(),
|
||||||
clientPhone: z.string().max(50).optional(),
|
clientPhone: z.string().max(50).optional(),
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
|
||||||
|
|
||||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
export const groomingLogsRouter = new Hono();
|
||||||
|
|
||||||
const createLogSchema = z.object({
|
const createLogSchema = z.object({
|
||||||
petId: z.string().uuid(),
|
petId: z.string().uuid(),
|
||||||
@@ -21,26 +20,6 @@ groomingLogsRouter.get("/", async (c) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const petId = c.req.query("petId");
|
const petId = c.req.query("petId");
|
||||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
if (!petId) return c.json({ error: "petId is required" }, 400);
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const [appt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.petId, petId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(groomingVisitLogs)
|
.from(groomingVisitLogs)
|
||||||
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
|
|||||||
zValidator("json", createLogSchema),
|
zValidator("json", createLogSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
const { groomedAt, ...rest } = c.req.valid("json");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
if (appointmentId) {
|
|
||||||
const [appt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.id, appointmentId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
} else {
|
|
||||||
const [appt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.petId, petId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(groomingVisitLogs)
|
.insert(groomingVisitLogs)
|
||||||
.values({
|
.values({
|
||||||
...rest,
|
...rest,
|
||||||
petId,
|
|
||||||
appointmentId: appointmentId ?? null,
|
|
||||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
|
|||||||
|
|
||||||
groomingLogsRouter.delete("/:id", async (c) => {
|
groomingLogsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const [row] = await db
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const [log] = await db
|
|
||||||
.select()
|
|
||||||
.from(groomingVisitLogs)
|
|
||||||
.where(eq(groomingVisitLogs.id, id))
|
|
||||||
.limit(1);
|
|
||||||
if (!log) return c.json({ error: "Not found" }, 404);
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const [appt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.petId, log.petId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(groomingVisitLogs)
|
.delete(groomingVisitLogs)
|
||||||
.where(eq(groomingVisitLogs.id, id))
|
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
||||||
.returning();
|
.returning();
|
||||||
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,61 +44,53 @@ const updateInvoiceSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// List invoices
|
// List invoices
|
||||||
const listInvoicesQuerySchema = z.object({
|
invoicesRouter.get("/", async (c) => {
|
||||||
clientId: z.string().uuid().optional(),
|
const db = getDb();
|
||||||
appointmentId: z.string().uuid().optional(),
|
const clientId = c.req.query("clientId");
|
||||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
const appointmentId = c.req.query("appointmentId");
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
const status = c.req.query("status");
|
||||||
offset: z.coerce.number().int().min(0).default(0),
|
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
||||||
|
const offset = parseInt(c.req.query("offset") || "0", 10);
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||||
|
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||||
|
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: invoices.id,
|
||||||
|
appointmentId: invoices.appointmentId,
|
||||||
|
clientId: invoices.clientId,
|
||||||
|
clientName: clients.name,
|
||||||
|
subtotalCents: invoices.subtotalCents,
|
||||||
|
taxCents: invoices.taxCents,
|
||||||
|
tipCents: invoices.tipCents,
|
||||||
|
totalCents: invoices.totalCents,
|
||||||
|
status: invoices.status,
|
||||||
|
paymentMethod: invoices.paymentMethod,
|
||||||
|
paidAt: invoices.paidAt,
|
||||||
|
notes: invoices.notes,
|
||||||
|
createdAt: invoices.createdAt,
|
||||||
|
updatedAt: invoices.updatedAt,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(invoices.createdAt)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
invoicesRouter.get(
|
|
||||||
"/",
|
|
||||||
zValidator("query", listInvoicesQuerySchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
|
|
||||||
|
|
||||||
const conditions = [];
|
|
||||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
|
||||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
|
||||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
|
|
||||||
const [totalResult] = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(invoices)
|
|
||||||
.where(whereClause);
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
id: invoices.id,
|
|
||||||
appointmentId: invoices.appointmentId,
|
|
||||||
clientId: invoices.clientId,
|
|
||||||
clientName: clients.name,
|
|
||||||
subtotalCents: invoices.subtotalCents,
|
|
||||||
taxCents: invoices.taxCents,
|
|
||||||
tipCents: invoices.tipCents,
|
|
||||||
totalCents: invoices.totalCents,
|
|
||||||
status: invoices.status,
|
|
||||||
paymentMethod: invoices.paymentMethod,
|
|
||||||
paidAt: invoices.paidAt,
|
|
||||||
notes: invoices.notes,
|
|
||||||
createdAt: invoices.createdAt,
|
|
||||||
updatedAt: invoices.updatedAt,
|
|
||||||
})
|
|
||||||
.from(invoices)
|
|
||||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
|
||||||
.where(whereClause)
|
|
||||||
.orderBy(invoices.createdAt)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get single invoice with line items and tip splits
|
// Get single invoice with line items and tip splits
|
||||||
invoicesRouter.get("/:id", async (c) => {
|
invoicesRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -35,12 +35,6 @@ portalRouter.get("/me", async (c) => {
|
|||||||
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.get("/config", async (c) => {
|
|
||||||
return c.json({
|
|
||||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.get("/services", async (c) => {
|
portalRouter.get("/services", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const allServices = await db.select().from(services).where(eq(services.active, true));
|
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||||
@@ -129,7 +123,7 @@ portalRouter.get("/invoices", async (c) => {
|
|||||||
id: inv.id,
|
id: inv.id,
|
||||||
status: inv.status,
|
status: inv.status,
|
||||||
totalCents: inv.totalCents,
|
totalCents: inv.totalCents,
|
||||||
date: inv.createdAt,
|
createdAt: inv.createdAt,
|
||||||
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||||
})));
|
})));
|
||||||
});
|
});
|
||||||
@@ -462,9 +456,45 @@ import {
|
|||||||
detachPaymentMethod,
|
detachPaymentMethod,
|
||||||
createSetupIntent,
|
createSetupIntent,
|
||||||
getOrCreateStripeCustomer,
|
getOrCreateStripeCustomer,
|
||||||
getStripeClient,
|
|
||||||
} from "../services/payment.js";
|
} from "../services/payment.js";
|
||||||
|
|
||||||
|
const payInvoiceSchema = z.object({
|
||||||
|
invoiceId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.post(
|
||||||
|
"/invoices/:id/pay",
|
||||||
|
zValidator("json", payInvoiceSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const invoiceId = c.req.param("id");
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const [invoice] = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||||
|
if (invoice.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
if (invoice.status === "draft" || invoice.status === "void") {
|
||||||
|
return c.json({ error: "Cannot pay a draft or void invoice" }, 422);
|
||||||
|
}
|
||||||
|
if (invoice.status === "paid") {
|
||||||
|
return c.json({ error: "Invoice is already paid" }, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
|
const result = await createPaymentIntent(invoiceId, clientId);
|
||||||
|
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
|
|
||||||
|
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const payMultipleSchema = z.object({
|
const payMultipleSchema = z.object({
|
||||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||||
});
|
});
|
||||||
@@ -544,23 +574,19 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
|
|||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const paymentMethodId = c.req.param("id");
|
const paymentMethodId = c.req.param("id");
|
||||||
|
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
|
||||||
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
|
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
|
|
||||||
|
|
||||||
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
|
|
||||||
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
|
|
||||||
return c.json({ error: "Payment method not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await detachPaymentMethod(paymentMethodId);
|
const ok = await detachPaymentMethod(paymentMethodId);
|
||||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Config endpoint ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
portalRouter.get("/config", (c) => {
|
||||||
|
return c.json({
|
||||||
|
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
||||||
// Allows the dev login selector to vend an impersonation session for a client
|
// Allows the dev login selector to vend an impersonation session for a client
|
||||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
|
|||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
basePriceCents: z.number().int().positive(),
|
basePriceCents: z.number().int().positive(),
|
||||||
durationMinutes: z.number().int().positive().max(480),
|
durationMinutes: z.number().int().positive(),
|
||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { z } from "zod/v3";
|
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
import { eq, getDb, invoices } from "@groombook/db";
|
||||||
import { getStripeClient } from "../services/payment.js";
|
|
||||||
|
|
||||||
export const webhooksRouter = new Hono();
|
export const webhooksRouter = new Hono();
|
||||||
|
|
||||||
webhooksRouter.post("/stripe", async (c) => {
|
webhooksRouter.post("/stripe", async (c) => {
|
||||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
if (!webhookSecret) {
|
if (!secret) {
|
||||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,14 +22,11 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
return c.json({ error: "Could not read body" }, 400);
|
return c.json({ error: "Could not read body" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
|
||||||
if (!stripe) {
|
|
||||||
return c.json({ error: "Stripe not configured" }, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
let event: Stripe.Event;
|
let event: Stripe.Event;
|
||||||
try {
|
try {
|
||||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||||
return c.json({ error: message }, 401);
|
return c.json({ error: message }, 401);
|
||||||
@@ -45,13 +40,10 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
|
||||||
if (!parsed.success) continue;
|
|
||||||
const invoiceIdTrimmed = invoiceId.trim();
|
|
||||||
const [inv] = await db
|
const [inv] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.id, invoiceIdTrimmed))
|
.where(eq(invoices.id, invoiceId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!inv) continue;
|
if (!inv) continue;
|
||||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||||
@@ -64,7 +56,7 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
stripePaymentIntentId: pi.id,
|
stripePaymentIntentId: pi.id,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
.where(eq(invoices.id, invoiceId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "payment_intent.payment_failed") {
|
} else if (event.type === "payment_intent.payment_failed") {
|
||||||
@@ -73,16 +65,13 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
|
||||||
if (!parsed.success) continue;
|
|
||||||
const invoiceIdTrimmed = invoiceId.trim();
|
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
.where(eq(invoices.id, invoiceId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "charge.refunded") {
|
} else if (event.type === "charge.refunded") {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
|
import { getDb, clients, eq, invoices } from "@groombook/db";
|
||||||
|
|
||||||
let _stripe: Stripe | null | undefined;
|
let _stripe: Stripe | null | undefined;
|
||||||
|
|
||||||
export function getStripeClient(): Stripe | null {
|
function getStripeClient(): Stripe | null {
|
||||||
if (_stripe === undefined) {
|
if (_stripe === undefined) {
|
||||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||||
if (!secretKey) return null;
|
if (!secretKey) return null;
|
||||||
@@ -59,8 +59,8 @@ export async function createPaymentIntent(
|
|||||||
const allInvoices = await db
|
const allInvoices = await db
|
||||||
.select({ totalCents: invoices.totalCents })
|
.select({ totalCents: invoices.totalCents })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(inArray(invoices.id, invoiceIds));
|
.where(eq(invoices.id, firstInvoiceId));
|
||||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@groombook/types": "workspace:*",
|
"@groombook/types": "workspace:*",
|
||||||
"@stripe/react-stripe-js": "^6.1.0",
|
|
||||||
"@stripe/stripe-js": "^9.1.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showReschedule && rescheduleAppointment && (
|
{showReschedule && rescheduleAppointment && (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment as any}
|
appointment={rescheduleAppointment as any}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
|
||||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
|
||||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
@@ -12,28 +10,31 @@ interface Invoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PaymentMethod {
|
interface PaymentMethod {
|
||||||
id: string;
|
|
||||||
brand: string;
|
brand: string;
|
||||||
last4: string;
|
last4: string;
|
||||||
expiryMonth: number;
|
expiryMonth: number;
|
||||||
expiryYear: number;
|
expiryYear: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Package {
|
||||||
|
name: string;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface BillingPaymentsProps {
|
interface BillingPaymentsProps {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||||
const [packages] = useState<{ name: string; remaining: number }[]>([]);
|
const [packages, setPackages] = useState<Package[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
||||||
const [autopay, setAutopay] = useState(false);
|
const [autopay, setAutopay] = useState(false);
|
||||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
const [publishableKey, setPublishableKey] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@@ -43,37 +44,20 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [configRes, invoicesRes, methodsRes] = await Promise.all([
|
const response = await fetch("/api/portal/invoices", {
|
||||||
fetch("/api/portal/config", {
|
headers: {
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
"X-Impersonation-Session-Id": sessionId,
|
||||||
}),
|
},
|
||||||
fetch("/api/portal/invoices", {
|
});
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
|
||||||
}),
|
|
||||||
fetch("/api/portal/payment-methods", {
|
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!configRes.ok) throw new Error("Failed to fetch config");
|
if (!response.ok) {
|
||||||
const configData = await configRes.json();
|
throw new Error("Failed to fetch invoices");
|
||||||
setPublishableKey(configData.stripePublishableKey ?? "");
|
|
||||||
|
|
||||||
const invoicesData = await invoicesRes.json();
|
|
||||||
setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []);
|
|
||||||
|
|
||||||
if (methodsRes.ok) {
|
|
||||||
const methodsData = await methodsRes.json();
|
|
||||||
setPaymentMethods(
|
|
||||||
(methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({
|
|
||||||
id: m.id,
|
|
||||||
brand: m.card?.brand ?? "unknown",
|
|
||||||
last4: m.card?.last4 ?? "****",
|
|
||||||
expiryMonth: m.card?.exp_month ?? 0,
|
|
||||||
expiryYear: m.card?.exp_year ?? 0,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setInvoices(Array.isArray(data) ? data : data.invoices || []);
|
||||||
|
setPaymentMethods(data.paymentMethods || []);
|
||||||
|
setPackages(data.packages || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "An error occurred");
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -84,8 +68,12 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const formatCents = (cents: number) =>
|
const formatCents = (cents: number) => {
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(cents / 100);
|
||||||
|
};
|
||||||
|
|
||||||
const pending = invoices.filter((i) => i.status === "pending");
|
const pending = invoices.filter((i) => i.status === "pending");
|
||||||
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
||||||
@@ -94,9 +82,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="animate-pulse space-y-4">
|
<div className="animate-pulse space-y-4">
|
||||||
<div className="h-6 bg-gray-200 rounded w-1/3" />
|
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
|
||||||
<div className="h-24 bg-gray-200 rounded" />
|
<div className="h-24 bg-gray-200 rounded"></div>
|
||||||
<div className="h-24 bg-gray-200 rounded" />
|
<div className="h-24 bg-gray-200 rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -112,6 +100,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Outstanding Balance Banner */}
|
||||||
{totalPending > 0 && (
|
{totalPending > 0 && (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -121,15 +110,16 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPaymentModal(true)}
|
onClick={() => setShowPaymentModal(true)}
|
||||||
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||||
>
|
>
|
||||||
Pay Now
|
Pay Now
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
@@ -151,6 +141,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Invoices */}
|
||||||
{tab === "invoices" && (
|
{tab === "invoices" && (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -161,7 +152,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<th className="px-5 py-3 font-medium">Description</th>
|
<th className="px-5 py-3 font-medium">Description</th>
|
||||||
<th className="px-5 py-3 font-medium">Amount</th>
|
<th className="px-5 py-3 font-medium">Amount</th>
|
||||||
<th className="px-5 py-3 font-medium">Status</th>
|
<th className="px-5 py-3 font-medium">Status</th>
|
||||||
<th className="px-5 py-3 font-medium" />
|
<th className="px-5 py-3 font-medium"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -169,7 +160,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
||||||
<td className="px-5 py-3 text-stone-700">
|
<td className="px-5 py-3 text-stone-700">
|
||||||
{new Date(inv.date).toLocaleDateString("en-US", {
|
{new Date(inv.date).toLocaleDateString("en-US", {
|
||||||
month: "short", day: "numeric", year: "numeric",
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-stone-600">
|
<td className="px-5 py-3 text-stone-600">
|
||||||
@@ -208,6 +201,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Payment Methods */}
|
||||||
{tab === "payment" && (
|
{tab === "payment" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{paymentMethods.length === 0 ? (
|
{paymentMethods.length === 0 ? (
|
||||||
@@ -216,7 +210,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paymentMethods.map((method) => (
|
{paymentMethods.map((method) => (
|
||||||
<div
|
<div
|
||||||
key={method.id}
|
key={`${method.brand}-${method.last4}`}
|
||||||
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -229,18 +223,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button className="text-sm text-blue-600 hover:underline">
|
||||||
onClick={async () => {
|
|
||||||
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-sm text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -249,6 +232,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Autopay */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -257,7 +241,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
||||||
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
<p className="text-xs text-stone-500">
|
||||||
|
Automatically charge after each appointment
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
@@ -283,13 +269,17 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Packages */}
|
||||||
{tab === "packages" && (
|
{tab === "packages" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{packages.length === 0 ? (
|
{packages.length === 0 ? (
|
||||||
<p className="text-gray-500 italic">No packages purchased</p>
|
<p className="text-gray-500 italic">No packages purchased</p>
|
||||||
) : (
|
) : (
|
||||||
packages.map((pkg, index) => (
|
packages.map((pkg, index) => (
|
||||||
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-stone-800">{pkg.name}</span>
|
<span className="font-medium text-stone-800">{pkg.name}</span>
|
||||||
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
||||||
@@ -300,124 +290,60 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showPaymentModal && publishableKey && (
|
{/* Payment Modal */}
|
||||||
<PaymentModalWrapper
|
{showPaymentModal && (
|
||||||
key={Date.now()}
|
<PaymentModal
|
||||||
sessionId={sessionId ?? ""}
|
|
||||||
publishableKey={publishableKey}
|
|
||||||
pending={pending}
|
pending={pending}
|
||||||
|
totalPending={totalPending}
|
||||||
onClose={() => setShowPaymentModal(false)}
|
onClose={() => setShowPaymentModal(false)}
|
||||||
onSuccess={() => {
|
|
||||||
setInvoices((prev) =>
|
|
||||||
prev.map((inv) =>
|
|
||||||
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setShowPaymentModal(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaymentModalWrapperProps {
|
function PaymentModal({
|
||||||
sessionId: string;
|
pending,
|
||||||
publishableKey: string;
|
totalPending: _totalPending,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
pending: Invoice[];
|
pending: Invoice[];
|
||||||
|
totalPending: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
}) {
|
||||||
}
|
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
|
||||||
|
new Set(pending.map((i) => i.id))
|
||||||
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
|
|
||||||
const [stripePromise] = useState(() =>
|
|
||||||
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
|
||||||
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
|
|
||||||
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
|
|
||||||
</Elements>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaymentModalProps {
|
|
||||||
sessionId: string;
|
|
||||||
pending: Invoice[];
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) {
|
|
||||||
const stripe = useStripe();
|
|
||||||
const elements = useElements();
|
|
||||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(pending.map((i) => i.id)));
|
|
||||||
const [saveCard, setSaveCard] = useState(false);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const formatCents = (cents: number) =>
|
const formatCents = (cents: number) =>
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(cents / 100);
|
||||||
|
|
||||||
const toggleInvoice = (id: string) => {
|
const toggleInvoice = (id: string) => {
|
||||||
const next = new Set(selectedInvoices);
|
const next = new Set(selectedInvoices);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) {
|
||||||
else next.add(id);
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
setSelectedInvoices(next);
|
setSelectedInvoices(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
|
|
||||||
|
|
||||||
const handlePay = async () => {
|
const handlePay = async () => {
|
||||||
if (!stripe || !elements) return;
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setError(null);
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
setIsProcessing(false);
|
||||||
try {
|
setIsComplete(true);
|
||||||
const isMulti = selectedInvoices.size > 1;
|
|
||||||
const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`;
|
|
||||||
const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {};
|
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Impersonation-Session-Id": sessionId,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error ?? "Failed to initialize payment");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { clientSecret } = await res.json();
|
|
||||||
|
|
||||||
const { error: stripeError } = await stripe.confirmPayment({
|
|
||||||
elements,
|
|
||||||
clientSecret,
|
|
||||||
confirmParams: saveCard
|
|
||||||
? { setup_future_usage: "off_session" }
|
|
||||||
: undefined,
|
|
||||||
redirect: "if_required",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (stripeError) {
|
|
||||||
setError(stripeError.message ?? "Payment failed");
|
|
||||||
setIsProcessing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsComplete(true);
|
|
||||||
onSuccess();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "An unexpected error occurred");
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedTotal = pending
|
||||||
|
.filter((i) => selectedInvoices.has(i.id))
|
||||||
|
.reduce((sum, i) => sum + i.totalCents, 0);
|
||||||
|
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
@@ -431,7 +357,10 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
<p className="text-stone-500 text-sm mb-6">
|
<p className="text-stone-500 text-sm mb-6">
|
||||||
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
|
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
|
||||||
</p>
|
</p>
|
||||||
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -479,36 +408,22 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span>
|
<span className="text-sm font-medium text-stone-800">
|
||||||
|
{formatCents(inv.totalCents)}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-stone-200 pt-4 mb-6">
|
<div className="border-t border-stone-200 pt-4 mb-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-stone-600">Total</span>
|
<span className="text-sm text-stone-600">Total</span>
|
||||||
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
|
<span className="text-lg font-bold text-stone-800">
|
||||||
|
{formatCents(selectedTotal)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PaymentElement />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 mb-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={saveCard}
|
|
||||||
onChange={(e) => setSaveCard(e.target.checked)}
|
|
||||||
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-stone-600">Save card for future payments</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -518,7 +433,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePay}
|
onClick={handlePay}
|
||||||
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
|
disabled={selectedInvoices.size === 0 || isProcessing}
|
||||||
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isProcessing ? "Processing..." : "Pay Now"}
|
{isProcessing ? "Processing..." : "Pay Now"}
|
||||||
@@ -529,8 +444,4 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BillingPayments(props: BillingPaymentsProps) {
|
|
||||||
return <BillingPaymentsInner {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BillingPayments;
|
export default BillingPayments;
|
||||||
+1
-1
Submodule infra updated: b667a3f005...d6c0d13d02
Generated
-54
@@ -86,12 +86,6 @@ importers:
|
|||||||
'@groombook/types':
|
'@groombook/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/types
|
version: link:../../packages/types
|
||||||
'@stripe/react-stripe-js':
|
|
||||||
specifier: ^6.1.0
|
|
||||||
version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
|
||||||
'@stripe/stripe-js':
|
|
||||||
specifier: ^9.1.0
|
|
||||||
version: 9.1.0
|
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||||
@@ -2118,17 +2112,6 @@ packages:
|
|||||||
'@standard-schema/utils@0.3.0':
|
'@standard-schema/utils@0.3.0':
|
||||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
'@stripe/react-stripe-js@6.1.0':
|
|
||||||
resolution: {integrity: sha512-LbKbRv4+wUSHLb5VNxqiYcKaqXPvTju0bJaF0RrzH0h4+aKWDXAk4RzUBcpNxxj8KtjuxICElANs1Li7aTv1IQ==}
|
|
||||||
peerDependencies:
|
|
||||||
'@stripe/stripe-js': '>=9.0.0 <10.0.0'
|
|
||||||
react: '>=16.8.0 <20.0.0'
|
|
||||||
react-dom: '>=16.8.0 <20.0.0'
|
|
||||||
|
|
||||||
'@stripe/stripe-js@9.1.0':
|
|
||||||
resolution: {integrity: sha512-v51LoEfZNiNS/5DcarWPCYgn24w4dqwwALR4GTbMW/N0DDzzj4DgYNoixX6PYvpt6uIJMucGUabn/BHhylggIQ==}
|
|
||||||
engines: {node: '>=12.16'}
|
|
||||||
|
|
||||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||||
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
||||||
|
|
||||||
@@ -3628,10 +3611,6 @@ packages:
|
|||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
|
||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
loupe@3.2.1:
|
loupe@3.2.1:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
@@ -3723,10 +3702,6 @@ packages:
|
|||||||
nwsapi@2.2.23:
|
nwsapi@2.2.23:
|
||||||
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
object-inspect@1.13.4:
|
object-inspect@1.13.4:
|
||||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3844,9 +3819,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3859,9 +3831,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
react: ^19.2.4
|
||||||
|
|
||||||
react-is@16.13.1:
|
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
|
||||||
|
|
||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
@@ -6714,15 +6683,6 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@stripe/react-stripe-js@6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
|
||||||
dependencies:
|
|
||||||
'@stripe/stripe-js': 9.1.0
|
|
||||||
prop-types: 15.8.1
|
|
||||||
react: 19.2.4
|
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
|
||||||
|
|
||||||
'@stripe/stripe-js@9.1.0': {}
|
|
||||||
|
|
||||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
ejs: 3.1.10
|
ejs: 3.1.10
|
||||||
@@ -8277,10 +8237,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
|
||||||
dependencies:
|
|
||||||
js-tokens: 4.0.0
|
|
||||||
|
|
||||||
loupe@3.2.1: {}
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
@@ -8355,8 +8311,6 @@ snapshots:
|
|||||||
|
|
||||||
nwsapi@2.2.23: {}
|
nwsapi@2.2.23: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|
||||||
object-keys@1.1.1: {}
|
object-keys@1.1.1: {}
|
||||||
@@ -8461,12 +8415,6 @@ snapshots:
|
|||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
||||||
prop-types@15.8.1:
|
|
||||||
dependencies:
|
|
||||||
loose-envify: 1.4.0
|
|
||||||
object-assign: 4.1.1
|
|
||||||
react-is: 16.13.1
|
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
@@ -8478,8 +8426,6 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
||||||
|
|||||||
Reference in New Issue
Block a user