diff --git a/.gitignore b/.gitignore index b826df6..14923ee 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ *.log .turbo/ coverage/ +minimax-output/ diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 286a969..e51436b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,6 +18,7 @@ import { groomingLogsRouter } from "./routes/groomingLogs.js"; import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; import { searchRouter } from "./routes/search.js"; +import { getPresignedGetUrl } from "./lib/s3.js"; import { calendarRouter } from "./routes/calendar.js"; import { setupRouter } from "./routes/setup.js"; import { getDb, businessSettings, eq, staff } from "@groombook/db"; @@ -55,11 +56,22 @@ app.route("/api/dev", devRouter); app.get("/api/branding", async (c) => { const db = getDb(); const [row] = await db.select().from(businessSettings).limit(1); - const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null }; + const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null }; + + let logoUrl: string | null = null; + if (settings.logoKey) { + try { + logoUrl = await getPresignedGetUrl(settings.logoKey); + } catch { + // If S3 URL generation fails, fall back to legacy base64 + } + } + return c.json({ businessName: settings.businessName, primaryColor: settings.primaryColor, accentColor: settings.accentColor, + logoUrl, logoBase64: settings.logoBase64, logoMimeType: settings.logoMimeType, }); diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index d11447b..ec1f8fa 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, getDb, businessSettings } from "@groombook/db"; +import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js"; import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono(); @@ -24,11 +25,6 @@ const updateSettingsSchema = z.object({ businessName: z.string().min(1).max(200).optional(), primaryColor: z.string().regex(hexColorRegex, "Must be a hex color like #4f8a6f").optional(), accentColor: z.string().regex(hexColorRegex, "Must be a hex color like #8b7355").optional(), - logoBase64: z.string().max(700_000).nullable().optional(), // ~512KB base64 - logoMimeType: z - .enum(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]) - .nullable() - .optional(), }); // PATCH /api/admin/settings — update business settings @@ -60,3 +56,123 @@ settingsRouter.patch( return c.json(updated); } ); + +// ─── Logo routes ────────────────────────────────────────────────────────────── + +const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]); +const MAX_LOGO_SIZE = 512 * 1024; // 512 KB + +const logoUploadUrlSchema = z.object({ + contentType: z.string().refine((v) => ALLOWED_LOGO_TYPES.has(v), { + message: "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp", + }), + fileSizeBytes: z.number().int().positive().max(MAX_LOGO_SIZE, { + message: "File must not exceed 512 KB", + }), +}); + +const logoConfirmSchema = z.object({ + key: z.string().min(1), +}); + +/** + * POST /api/admin/settings/logo/upload-url + * Returns a presigned S3 PUT URL and the object key for logo upload. + */ +settingsRouter.post( + "/logo/upload-url", + zValidator("json", logoUploadUrlSchema), + async (c) => { + const db = getDb(); + const { contentType, fileSizeBytes } = c.req.valid("json"); + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + const ext = contentType.split("/")[1] ?? "png"; + const key = `logos/${settingsId}/${Date.now()}.${ext}`; + const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes); + + return c.json({ uploadUrl, key }); + } +); + +/** + * POST /api/admin/settings/logo/confirm + * Called after the client has successfully uploaded to the presigned URL. + * Records the object key in the DB and clears legacy base64 fields. + */ +settingsRouter.post( + "/logo/confirm", + zValidator("json", logoConfirmSchema), + async (c) => { + const db = getDb(); + const { key } = c.req.valid("json"); + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + // Validate key prefix + if (!key.startsWith(`logos/${settingsId}/`)) { + return c.json({ error: "Invalid key" }, 400); + } + + // Delete previous S3 object if any + if (rows[0].logoKey) { + await deleteObject(rows[0].logoKey); + } + + const [updated] = await db + .update(businessSettings) + .set({ logoKey: key, logoBase64: null, logoMimeType: null, updatedAt: new Date() }) + .where(eq(businessSettings.id, settingsId)) + .returning(); + + if (!updated) { + return c.json({ error: "Settings not found" }, 404); + } + + return c.json({ ok: true, logoKey: updated.logoKey }); + } +); + +/** + * GET /api/admin/settings/logo + * Returns a presigned GET URL for the logo. + */ +settingsRouter.get("/logo", async (c) => { + const db = getDb(); + + const [row] = await db.select().from(businessSettings).limit(1); + if (!row) return c.json({ error: "Settings not found" }, 404); + if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); + + const url = await getPresignedGetUrl(row.logoKey); + return c.json({ url, logoKey: row.logoKey }); +}); + +/** + * DELETE /api/admin/settings/logo + * Removes the logo from S3 and clears the DB record. + */ +settingsRouter.delete("/logo", async (c) => { + const db = getDb(); + + const [row] = await db.select().from(businessSettings).limit(1); + if (!row) return c.json({ error: "Settings not found" }, 404); + if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); + + await deleteObject(row.logoKey); + await db + .update(businessSettings) + .set({ logoKey: null, updatedAt: new Date() }) + .where(eq(businessSettings.id, row.id)); + + return c.json({ ok: true }); +}); diff --git a/apps/e2e/tests/admin-reports.spec.ts b/apps/e2e/tests/admin-reports.spec.ts new file mode 100644 index 0000000..5fba3e0 --- /dev/null +++ b/apps/e2e/tests/admin-reports.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for admin reports page. + * Verifies that reports render with data when date range is set. + */ + +function getDateDaysAgo(days: number): string { + const d = new Date(); + d.setDate(d.getDate() - days); + return d.toISOString().slice(0, 10); +} + +const MOCK_SUMMARY = { + from: getDateDaysAgo(60), + to: new Date().toISOString().slice(0, 10), + revenue: { totalCents: 125000, paidInvoices: 15 }, + appointments: { total: 25, completed: 20, cancelled: 3, noShow: 2 }, + clients: { total: 42, new: 8 }, +}; + +const MOCK_REVENUE = { + byPeriod: [ + { period: "2026-03-01", totalCents: 45000, invoiceCount: 5 }, + { period: "2026-03-15", totalCents: 80000, invoiceCount: 10 }, + ], + byGroomer: [ + { staffId: "staff-1", staffName: "Alice Groomer", totalCents: 125000, invoiceCount: 15 }, + ], +}; + +test.describe("Admin Reports Data", () => { + test.beforeEach(async ({ page }) => { + // Login as staff + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("/admin"); + + // Mock all report endpoints + await page.route("**/api/reports/summary**", (route) => + route.fulfill({ json: MOCK_SUMMARY }) + ); + await page.route("**/api/reports/revenue**", (route) => + route.fulfill({ json: MOCK_REVENUE }) + ); + await page.route("**/api/reports/appointments**", (route) => + route.fulfill({ json: { byPeriod: [] } }) + ); + await page.route("**/api/reports/services**", (route) => + route.fulfill({ json: { rows: [] } }) + ); + await page.route("**/api/reports/clients**", (route) => + route.fulfill({ json: { newClients: [], activeInPeriodCount: 10, churnRisk: [], churnRiskTotal: 0 } }) + ); + }); + + test("reports page loads and displays KPI cards", async ({ page }) => { + await page.goto("/admin/reports"); + + // Wait for reports to load + await expect(page.locator("h1")).toContainText("Reports", { timeout: 10_000 }); + + // Should show KPI cards with data (use .first() to avoid strict mode violation) + await expect(page.locator("text=/Revenue/i").first()).toBeVisible(); + await expect(page.locator("text=/Appointments/i").first()).toBeVisible(); + await expect(page.locator("text=/New Clients/i").first()).toBeVisible(); + }); + + test("reports show non-zero data when data exists", async ({ page }) => { + await page.goto("/admin/reports"); + + // Wait for data to load + await page.waitForTimeout(2_000); + + // Revenue card should show non-zero value (check dollar amount or Revenue heading) + const revenueCard = page.locator("text=/\\$1,250|Revenue/i").first(); + await expect(revenueCard).toBeVisible(); + + // Appointments card should show non-zero + await expect(page.getByText("25", { exact: true }).first()).toBeVisible(); + }); + + test("reports date range inputs exist and are functional", async ({ page }) => { + await page.goto("/admin/reports"); + + // Wait for page to load + await expect(page.locator("h1")).toContainText("Reports", { timeout: 10_000 }); + + // Date inputs should exist + const fromInput = page.locator('input[type="date"]').first(); + const toInput = page.locator('input[type="date"]').nth(1); + + await expect(fromInput).toBeVisible(); + await expect(toInput).toBeVisible(); + + // Change date range - set to last 60 days + const sixtyDaysAgo = getDateDaysAgo(60); + await fromInput.fill(sixtyDaysAgo); + + // Click refresh + await page.getByRole("button", { name: /Refresh/i }).click(); + + // Wait for data to reload + await page.waitForTimeout(1_000); + + // Reports should still display + await expect(page.locator("h1")).toContainText("Reports"); + }); + + test("reports page renders charts/metrics sections", async ({ page }) => { + await page.goto("/admin/reports"); + + // Wait for reports to load + await page.waitForTimeout(2_000); + + // Should show section headers (use .first() to avoid strict mode violation) + await expect(page.locator("text=/Revenue by/i").first()).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/admin-services.spec.ts b/apps/e2e/tests/admin-services.spec.ts new file mode 100644 index 0000000..8490656 --- /dev/null +++ b/apps/e2e/tests/admin-services.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for services deduplication. + * Verifies that service names are unique in both admin services list + * and in the booking service picker. + */ + +const MOCK_SERVICES = [ + { id: "svc-1", name: "Full Groom", description: "Bath and haircut", basePriceCents: 7500, durationMinutes: 90, isActive: true }, + { id: "svc-2", name: "Bath Only", description: "Just the bath", basePriceCents: 3500, durationMinutes: 45, isActive: true }, + { id: "svc-3", name: "Nail Trim", description: "Just nails", basePriceCents: 1500, durationMinutes: 15, isActive: true }, +]; + +test.describe("Services Deduplication", () => { + test.beforeEach(async ({ page }) => { + // Mock services endpoint FIRST before navigation + // Also mock /api/book/services used by the booking wizard + await page.route("**/api/services**", (route) => + route.fulfill({ json: MOCK_SERVICES }) + ); + await page.route("**/api/book/services**", (route) => + route.fulfill({ json: MOCK_SERVICES }) + ); + + // Login as staff + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("/admin"); + }); + + test("admin services page shows no duplicate service names", async ({ page }) => { + await page.goto("/admin/services"); + + // Wait for services to load + await page.waitForTimeout(1_000); + + // Collect all service names from the table + const serviceNameCells = page.locator("table tbody tr td:first-child"); + const count = await serviceNameCells.count(); + + const names: string[] = []; + for (let i = 0; i < count; i++) { + const text = await serviceNameCells.nth(i).textContent(); + if (text) names.push(text.trim()); + } + + // Check for duplicates + const duplicates = names.filter((name, index) => names.indexOf(name) !== index); + + // Assert no duplicate names + expect(duplicates, `Found duplicate service names: ${duplicates.join(", ")}`).toHaveLength(0); + + // Verify all names are unique using Set comparison + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + }); + + test("admin services page renders all services", async ({ page }) => { + await page.goto("/admin/services"); + + // Wait for table to render + await expect(page.locator("table")).toBeVisible({ timeout: 10_000 }); + + // Should show the heading + await expect(page.locator("h1")).toContainText("Services"); + + // Should show all unique services + const rowCount = await page.locator("table tbody tr").count(); + expect(rowCount).toBeGreaterThan(0); + }); + + test("booking service picker shows no duplicates", async ({ page }) => { + await page.goto("/admin/book"); + + // Wait for services to load in the booking wizard + await page.waitForTimeout(1_000); + + // Collect service names from the picker + const serviceCards = page.locator("text=/Full Groom|Bath Only|Nail Trim/"); + const serviceNames: string[] = []; + + // Get all text content that looks like service names + const allText = await page.locator("body").textContent(); + if (allText) { + const matches = allText.match(/(?:Full Groom|Bath Only|Nail Trim)/g); + if (matches) { + serviceNames.push(...matches); + } + } + + // Check for duplicates in the booking picker + const duplicates = serviceNames.filter((name, index) => serviceNames.indexOf(name) !== index); + + expect(duplicates, `Found duplicate service names in booking picker: ${duplicates.join(", ")}`).toHaveLength(0); + }); + + test("booking wizard step 1 shows services", async ({ page }) => { + await page.goto("/admin/book"); + + // Should show at least one service + await expect(page.getByText("Full Groom")).toBeVisible({ timeout: 10_000 }); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/console-health.spec.ts b/apps/e2e/tests/console-health.spec.ts new file mode 100644 index 0000000..f3466f2 --- /dev/null +++ b/apps/e2e/tests/console-health.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for baseline console health. + * Verifies no 404s for critical assets and no JS exceptions on initial render. + */ + +test.describe("Console Health", () => { + test("admin page loads without 404s or JS errors", async ({ page }) => { + const consoleMessages: { type: string; text: string }[] = []; + const failedRequests: string[] = []; + + // Capture console messages + page.on("console", (msg) => { + consoleMessages.push({ type: msg.type(), text: msg.text() }); + }); + + // Capture failed requests + page.on("requestfailed", (request) => { + failedRequests.push(request.url()); + }); + + // Navigate to admin + await page.goto("/admin"); + + // Wait for initial render + await page.waitForLoadState("networkidle"); + + // Check no 404s for critical assets + const criticalAssetFailures = failedRequests.filter( + (url) => + url.includes("favicon") || + url.includes("manifest") || + url.includes(".js") || + url.includes(".css") || + url.includes(".png") || + url.includes(".svg") + ); + + expect( + criticalAssetFailures, + `Critical asset 404s found: ${criticalAssetFailures.join(", ")}` + ).toHaveLength(0); + + // Check no JS exceptions (filter out known non-critical errors) + const jsErrors = consoleMessages.filter( + (m) => + m.type === "error" && + !m.text.includes("favicon") && + !m.text.includes("502") && + !m.text.includes("Failed to load resource") + ); + + expect(jsErrors, `JS errors found: ${JSON.stringify(jsErrors)}`).toHaveLength(0); + }); + + test("portal page loads without 404s or JS errors", async ({ page }) => { + const consoleMessages: { type: string; text: string }[] = []; + const failedRequests: string[] = []; + + page.on("console", (msg) => { + consoleMessages.push({ type: msg.type(), text: msg.text() }); + }); + + page.on("requestfailed", (request) => { + failedRequests.push(request.url()); + }); + + // Login as client first + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("/"); + + // Wait for initial render + await page.waitForLoadState("networkidle"); + + // Check no 404s for critical assets + const criticalAssetFailures = failedRequests.filter( + (url) => + url.includes("favicon") || + url.includes("manifest") || + url.includes(".js") || + url.includes(".css") || + url.includes(".png") || + url.includes(".svg") + ); + + expect( + criticalAssetFailures, + `Critical asset 404s found: ${criticalAssetFailures.join(", ")}` + ).toHaveLength(0); + + // Check no JS exceptions + const jsErrors = consoleMessages.filter( + (m) => m.type === "error" && !m.text.includes("favicon") && !m.text.includes("502") && !m.text.includes("Failed to load resource") + ); + + expect(jsErrors, `JS errors found: ${JSON.stringify(jsErrors)}`).toHaveLength(0); + }); + + test("no uncaught exceptions on page load", async ({ page }) => { + const jsErrors: string[] = []; + + page.on("pageerror", (error) => { + jsErrors.push(error.message); + }); + + await page.goto("/admin"); + await page.waitForLoadState("domcontentloaded"); + + expect(jsErrors, `Uncaught exceptions: ${jsErrors.join(", ")}`).toHaveLength(0); + }); + + test("portal dashboard renders without uncaught exceptions", async ({ page }) => { + const jsErrors: string[] = []; + + page.on("pageerror", (error) => { + jsErrors.push(error.message); + }); + + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("/"); + await page.waitForLoadState("domcontentloaded"); + + expect(jsErrors, `Uncaught exceptions: ${jsErrors.join(", ")}`).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/fixtures.ts b/apps/e2e/tests/fixtures.ts index 22b510e..ea159d4 100644 --- a/apps/e2e/tests/fixtures.ts +++ b/apps/e2e/tests/fixtures.ts @@ -47,6 +47,20 @@ export const test = base.extend({ await page.route("**/api/setup/status", (route) => route.fulfill({ json: { needsSetup: false } }) ); + // Mock the portal dev-session endpoint for client portal login + await page.route("**/api/portal/dev-session", (route) => + route.fulfill({ + status: 201, + json: { + id: "dev-session-1", + staffId: "00000000-0000-0000-0000-000000000001", + clientId: route.request().postDataJSON().clientId, + reason: "dev-mode-client-portal", + status: "active", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }, + }) + ); // Seed localStorage as a fallback in case the mock is bypassed await page.addInitScript(() => { localStorage.setItem( diff --git a/apps/e2e/tests/portal-auth.spec.ts b/apps/e2e/tests/portal-auth.spec.ts new file mode 100644 index 0000000..5df7bca --- /dev/null +++ b/apps/e2e/tests/portal-auth.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for client portal authentication via dev login selector. + * Verifies the fix for the "Hi, Guest" bug where client name was not displayed. + */ + +test.describe("Client Portal Auth", () => { + test("portal shows client name after login via dev selector", async ({ page }) => { + // Navigate to login + await page.goto("/login"); + + // Select Carol Client (has 2 pets per fixtures.ts) + await page.getByText("Carol Client").click(); + + // Should navigate to portal home + await expect(page).toHaveURL("/"); + + // Heading should contain client name, NOT "Hi, Guest" or "Please sign in" + const greeting = page.locator("text=/Hi,\\s*Carol/"); + await expect(greeting).toBeVisible({ timeout: 10_000 }); + + // Should NOT show "Hi, Guest" + await expect(page.locator("text=/Hi,\\s*Guest/")).not.toBeVisible(); + + // Portal dashboard should render with actual content + await expect(page.locator("nav")).toBeVisible(); + // Dashboard section should be visible (Home nav item is active by default) + await expect(page.getByRole("button", { name: /Home/i })).toBeVisible(); + }); + + test("portal does not show Please sign in after client login", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("/"); + + // Should not show "Please sign in" message + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + }); + + test("different client gets correct name displayed", async ({ page }) => { + await page.goto("/login"); + + // Select Dave Client (has 1 pet per fixtures.ts) + await page.getByText("Dave Client").click(); + + await expect(page).toHaveURL("/"); + + // Should show Dave's name, not Carol's + const greeting = page.locator("text=/Hi,\\s*Dave/"); + await expect(greeting).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=/Hi,\\s*Carol/")).not.toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/portal-data.spec.ts b/apps/e2e/tests/portal-data.spec.ts new file mode 100644 index 0000000..5e9b2ed --- /dev/null +++ b/apps/e2e/tests/portal-data.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for portal data integrity. + * Verifies that portal sections render correctly without JS errors + * and without showing "Please sign in" messages. + */ + +const MOCK_PET = { + id: "pet-1", + name: "Buddy", + species: "dog", + breed: "Golden Retriever", + clientId: "client-1", +}; + +const MOCK_SESSION = { + id: "session-1", + staffId: "staff-1", + clientId: "client-1", + reason: "E2E test", + status: "active", + startedAt: new Date().toISOString(), + endedAt: null, + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + createdAt: new Date().toISOString(), +}; + +test.describe("Portal Data Integrity", () => { + test.beforeEach(async ({ page }) => { + // Login as Carol Client first + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("/"); + + // Mock portal/me for client data + await page.route("**/api/portal/me", (route) => + route.fulfill({ json: { id: "client-1", name: "Carol Client", email: "carol@example.com" } }) + ); + + // Mock portal session endpoint + await page.route("**/api/portal/dev-session", (route) => + route.fulfill({ json: MOCK_SESSION }) + ); + }); + + test("appointments section renders without Please sign in", async ({ page }) => { + // Navigate to appointments section + await page.getByRole("button", { name: /Appointments/i }).click(); + + // Should not show "Please sign in" message + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + + // Content area should be present + await expect(page.locator("main")).toBeVisible(); + }); + + test("pets section renders with content or empty state", async ({ page }) => { + // Mock pets endpoint + await page.route("**/api/pets**", (route) => + route.fulfill({ json: [MOCK_PET] }) + ); + + // Navigate to pets section + await page.getByRole("button", { name: /My Pets/i }).click(); + + // Should not show "Please sign in" message + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + + // Content should render - either pet card or empty state + await expect(page.locator("main")).toBeVisible(); + }); + + test("billing section renders without JS errors", async ({ page }) => { + // Mock billing endpoint + await page.route("**/api/billing**", (route) => + route.fulfill({ json: { invoices: [], balanceCents: 0 } }) + ); + + const consoleErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + + // Navigate to billing section + await page.getByRole("button", { name: /Billing/i }).click(); + + // Wait for content to load + await page.waitForTimeout(1_000); + + // Should not show "Please sign in" message + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + + // No JS errors should have occurred + const jsErrors = consoleErrors.filter(e => !e.includes("favicon") && !e.includes("404")); + expect(jsErrors).toHaveLength(0); + }); + + test("dashboard renders correctly after login", async ({ page }) => { + // Should already be on dashboard (/) after login + + // Should not show "Please sign in" + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + + // Should show the greeting with client name + await expect(page.locator("text=/Hi,\\s*Carol/")).toBeVisible(); + + // Navigation should be visible + await expect(page.locator("nav")).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/web/e2e/test-results/.last-run.json b/apps/web/e2e/test-results/.last-run.json new file mode 100644 index 0000000..a388776 --- /dev/null +++ b/apps/web/e2e/test-results/.last-run.json @@ -0,0 +1,8 @@ +{ + "status": "failed", + "failedTests": [ + "cc6a1cee8cd61301041b-bf77e4c199938445b05b", + "cc6a1cee8cd61301041b-2a304fecac661e282f98", + "cc6a1cee8cd61301041b-846717512ae61dac91f4" + ] +} \ No newline at end of file diff --git a/apps/web/public/demo-pets/dog-bichon-frise.png b/apps/web/public/demo-pets/dog-bichon-frise.png new file mode 100644 index 0000000..479aea4 Binary files /dev/null and b/apps/web/public/demo-pets/dog-bichon-frise.png differ diff --git a/apps/web/public/demo-pets/dog-black-lab.png b/apps/web/public/demo-pets/dog-black-lab.png new file mode 100644 index 0000000..c864c2f Binary files /dev/null and b/apps/web/public/demo-pets/dog-black-lab.png differ diff --git a/apps/web/public/demo-pets/dog-cocker-spaniel.png b/apps/web/public/demo-pets/dog-cocker-spaniel.png new file mode 100644 index 0000000..6e09894 Binary files /dev/null and b/apps/web/public/demo-pets/dog-cocker-spaniel.png differ diff --git a/apps/web/public/demo-pets/dog-dachshund.png b/apps/web/public/demo-pets/dog-dachshund.png new file mode 100644 index 0000000..0ee1a7d Binary files /dev/null and b/apps/web/public/demo-pets/dog-dachshund.png differ diff --git a/apps/web/public/demo-pets/dog-golden-after.png b/apps/web/public/demo-pets/dog-golden-after.png new file mode 100644 index 0000000..5864899 Binary files /dev/null and b/apps/web/public/demo-pets/dog-golden-after.png differ diff --git a/apps/web/public/demo-pets/dog-golden-before.png b/apps/web/public/demo-pets/dog-golden-before.png new file mode 100644 index 0000000..d1442f7 Binary files /dev/null and b/apps/web/public/demo-pets/dog-golden-before.png differ diff --git a/apps/web/public/demo-pets/dog-golden-retriever.png b/apps/web/public/demo-pets/dog-golden-retriever.png new file mode 100644 index 0000000..7c07cc9 Binary files /dev/null and b/apps/web/public/demo-pets/dog-golden-retriever.png differ diff --git a/apps/web/public/demo-pets/dog-labrador.png b/apps/web/public/demo-pets/dog-labrador.png new file mode 100644 index 0000000..451fcc7 Binary files /dev/null and b/apps/web/public/demo-pets/dog-labrador.png differ diff --git a/apps/web/public/demo-pets/dog-maltese.png b/apps/web/public/demo-pets/dog-maltese.png new file mode 100644 index 0000000..b8cd227 Binary files /dev/null and b/apps/web/public/demo-pets/dog-maltese.png differ diff --git a/apps/web/public/demo-pets/dog-mixed-breed.png b/apps/web/public/demo-pets/dog-mixed-breed.png new file mode 100644 index 0000000..8b280b7 Binary files /dev/null and b/apps/web/public/demo-pets/dog-mixed-breed.png differ diff --git a/apps/web/public/demo-pets/dog-pomeranian.png b/apps/web/public/demo-pets/dog-pomeranian.png new file mode 100644 index 0000000..93dd0a4 Binary files /dev/null and b/apps/web/public/demo-pets/dog-pomeranian.png differ diff --git a/apps/web/public/demo-pets/dog-poodle-groomed.png b/apps/web/public/demo-pets/dog-poodle-groomed.png new file mode 100644 index 0000000..8f8ce80 Binary files /dev/null and b/apps/web/public/demo-pets/dog-poodle-groomed.png differ diff --git a/apps/web/public/demo-pets/dog-poodle.png b/apps/web/public/demo-pets/dog-poodle.png new file mode 100644 index 0000000..ab69f7d Binary files /dev/null and b/apps/web/public/demo-pets/dog-poodle.png differ diff --git a/apps/web/public/demo-pets/dog-schnauzer.png b/apps/web/public/demo-pets/dog-schnauzer.png new file mode 100644 index 0000000..d1f0b23 Binary files /dev/null and b/apps/web/public/demo-pets/dog-schnauzer.png differ diff --git a/apps/web/public/demo-pets/dog-shih-tzu.png b/apps/web/public/demo-pets/dog-shih-tzu.png new file mode 100644 index 0000000..860572b Binary files /dev/null and b/apps/web/public/demo-pets/dog-shih-tzu.png differ diff --git a/apps/web/public/demo-pets/dog-terrier.png b/apps/web/public/demo-pets/dog-terrier.png new file mode 100644 index 0000000..c00f364 Binary files /dev/null and b/apps/web/public/demo-pets/dog-terrier.png differ diff --git a/apps/web/public/groombook-logo.png b/apps/web/public/groombook-logo.png new file mode 100644 index 0000000..ad6c338 Binary files /dev/null and b/apps/web/public/groombook-logo.png differ diff --git a/apps/web/src/BrandingContext.tsx b/apps/web/src/BrandingContext.tsx index 1420e02..a29d12e 100644 --- a/apps/web/src/BrandingContext.tsx +++ b/apps/web/src/BrandingContext.tsx @@ -4,6 +4,7 @@ export interface Branding { businessName: string; primaryColor: string; accentColor: string; + logoUrl: string | null; logoBase64: string | null; logoMimeType: string | null; } @@ -12,6 +13,7 @@ const DEFAULT_BRANDING: Branding = { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", + logoUrl: null, logoBase64: null, logoMimeType: null, }; diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 09ff522..265d3e1 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -5,8 +5,10 @@ interface SettingsForm { businessName: string; primaryColor: string; accentColor: string; - logoBase64: string | null; - logoMimeType: string | null; + logoKey: string | null; + logoUrl: string | null; + logoBase64: string | null; // legacy + logoMimeType: string | null; // legacy } export function SettingsPage() { @@ -15,6 +17,8 @@ export function SettingsPage() { businessName: "", primaryColor: "#4f8a6f", accentColor: "#8b7355", + logoKey: null, + logoUrl: null, logoBase64: null, logoMimeType: null, }); @@ -26,11 +30,25 @@ export function SettingsPage() { useEffect(() => { fetch("/api/admin/settings") .then((r) => r.json()) - .then((data) => { + .then(async (data) => { + let logoUrl: string | null = null; + if (data.logoKey) { + try { + const logoRes = await fetch("/api/admin/settings/logo"); + if (logoRes.ok) { + const logoData = await logoRes.json(); + logoUrl = logoData.url; + } + } catch { + // ignore + } + } setForm({ businessName: data.businessName ?? "GroomBook", primaryColor: data.primaryColor ?? "#4f8a6f", accentColor: data.accentColor ?? "#8b7355", + logoKey: data.logoKey ?? null, + logoUrl, logoBase64: data.logoBase64 ?? null, logoMimeType: data.logoMimeType ?? null, }); @@ -39,7 +57,7 @@ export function SettingsPage() { .catch(() => setLoaded(true)); }, []); - const handleLogoChange = (e: React.ChangeEvent) => { + const handleLogoChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -54,15 +72,53 @@ export function SettingsPage() { return; } - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - // Strip the data:...;base64, prefix - const base64 = result.split(",")[1] ?? null; - setForm((f) => ({ ...f, logoBase64: base64, logoMimeType: file.type as SettingsForm["logoMimeType"] })); - setMessage(null); - }; - reader.readAsDataURL(file); + try { + // Step 1: Get presigned upload URL + const uploadRes = await fetch("/api/admin/settings/logo/upload-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }), + }); + if (!uploadRes.ok) { + const err = await uploadRes.json().catch(() => null); + throw new Error(err?.error ?? "Failed to get upload URL"); + } + const { uploadUrl, key } = await uploadRes.json(); + + // Step 2: PUT the file directly to S3 + const putRes = await fetch(uploadUrl, { + method: "PUT", + headers: { "Content-Type": file.type }, + body: file, + }); + if (!putRes.ok) { + throw new Error("Failed to upload logo to storage"); + } + + // Step 3: Confirm the upload + const confirmRes = await fetch("/api/admin/settings/logo/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }); + if (!confirmRes.ok) { + const err = await confirmRes.json().catch(() => null); + throw new Error(err?.error ?? "Failed to confirm logo upload"); + } + + // Step 4: Fetch the presigned GET URL for display + const logoRes = await fetch("/api/admin/settings/logo"); + if (logoRes.ok) { + const logoData = await logoRes.json(); + setForm((f) => ({ ...f, logoKey: key, logoUrl: logoData.url, logoBase64: null, logoMimeType: null })); + } else { + setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null })); + } + setMessage({ type: "success", text: "Logo uploaded." }); + refresh(); + } catch (err: unknown) { + setMessage({ type: "error", text: err instanceof Error ? err.message : "Logo upload failed" }); + } }; const handleSave = async () => { @@ -89,9 +145,7 @@ export function SettingsPage() { if (!loaded) return

Loading settings...

; - const logoSrc = form.logoBase64 && form.logoMimeType - ? `data:${form.logoMimeType};base64,${form.logoBase64}` - : null; + const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null); return (
@@ -164,7 +218,20 @@ export function SettingsPage() { /> {logoSrc && (