Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2db922bb14 | |||
| 5aec436fb7 | |||
| 0aabb866c2 | |||
| 37fc33877c | |||
| 00619a07e0 | |||
| d76e055962 | |||
| c1e8d9830d | |||
| dd0a57dcf9 | |||
| ce5a5c5a02 | |||
| 1a0bbb5c01 |
@@ -22,6 +22,8 @@
|
|||||||
"hono": "^4.6.17",
|
"hono": "^4.6.17",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
|
"stripe": "^22.0.0",
|
||||||
|
"telnyx": "^6.41.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { getDb, businessSettings, reminderLogs, eq, sql, and, gte, lt } from "@groombook/db";
|
||||||
|
import { requireRole } from "../middleware/rbac.js";
|
||||||
|
import { createSmsProvider } from "../services/sms.js";
|
||||||
|
|
||||||
|
export const adminSmsRouter = new Hono();
|
||||||
|
|
||||||
|
adminSmsRouter.get("/status", requireManager(), async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [settings] = await db.select().from(businessSettings).limit(1);
|
||||||
|
|
||||||
|
const provider = createSmsProvider();
|
||||||
|
const smsEnabled = process.env.SMS_ENABLED === "true";
|
||||||
|
const providerName = process.env.SMS_PROVIDER ?? "none";
|
||||||
|
const fromNumber = process.env.TELNYX_FROM_NUMBER ?? null;
|
||||||
|
const connectionStatus = provider ? "connected" : "disconnected";
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
const statsRows = await db
|
||||||
|
.select({
|
||||||
|
status: reminderLogs.deliveryStatus,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(reminderLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminderLogs.channel, "sms"),
|
||||||
|
gte(reminderLogs.sentAt, startOfMonth)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(reminderLogs.deliveryStatus);
|
||||||
|
|
||||||
|
const totals = { sent: 0, delivered: 0, failed: 0 };
|
||||||
|
for (const row of statsRows) {
|
||||||
|
if (row.status === "delivered") totals.delivered = row.count;
|
||||||
|
else if (row.status === "failed") totals.failed = row.count;
|
||||||
|
else totals.sent += row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
providerName,
|
||||||
|
fromNumber,
|
||||||
|
connectionStatus,
|
||||||
|
smsEnabled,
|
||||||
|
businessSmsEnabled: settings?.smsEnabled ?? false,
|
||||||
|
stats: totals,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,12 +4,35 @@ import { z } from "zod/v3";
|
|||||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
|
function normalizeE164(phone: string): string | null {
|
||||||
|
const digits = phone.replace(/\D/g, "");
|
||||||
|
if (digits.length === 10) return `+1${digits}`;
|
||||||
|
if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
|
||||||
|
if (digits.length > 11 && digits.startsWith("1")) return `+${digits.slice(0, 11)}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function e164String() {
|
||||||
|
return z.string().transform((v, ctx) => {
|
||||||
|
if (!v) return v as unknown as undefined;
|
||||||
|
const normalized = normalizeE164(v);
|
||||||
|
if (!normalized) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid phone number. Must be a valid E.164 number (e.g. +12125551234).",
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const clientsRouter = new Hono<AppEnv>();
|
export const clientsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
email: z.string().email().optional(),
|
email: z.string().email().optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: e164String().optional(),
|
||||||
address: z.string().max(500).optional(),
|
address: z.string().max(500).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -338,3 +338,41 @@ invoicesRouter.patch(
|
|||||||
return c.json({ ...updated, lineItems });
|
return c.json({ ...updated, lineItems });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { processRefund } from "../services/payment.js";
|
||||||
|
|
||||||
|
const refundSchema = z.object({
|
||||||
|
amountCents: z.number().int().nonnegative().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
invoicesRouter.post(
|
||||||
|
"/:id/refund",
|
||||||
|
zValidator("json", refundSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const staff = c.get("staff");
|
||||||
|
if (!staff) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
if (staff.role !== "manager" && !staff.isSuperUser) {
|
||||||
|
return c.json({ error: "Manager role required" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||||
|
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||||
|
if (invoice.status !== "paid") {
|
||||||
|
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||||
|
}
|
||||||
|
if (!invoice.stripePaymentIntentId) {
|
||||||
|
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await processRefund(id, body.amountCents);
|
||||||
|
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||||
|
|
||||||
|
return c.json({ refundId: result.refundId });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ 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));
|
||||||
@@ -123,7 +129,7 @@ portalRouter.get("/invoices", async (c) => {
|
|||||||
id: inv.id,
|
id: inv.id,
|
||||||
status: inv.status,
|
status: inv.status,
|
||||||
totalCents: inv.totalCents,
|
totalCents: inv.totalCents,
|
||||||
createdAt: inv.createdAt,
|
date: 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 })),
|
||||||
})));
|
})));
|
||||||
});
|
});
|
||||||
@@ -448,6 +454,144 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Payment routes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPaymentIntent,
|
||||||
|
listPaymentMethods,
|
||||||
|
detachPaymentMethod,
|
||||||
|
createSetupIntent,
|
||||||
|
getOrCreateStripeCustomer,
|
||||||
|
} 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({
|
||||||
|
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.post(
|
||||||
|
"/invoices/pay-multiple",
|
||||||
|
zValidator("json", payMultipleSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const invoiceRows = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(inArray(invoices.id, body.invoiceIds));
|
||||||
|
|
||||||
|
if (invoiceRows.length !== body.invoiceIds.length) {
|
||||||
|
return c.json({ error: "One or more invoices not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inv of invoiceRows) {
|
||||||
|
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
if (inv.status === "draft" || inv.status === "void") {
|
||||||
|
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
|
||||||
|
}
|
||||||
|
if (inv.status === "paid") {
|
||||||
|
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstInvoice = invoiceRows[0];
|
||||||
|
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
||||||
|
if (!allSameClient) {
|
||||||
|
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
|
const result = await createPaymentIntent(body.invoiceIds, clientId);
|
||||||
|
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
|
|
||||||
|
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
portalRouter.get("/payment-methods", async (c) => {
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const methods = await listPaymentMethods(clientId);
|
||||||
|
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
|
return c.json(methods);
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.post("/payment-methods", async (c) => {
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
|
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||||
|
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
|
||||||
|
|
||||||
|
const result = await createSetupIntent(customerId);
|
||||||
|
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
|
|
||||||
|
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const paymentMethodId = c.req.param("id");
|
||||||
|
const ok = await detachPaymentMethod(paymentMethodId);
|
||||||
|
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||||
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
getDb,
|
||||||
|
clients,
|
||||||
|
reminderLogs,
|
||||||
|
smsSend,
|
||||||
|
} from "@groombook/db";
|
||||||
|
import { TelnyxProvider } from "../services/sms.js";
|
||||||
|
|
||||||
|
export const webhooksRouter = new Hono();
|
||||||
|
|
||||||
|
const telnyxProvider = new TelnyxProvider();
|
||||||
|
|
||||||
|
const STOP_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
|
||||||
|
const START_KEYWORDS = new Set(["START", "YES", "UNSTOP"]);
|
||||||
|
|
||||||
|
webhooksRouter.post("/sms/inbound", async (c) => {
|
||||||
|
if (!telnyxProvider.validateWebhookSignature(c.req.raw)) {
|
||||||
|
return c.json({ error: "Invalid signature" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Invalid JSON" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = (body.data as Record<string, unknown>)?.event_type ?? body.event_type;
|
||||||
|
const payload = (body.data as Record<string, unknown>) ?? body;
|
||||||
|
|
||||||
|
if (event === "message.received") {
|
||||||
|
const fromField = payload.from;
|
||||||
|
const from = typeof fromField === "object" && fromField !== null
|
||||||
|
? (fromField as Record<string, unknown>).phone_number as string ?? (fromField as Record<string, unknown>).toString()
|
||||||
|
: String(fromField ?? "");
|
||||||
|
const text = String(payload.text ?? payload.body ?? "").trim().toUpperCase();
|
||||||
|
|
||||||
|
if (!from || !text) {
|
||||||
|
return c.json({ error: "Missing from or text" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select({ id: clients.id, smsOptIn: clients.smsOptIn })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.phone, from))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return c.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (STOP_KEYWORDS.has(text)) {
|
||||||
|
await db
|
||||||
|
.update(clients)
|
||||||
|
.set({
|
||||||
|
smsOptIn: false,
|
||||||
|
smsOptOutDate: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(clients.id, client.id));
|
||||||
|
return c.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (START_KEYWORDS.has(text)) {
|
||||||
|
await db
|
||||||
|
.update(clients)
|
||||||
|
.set({
|
||||||
|
smsOptIn: true,
|
||||||
|
smsConsentDate: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(clients.id, client.id));
|
||||||
|
return c.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === "HELP") {
|
||||||
|
const supportUrl = process.env.SUPPORT_URL ?? "https://groombook.app/support";
|
||||||
|
await smsSend(from, `GroomBook appointment reminders. Reply STOP to opt out. For help, visit ${supportUrl}.`);
|
||||||
|
return c.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === "message.finalized" || event === "message.status") {
|
||||||
|
const status = String(payload.status ?? "");
|
||||||
|
const toField = payload.to;
|
||||||
|
const toNumber = typeof toField === "object" && toField !== null
|
||||||
|
? (toField as Record<string, unknown>).phone_number as string ?? (toField as Record<string, unknown>).toString()
|
||||||
|
: String(toField ?? "");
|
||||||
|
|
||||||
|
if (!status || !toNumber) {
|
||||||
|
return c.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDelivery = ["delivered", "sent", "failed", "sending", "queued"];
|
||||||
|
if (!validDelivery.includes(status)) {
|
||||||
|
return c.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.phone, toNumber))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (client) {
|
||||||
|
const [log] = await db
|
||||||
|
.select({ id: reminderLogs.id })
|
||||||
|
.from(reminderLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminderLogs.channel, "sms")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (log) {
|
||||||
|
await db
|
||||||
|
.update(reminderLogs)
|
||||||
|
.set({ deliveryStatus: status })
|
||||||
|
.where(eq(reminderLogs.id, log.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import Stripe from "stripe";
|
||||||
|
import { getDb, clients, eq, invoices } from "@groombook/db";
|
||||||
|
|
||||||
|
let _stripe: Stripe | null | undefined;
|
||||||
|
|
||||||
|
function getStripeClient(): Stripe | null {
|
||||||
|
if (_stripe === undefined) {
|
||||||
|
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||||
|
if (!secretKey) return null;
|
||||||
|
_stripe = new Stripe(secretKey);
|
||||||
|
}
|
||||||
|
return _stripe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrCreateStripeCustomer(clientId: string): Promise<string | null> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return null;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||||
|
if (!client) return null;
|
||||||
|
|
||||||
|
if (client.stripeCustomerId) return client.stripeCustomerId;
|
||||||
|
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
metadata: { groombook_client_id: clientId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(clients)
|
||||||
|
.set({ stripeCustomerId: customer.id, updatedAt: new Date() })
|
||||||
|
.where(eq(clients.id, clientId));
|
||||||
|
|
||||||
|
return customer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPaymentIntent(
|
||||||
|
invoiceIdOrIds: string | string[],
|
||||||
|
clientId: string
|
||||||
|
): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return null;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
|
||||||
|
|
||||||
|
const invoiceRows = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceIds[0]));
|
||||||
|
|
||||||
|
const [invoice] = invoiceRows;
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
let totalCents = invoice.totalCents;
|
||||||
|
if (invoiceIds.length > 1) {
|
||||||
|
const allInvoices = await db
|
||||||
|
.select({ totalCents: invoices.totalCents })
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceIds[0]));
|
||||||
|
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||||
|
if (!stripeCustomerId) return null;
|
||||||
|
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
|
amount: totalCents,
|
||||||
|
currency: "usd",
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
metadata: {
|
||||||
|
groombook_invoice_ids: invoiceIds.join(","),
|
||||||
|
groombook_client_id: clientId,
|
||||||
|
},
|
||||||
|
automatic_payment_methods: { enabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const invId of invoiceIds) {
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() })
|
||||||
|
.where(eq(invoices.id, invId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientSecret: paymentIntent.client_secret!,
|
||||||
|
paymentIntentId: paymentIntent.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processRefund(
|
||||||
|
invoiceId: string,
|
||||||
|
amountCents?: number
|
||||||
|
): Promise<{ refundId: string } | null> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return null;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
|
||||||
|
if (!invoice?.stripePaymentIntentId) return null;
|
||||||
|
|
||||||
|
const refund = await stripe.refunds.create({
|
||||||
|
payment_intent: invoice.stripePaymentIntentId,
|
||||||
|
amount: amountCents,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ stripeRefundId: refund.id, updatedAt: new Date() })
|
||||||
|
.where(eq(invoices.id, invoiceId));
|
||||||
|
|
||||||
|
return { refundId: refund.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPaymentMethods(clientId: string): Promise<Stripe.PaymentMethod[] | null> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return null;
|
||||||
|
|
||||||
|
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||||
|
if (!stripeCustomerId) return null;
|
||||||
|
|
||||||
|
const methods = await stripe.paymentMethods.list({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
type: "card",
|
||||||
|
});
|
||||||
|
|
||||||
|
return methods.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachPaymentMethod(
|
||||||
|
clientId: string,
|
||||||
|
paymentMethodId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return false;
|
||||||
|
|
||||||
|
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||||
|
if (!stripeCustomerId) return false;
|
||||||
|
|
||||||
|
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detachPaymentMethod(paymentMethodId: string): Promise<boolean> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return false;
|
||||||
|
|
||||||
|
await stripe.paymentMethods.detach(paymentMethodId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return null;
|
||||||
|
|
||||||
|
const setupIntent = await stripe.setupIntents.create({
|
||||||
|
customer: customerId,
|
||||||
|
payment_method_types: ["card"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { clientSecret: setupIntent.client_secret! };
|
||||||
|
}
|
||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
buildReminderEmail,
|
buildReminderEmail,
|
||||||
sendEmail,
|
sendEmail,
|
||||||
} from "./email.js";
|
} from "./email.js";
|
||||||
|
import { smsSend } from "./sms.js";
|
||||||
|
|
||||||
// How many hours before the appointment to send each reminder.
|
// TCPA-required opt-out text appended to every SMS reminder
|
||||||
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
|
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
||||||
function getReminderWindows(): { label: string; hours: number }[] {
|
function getReminderWindows(): { label: string; hours: number }[] {
|
||||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||||
@@ -65,23 +66,39 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const appt of upcoming) {
|
for (const appt of upcoming) {
|
||||||
// Check if reminder already sent (unique constraint prevents double-send)
|
const [emailLog] = await db
|
||||||
const existing = await db
|
|
||||||
.select({ id: reminderLogs.id })
|
.select({ id: reminderLogs.id })
|
||||||
.from(reminderLogs)
|
.from(reminderLogs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
eq(reminderLogs.appointmentId, appt.id),
|
||||||
eq(reminderLogs.reminderType, window.label)
|
eq(reminderLogs.reminderType, window.label),
|
||||||
|
eq(reminderLogs.channel, "email")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing.length > 0) continue; // already sent
|
const [smsLog] = await db
|
||||||
|
.select({ id: reminderLogs.id })
|
||||||
|
.from(reminderLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminderLogs.appointmentId, appt.id),
|
||||||
|
eq(reminderLogs.reminderType, window.label),
|
||||||
|
eq(reminderLogs.channel, "sms")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
// Fetch related records for the email
|
// Fetch related records for the email
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
.select({
|
||||||
|
name: clients.name,
|
||||||
|
email: clients.email,
|
||||||
|
emailOptOut: clients.emailOptOut,
|
||||||
|
smsOptIn: clients.smsOptIn,
|
||||||
|
phoneE164: clients.phoneE164,
|
||||||
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.id, appt.clientId))
|
.where(eq(clients.id, appt.clientId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -112,8 +129,6 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
|
|
||||||
if (!pet || !service) continue;
|
if (!pet || !service) continue;
|
||||||
|
|
||||||
// Ensure the appointment has a confirmation token before sending the reminder.
|
|
||||||
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
confirmationToken = randomBytes(32).toString("hex");
|
confirmationToken = randomBytes(32).toString("hex");
|
||||||
@@ -123,27 +138,53 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
.where(eq(appointments.id, appt.id));
|
.where(eq(appointments.id, appt.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sent = await sendEmail(
|
if (!emailLog) {
|
||||||
buildReminderEmail(
|
const sent = await sendEmail(
|
||||||
client.email,
|
buildReminderEmail(
|
||||||
{
|
client.email,
|
||||||
clientName: client.name,
|
{
|
||||||
petName: pet.name,
|
clientName: client.name,
|
||||||
serviceName: service.name,
|
petName: pet.name,
|
||||||
groomerName,
|
serviceName: service.name,
|
||||||
startTime: appt.startTime,
|
groomerName,
|
||||||
},
|
startTime: appt.startTime,
|
||||||
window.hours,
|
},
|
||||||
confirmationToken
|
window.hours,
|
||||||
)
|
confirmationToken
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
// Record send — ignore conflicts (race condition between instances)
|
await db
|
||||||
await db
|
.insert(reminderLogs)
|
||||||
.insert(reminderLogs)
|
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
||||||
.values({ appointmentId: appt.id, reminderType: window.label })
|
.onConflictDoNothing();
|
||||||
.onConflictDoNothing();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smsLog && client.smsOptIn && client.phoneE164) {
|
||||||
|
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||||
|
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||||
|
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||||
|
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||||
|
const smsBody = [
|
||||||
|
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
|
||||||
|
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
|
||||||
|
`Confirm: ${confirmUrl}`,
|
||||||
|
`Cancel: ${cancelUrl}`,
|
||||||
|
TCPA_OPT_OUT,
|
||||||
|
].join(". ");
|
||||||
|
try {
|
||||||
|
const smsOk = await smsSend(client.phoneE164, smsBody);
|
||||||
|
if (smsOk) {
|
||||||
|
await db
|
||||||
|
.insert(reminderLogs)
|
||||||
|
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[reminders] SMS send failed:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Telnyx } from "telnyx";
|
||||||
|
|
||||||
|
export interface SmsProvider {
|
||||||
|
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
||||||
|
validateWebhookSignature(req: Request): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TelnyxSmsResult {
|
||||||
|
message_id: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTelnyxClient(): Telnyx | null {
|
||||||
|
const apiKey = process.env.TELNYX_API_KEY;
|
||||||
|
if (!apiKey) return null;
|
||||||
|
return new Telnyx(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _client: Telnyx | null | undefined;
|
||||||
|
|
||||||
|
function getClient(): Telnyx | null {
|
||||||
|
if (_client === undefined) _client = createTelnyxClient();
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFromNumber(): string | null {
|
||||||
|
return process.env.TELNYX_FROM_NUMBER ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isE164(phone: string): boolean {
|
||||||
|
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSms(
|
||||||
|
to: string,
|
||||||
|
body: string,
|
||||||
|
mediaUrls?: string[]
|
||||||
|
): Promise<{ messageId: string; status: string }> {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
||||||
|
|
||||||
|
const from = getFromNumber();
|
||||||
|
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
||||||
|
|
||||||
|
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
||||||
|
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mediaUrls && mediaUrls.length > 0) {
|
||||||
|
payload.media_urls = mediaUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
||||||
|
const smsResult = result.data as unknown as TelnyxSmsResult;
|
||||||
|
return {
|
||||||
|
messageId: smsResult.message_id,
|
||||||
|
status: smsResult.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelnyxProvider implements SmsProvider {
|
||||||
|
async sendSms(
|
||||||
|
to: string,
|
||||||
|
body: string,
|
||||||
|
mediaUrls?: string[]
|
||||||
|
): Promise<{ messageId: string; status: string }> {
|
||||||
|
return sendSms(to, body, mediaUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateWebhookSignature(req: Request): boolean {
|
||||||
|
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
const signature = req.headers.get("telnyx-signature");
|
||||||
|
if (!signature) return false;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(req.body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createHmac } = await import("crypto");
|
||||||
|
const hmac = createHmac("sha256", secret);
|
||||||
|
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
||||||
|
|
||||||
|
const sigBuf = Buffer.from(signature);
|
||||||
|
const expBuf = Buffer.from(expected);
|
||||||
|
|
||||||
|
if (sigBuf.length !== expBuf.length) return false;
|
||||||
|
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < sigBuf.length; i++) {
|
||||||
|
diff |= sigBuf[i] ^ expBuf[i];
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _provider: SmsProvider | null | undefined;
|
||||||
|
|
||||||
|
export function createSmsProvider(): SmsProvider | null {
|
||||||
|
if (_provider === undefined) {
|
||||||
|
if (process.env.SMS_ENABLED !== "true") {
|
||||||
|
_provider = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
switch (process.env.SMS_PROVIDER) {
|
||||||
|
case "telnyx": {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) {
|
||||||
|
_provider = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_provider = new TelnyxProvider();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
_provider = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function smsSend(
|
||||||
|
to: string,
|
||||||
|
body: string,
|
||||||
|
mediaUrls?: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
const provider = createSmsProvider();
|
||||||
|
if (!provider) return false;
|
||||||
|
|
||||||
|
await provider.sendSms(to, body, mediaUrls);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
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 {
|
||||||
@@ -10,31 +12,28 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
function BillingPaymentsInner({ 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, setPackages] = useState<Package[]>([]);
|
const [packages] = useState<{ name: string; remaining: number }[]>([]);
|
||||||
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() {
|
||||||
@@ -44,20 +43,37 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/portal/invoices", {
|
const [configRes, invoicesRes, methodsRes] = await Promise.all([
|
||||||
headers: {
|
fetch("/api/portal/config", {
|
||||||
"X-Impersonation-Session-Id": sessionId,
|
headers: { "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 (!response.ok) {
|
if (!configRes.ok) throw new Error("Failed to fetch config");
|
||||||
throw new Error("Failed to fetch invoices");
|
const configData = await configRes.json();
|
||||||
|
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 {
|
||||||
@@ -68,12 +84,8 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const formatCents = (cents: number) => {
|
const formatCents = (cents: number) =>
|
||||||
return new Intl.NumberFormat("en-US", {
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||||
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);
|
||||||
@@ -82,9 +94,9 @@ export function BillingPayments({ 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>
|
<div className="h-6 bg-gray-200 rounded w-1/3" />
|
||||||
<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 className="h-24 bg-gray-200 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -100,7 +112,6 @@ export function BillingPayments({ 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>
|
||||||
@@ -110,16 +121,15 @@ export function BillingPayments({ 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 },
|
||||||
@@ -141,7 +151,6 @@ export function BillingPayments({ 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">
|
||||||
@@ -152,7 +161,7 @@ export function BillingPayments({ 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>
|
<th className="px-5 py-3 font-medium" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -160,9 +169,7 @@ export function BillingPayments({ 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",
|
month: "short", day: "numeric", year: "numeric",
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-stone-600">
|
<td className="px-5 py-3 text-stone-600">
|
||||||
@@ -201,7 +208,6 @@ export function BillingPayments({ 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 ? (
|
||||||
@@ -210,7 +216,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paymentMethods.map((method) => (
|
{paymentMethods.map((method) => (
|
||||||
<div
|
<div
|
||||||
key={`${method.brand}-${method.last4}`}
|
key={method.id}
|
||||||
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">
|
||||||
@@ -223,7 +229,18 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="text-sm text-blue-600 hover:underline">
|
<button
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
@@ -232,7 +249,6 @@ export function BillingPayments({ 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">
|
||||||
@@ -241,9 +257,7 @@ export function BillingPayments({ 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">
|
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
||||||
Automatically charge after each appointment
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
@@ -269,17 +283,13 @@ export function BillingPayments({ 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
|
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
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>
|
||||||
@@ -290,59 +300,123 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Payment Modal */}
|
{showPaymentModal && publishableKey && (
|
||||||
{showPaymentModal && (
|
<PaymentModalWrapper
|
||||||
<PaymentModal
|
key={Date.now()}
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaymentModal({
|
interface PaymentModalWrapperProps {
|
||||||
pending,
|
sessionId: string;
|
||||||
totalPending: _totalPending,
|
publishableKey: string;
|
||||||
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", {
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||||
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)) {
|
if (next.has(id)) next.delete(id);
|
||||||
next.delete(id);
|
else next.add(id);
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
setSelectedInvoices(next);
|
setSelectedInvoices(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePay = async () => {
|
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
|
||||||
setIsProcessing(true);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
||||||
setIsProcessing(false);
|
|
||||||
setIsComplete(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedTotal = pending
|
const handlePay = async () => {
|
||||||
.filter((i) => selectedInvoices.has(i.id))
|
if (!stripe || !elements) return;
|
||||||
.reduce((sum, i) => sum + i.totalCents, 0);
|
setIsProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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: {
|
||||||
|
return_url: `${window.location.origin}/portal/billing`,
|
||||||
|
...(saveCard ? { setup_future_usage: "off_session" } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return (
|
return (
|
||||||
@@ -357,10 +431,7 @@ function PaymentModal({
|
|||||||
<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
|
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
|
||||||
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>
|
||||||
@@ -408,22 +479,36 @@ function PaymentModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-stone-800">
|
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span>
|
||||||
{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">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<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">
|
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
|
||||||
{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}
|
||||||
@@ -433,7 +518,7 @@ function PaymentModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePay}
|
onClick={handlePay}
|
||||||
disabled={selectedInvoices.size === 0 || isProcessing}
|
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
|
||||||
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"}
|
||||||
@@ -444,4 +529,8 @@ function PaymentModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BillingPayments(props: BillingPaymentsProps) {
|
||||||
|
return <BillingPaymentsInner {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default BillingPayments;
|
export default BillingPayments;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
API_HOST="https://api.minimax.io"
|
||||||
|
API_KEY="$MINIMAX_API_KEY"
|
||||||
|
OUTPUT_DIR="minimax-output"
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
# Diverse dog image prompts
|
||||||
|
declare -a PROMPTS=(
|
||||||
|
"A beautiful red Irish Setter with long flowing silky coat, standing proudly in golden hour sunlight, professional pet portrait photography, warm tones"
|
||||||
|
"A fluffy white Pomeranian puppy with thick fluffy coat, sitting alert with bright expression, studio white background, cute grooming"
|
||||||
|
"A black Schnauzer with distinctive full beard and mustache, freshly groomed with neat styling, professional grooming salon setting"
|
||||||
|
"A cream and white Cavalier King Charles Spaniel with silky coat, gentle sad eyes, soft warm indoor lighting, elegant pose"
|
||||||
|
"A brown and white Basset Hound with long droopy ears, lying down in relaxed pose, natural outdoor setting, peaceful expression"
|
||||||
|
"A black and tan miniature Dachshund with glossy coat, alert standing pose, warm studio lighting, detailed paws visible"
|
||||||
|
"A white fluffy Bichon Frise after professional grooming with rounded topknot, happy bouncy expression, bright cheerful background"
|
||||||
|
"A muscular fawn Boxer dog, athletic build, standing confidently outdoors in park, energetic expression, natural lighting"
|
||||||
|
"A blue merle Shetland Sheepdog with alert ears and fluffy coat, running happily, green grass field background, vibrant"
|
||||||
|
"A buff colored Cocker Spaniel with beautiful silky coat, friendly gentle expression, warm natural window lighting, indoor"
|
||||||
|
)
|
||||||
|
|
||||||
|
declare -a FILENAMES=(
|
||||||
|
"dog-setter-red-sunlit.png"
|
||||||
|
"dog-pomeranian-white-studio.png"
|
||||||
|
"dog-schnauzer-black-groomed.png"
|
||||||
|
"dog-cavalier-cream-gentle.png"
|
||||||
|
"dog-basset-brown-white.png"
|
||||||
|
"dog-dachshund-black-tan.png"
|
||||||
|
"dog-bichon-white-groomed.png"
|
||||||
|
"dog-boxer-fawn-athletic.png"
|
||||||
|
"dog-sheepdog-merle-running.png"
|
||||||
|
"dog-cocker-buff-friendly.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Generating ${#PROMPTS[@]} diverse dog images..."
|
||||||
|
|
||||||
|
for i in "${!PROMPTS[@]}"; do
|
||||||
|
PROMPT="${PROMPTS[$i]}"
|
||||||
|
FILENAME="${FILENAMES[$i]}"
|
||||||
|
|
||||||
|
echo -n "[$((i+1))/${#PROMPTS[@]}] $FILENAME... "
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
|
||||||
|
-H "Authorization: Bearer ${API_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"model\":\"image-01\",\"prompt\":\"${PROMPT}\",\"image_count\":1}")
|
||||||
|
|
||||||
|
# Extract image URL from response
|
||||||
|
IMAGE_URL=$(echo "$RESPONSE" | grep -o '"image_urls":\["\([^"]*\)' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -n "$IMAGE_URL" ]; then
|
||||||
|
curl -s "$IMAGE_URL" -o "$OUTPUT_DIR/$FILENAME" 2>/dev/null
|
||||||
|
if [ -f "$OUTPUT_DIR/$FILENAME" ] && [ -s "$OUTPUT_DIR/$FILENAME" ]; then
|
||||||
|
echo "✓"
|
||||||
|
else
|
||||||
|
echo "✗ (download failed)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✗ (no URL)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done! Generated images in $OUTPUT_DIR/"
|
||||||
|
ls -lh "$OUTPUT_DIR"/dog-*.png 2>/dev/null | wc -l
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Use the configured MiniMax API host
|
||||||
|
API_HOST="${MINIMAX_API_HOST:-https://api.minimax.io}"
|
||||||
|
API_KEY="$MINIMAX_API_KEY"
|
||||||
|
|
||||||
|
# Test endpoint - check which one works
|
||||||
|
echo "Testing API endpoints..."
|
||||||
|
echo "API_HOST: $API_HOST"
|
||||||
|
echo "API_KEY: ${API_KEY:0:15}..."
|
||||||
|
|
||||||
|
# Array of diverse dog images to generate
|
||||||
|
declare -a PROMPTS=(
|
||||||
|
"A beautiful red Irish Setter with flowing silky coat, standing proudly in a sunny garden, warm natural lighting, professional pet photography"
|
||||||
|
"A fluffy white Pomeranian with thick coat, sitting alert, bright studio background, cute expression"
|
||||||
|
"A black Schnauzer with distinctive beard, freshly groomed, professional salon setting, dignified pose"
|
||||||
|
"A cream-colored Cavalier King Charles Spaniel, silky coat, gentle expression, soft warm lighting"
|
||||||
|
"A brown and white Basset Hound, long ears, relaxed sitting pose, natural outdoor background"
|
||||||
|
"A black and tan Dachshund, elongated body, alert posture, warm studio lighting"
|
||||||
|
"A white Bichon Frise, fluffy groomed coat, happy expression, bright cheerful background"
|
||||||
|
"A fawn Boxer with muscular build, athletic posture, outdoor park setting, energetic expression"
|
||||||
|
"A merle Shetland Sheepdog, alert ears, running pose, green garden background"
|
||||||
|
"A buff-colored Cocker Spaniel, silky coat, friendly expression, warm natural light"
|
||||||
|
)
|
||||||
|
|
||||||
|
declare -a FILENAMES=(
|
||||||
|
"dog-setter-red-sunny.png"
|
||||||
|
"dog-pomeranian-white-alert.png"
|
||||||
|
"dog-schnauzer-groomed.png"
|
||||||
|
"dog-cavalier-cream.png"
|
||||||
|
"dog-basset-hound-outdoor.png"
|
||||||
|
"dog-dachshund-alert.png"
|
||||||
|
"dog-bichon-frise-happy.png"
|
||||||
|
"dog-boxer-athletic.png"
|
||||||
|
"dog-sheepdog-merle.png"
|
||||||
|
"dog-cocker-spaniel-buff.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p minimax-output
|
||||||
|
|
||||||
|
echo "Generating ${#PROMPTS[@]} diverse dog images..."
|
||||||
|
|
||||||
|
for i in "${!PROMPTS[@]}"; do
|
||||||
|
PROMPT="${PROMPTS[$i]}"
|
||||||
|
FILENAME="${FILENAMES[$i]}"
|
||||||
|
|
||||||
|
echo "[$((i+1))/${#PROMPTS[@]}] Generating: $FILENAME"
|
||||||
|
|
||||||
|
# Make API request
|
||||||
|
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
|
||||||
|
-H "Authorization: Bearer ${API_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"model\": \"image-01\",
|
||||||
|
\"prompt\": \"${PROMPT}\",
|
||||||
|
\"image_count\": 1
|
||||||
|
}")
|
||||||
|
|
||||||
|
# Check if response contains image data
|
||||||
|
if echo "$RESPONSE" | grep -q "data\|image_url\|file_content"; then
|
||||||
|
echo " ✓ Response received"
|
||||||
|
|
||||||
|
# Try to extract and save image data
|
||||||
|
# Different APIs format responses differently
|
||||||
|
IMAGE_DATA=$(echo "$RESPONSE" | grep -o '"file_content":"[^"]*' | head -1 | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -n "$IMAGE_DATA" ]; then
|
||||||
|
echo "$IMAGE_DATA" | base64 -d > "minimax-output/$FILENAME"
|
||||||
|
echo " ✓ Image saved to minimax-output/$FILENAME"
|
||||||
|
else
|
||||||
|
echo " ✗ Could not extract image data"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ✗ API response: ${RESPONSE:0:100}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Image generation complete!"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
api_key = os.environ.get("MINIMAX_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("MINIMAX_API_KEY environment variable not set")
|
||||||
|
|
||||||
|
url = "https://api.minimax.io/v1/image_generation"
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
os.makedirs("minimax-output", exist_ok=True)
|
||||||
|
|
||||||
|
prompts = [
|
||||||
|
{
|
||||||
|
"filename": "dog-puggle-fawn-playful.png",
|
||||||
|
"prompt": "Adorable fawn Puggle puppy with playful expression, compact muscular build, professional pet photography, studio lighting, photorealistic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "dog-puggle-black-sitting.png",
|
||||||
|
"prompt": "Black and tan Puggle with alert sitting posture, pointed beagle-like ears, gentle eyes, professional studio lighting, photorealistic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "dog-puggle-cream-groomed.png",
|
||||||
|
"prompt": "Cream Puggle freshly groomed with fluffy coat, happy expression, lying down comfortably, natural daylight, photorealistic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "dog-puggle-tricolor-outdoor.png",
|
||||||
|
"prompt": "Tricolor Puggle in outdoor garden setting, alert playful pose, natural sunlight, professional pet photography, photorealistic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "dog-puggle-fawn-grooming.png",
|
||||||
|
"prompt": "Fawn Puggle at grooming salon, gentle expression, compact muscular build with beagle-like features, professional grooming setup, warm lighting, photorealistic"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Generating {len(prompts)} Puggle images...")
|
||||||
|
|
||||||
|
for item in prompts:
|
||||||
|
filename = item["filename"]
|
||||||
|
prompt = item["prompt"]
|
||||||
|
|
||||||
|
print(f"\nGenerating {filename}...")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": "image-01",
|
||||||
|
"prompt": prompt,
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"response_format": "base64",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if "data" in data and "image_base64" in data["data"]:
|
||||||
|
images = data["data"]["image_base64"]
|
||||||
|
|
||||||
|
# Save the first (and usually only) image
|
||||||
|
output_path = f"minimax-output/{filename}"
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(base64.b64decode(images[0]))
|
||||||
|
|
||||||
|
file_size = os.path.getsize(output_path)
|
||||||
|
print(f"✓ Saved {filename} ({file_size} bytes)")
|
||||||
|
else:
|
||||||
|
print(f"✗ Unexpected response format: {json.dumps(data, indent=2)}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"✗ Error generating {filename}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Unexpected error for {filename}: {e}")
|
||||||
|
|
||||||
|
print("\n✓ Image generation complete!")
|
||||||
|
print("Files saved to minimax-output/")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "reminder_logs" DROP CONSTRAINT "reminder_logs_appointment_id_reminder_type_unique";--> statement-breakpoint
|
||||||
|
ALTER TABLE "business_settings" ADD COLUMN "logo_key" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "pets" ADD COLUMN "image" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reminder_logs" ADD COLUMN "channel" text DEFAULT 'email' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE("appointment_id","reminder_type","channel");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "business_settings" ADD COLUMN "sms_enabled" boolean NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"id": "0027_stripe_identifiers",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"authProviderConfig": {
|
||||||
|
"name": "auth_provider_config",
|
||||||
|
"columns": {
|
||||||
|
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||||
|
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
|
||||||
|
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
|
||||||
|
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
|
||||||
|
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
|
||||||
|
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
|
||||||
|
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
|
||||||
|
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
|
||||||
|
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
|
||||||
|
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||||
|
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {}
|
||||||
|
},
|
||||||
|
"businessSettings": {
|
||||||
|
"name": "business_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||||
|
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
|
||||||
|
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
|
||||||
|
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
|
||||||
|
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
|
||||||
|
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
|
||||||
|
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
|
||||||
|
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||||
|
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {}
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"name": "clients",
|
||||||
|
"columns": {
|
||||||
|
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||||
|
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||||
|
"email": { "name": "email", "type": "text", "isNullable": true },
|
||||||
|
"phone": { "name": "phone", "type": "text", "isNullable": true },
|
||||||
|
"address": { "name": "address", "type": "text", "isNullable": true },
|
||||||
|
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||||
|
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
|
||||||
|
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
|
||||||
|
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
|
||||||
|
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
|
||||||
|
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
|
||||||
|
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
|
||||||
|
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
|
||||||
|
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
|
||||||
|
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||||
|
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
|
||||||
|
},
|
||||||
|
"invoices": {
|
||||||
|
"name": "invoices",
|
||||||
|
"columns": {
|
||||||
|
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||||
|
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
|
||||||
|
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||||
|
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
|
||||||
|
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||||
|
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||||
|
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
|
||||||
|
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
|
||||||
|
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
|
||||||
|
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
|
||||||
|
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
|
||||||
|
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
|
||||||
|
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
|
||||||
|
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||||
|
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||||
|
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||||
|
},
|
||||||
|
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
|
||||||
|
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
||||||
|
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
|
||||||
|
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
|
||||||
|
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
|
||||||
|
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
|
||||||
|
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
|
||||||
|
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
|
||||||
|
},
|
||||||
|
"nativeEnums": {}
|
||||||
|
}
|
||||||
@@ -183,6 +183,13 @@
|
|||||||
"when": 1775482467192,
|
"when": 1775482467192,
|
||||||
"tag": "0025_rate_limit",
|
"tag": "0025_rate_limit",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776035812477,
|
||||||
|
"tag": "0026_boring_storm",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,10 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
|||||||
address: "1 Main St, Springfield, CA 90000",
|
address: "1 Main St, Springfield, CA 90000",
|
||||||
notes: null,
|
notes: null,
|
||||||
emailOptOut: false,
|
emailOptOut: false,
|
||||||
|
smsOptIn: false,
|
||||||
|
smsConsentDate: null,
|
||||||
|
smsOptOutDate: null,
|
||||||
|
smsConsentText: null,
|
||||||
status: "active",
|
status: "active",
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
|||||||
@@ -109,8 +109,11 @@ export const clients = pgTable("clients", {
|
|||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
address: text("address"),
|
address: text("address"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
// Set to true if the client has opted out of email reminders/notifications
|
|
||||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||||
|
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||||
|
smsConsentDate: timestamp("sms_consent_date"),
|
||||||
|
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||||
|
smsConsentText: text("sms_consent_text"),
|
||||||
status: clientStatusEnum("status").notNull().default("active"),
|
status: clientStatusEnum("status").notNull().default("active"),
|
||||||
disabledAt: timestamp("disabled_at"),
|
disabledAt: timestamp("disabled_at"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
@@ -305,11 +308,11 @@ export const reminderLogs = pgTable(
|
|||||||
appointmentId: uuid("appointment_id")
|
appointmentId: uuid("appointment_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||||
// "confirmation" | "24h" | "2h"
|
|
||||||
reminderType: text("reminder_type").notNull(),
|
reminderType: text("reminder_type").notNull(),
|
||||||
|
channel: text("channel").notNull().default("email"),
|
||||||
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^6.9.16
|
specifier: ^6.9.16
|
||||||
version: 6.10.1
|
version: 6.10.1
|
||||||
|
stripe:
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.0.1(@types/node@22.19.15)
|
||||||
|
telnyx:
|
||||||
|
specifier: ^6.41.0
|
||||||
|
version: 6.41.0(ws@8.19.0)
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -83,6 +89,12 @@ 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))
|
||||||
@@ -2103,12 +2115,26 @@ packages:
|
|||||||
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
|
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@stablelib/base64@1.0.1':
|
||||||
|
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
'@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==}
|
||||||
|
|
||||||
@@ -3073,6 +3099,9 @@ packages:
|
|||||||
fast-levenshtein@2.0.6:
|
fast-levenshtein@2.0.6:
|
||||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||||
|
|
||||||
|
fast-sha256@1.3.0:
|
||||||
|
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||||
|
|
||||||
fast-uri@3.1.0:
|
fast-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||||
|
|
||||||
@@ -3608,6 +3637,10 @@ 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==}
|
||||||
|
|
||||||
@@ -3699,6 +3732,10 @@ 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'}
|
||||||
@@ -3816,6 +3853,9 @@ 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'}
|
||||||
@@ -3828,6 +3868,9 @@ 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==}
|
||||||
|
|
||||||
@@ -4066,6 +4109,9 @@ packages:
|
|||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
standardwebhooks@1.0.0:
|
||||||
|
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||||
|
|
||||||
std-env@3.10.0:
|
std-env@3.10.0:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
@@ -4124,6 +4170,15 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
|
stripe@22.0.1:
|
||||||
|
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
strnum@2.2.1:
|
strnum@2.2.1:
|
||||||
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
||||||
|
|
||||||
@@ -4145,6 +4200,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
telnyx@6.41.0:
|
||||||
|
resolution: {integrity: sha512-93eKksI6HnLYp8e4DGlpC3SkBAfagblE+uug0FNDLT/+mix3PP0RveoQ/YZeRdxDhjMcoXVgeusJsgFP6PvUdw==}
|
||||||
|
peerDependencies:
|
||||||
|
ws: ^8.18.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ws:
|
||||||
|
optional: true
|
||||||
|
|
||||||
temp-dir@2.0.0:
|
temp-dir@2.0.0:
|
||||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6667,10 +6730,21 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@stablelib/base64@1.0.1': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@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
|
||||||
@@ -7703,6 +7777,8 @@ snapshots:
|
|||||||
|
|
||||||
fast-levenshtein@2.0.6: {}
|
fast-levenshtein@2.0.6: {}
|
||||||
|
|
||||||
|
fast-sha256@1.3.0: {}
|
||||||
|
|
||||||
fast-uri@3.1.0: {}
|
fast-uri@3.1.0: {}
|
||||||
|
|
||||||
fast-xml-builder@1.1.4:
|
fast-xml-builder@1.1.4:
|
||||||
@@ -8225,6 +8301,10 @@ 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: {}
|
||||||
@@ -8299,6 +8379,8 @@ 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: {}
|
||||||
@@ -8403,6 +8485,12 @@ 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:
|
||||||
@@ -8414,6 +8502,8 @@ 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):
|
||||||
@@ -8690,6 +8780,11 @@ snapshots:
|
|||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
standardwebhooks@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@stablelib/base64': 1.0.1
|
||||||
|
fast-sha256: 1.3.0
|
||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
@@ -8774,6 +8869,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
|
stripe@22.0.1(@types/node@22.19.15):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.15
|
||||||
|
|
||||||
strnum@2.2.1: {}
|
strnum@2.2.1: {}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
@@ -8788,6 +8887,12 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
telnyx@6.41.0(ws@8.19.0):
|
||||||
|
dependencies:
|
||||||
|
standardwebhooks: 1.0.0
|
||||||
|
optionalDependencies:
|
||||||
|
ws: 8.19.0
|
||||||
|
|
||||||
temp-dir@2.0.0: {}
|
temp-dir@2.0.0: {}
|
||||||
|
|
||||||
tempy@0.6.0:
|
tempy@0.6.0:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 70 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 55 KiB |