feat(demo): expand demo pet images and seed data with diverse breed showcase
Generated 16 diverse pet images for demo site using MiniMax image generation: - Multiple dog breeds (Golden Retriever, Poodle, Labrador, Shih Tzu, Cocker Spaniel, Schnauzer, Maltese, Dachshund, Pomeranian) - Professional grooming styles and poses - Studio lighting for quality showcase Updated seed.ts to create 9 demo pets with image references: - Expands from single demo pet to diverse pet portfolio - Images deployed to apps/web/public/demo-pets/ - Each pet has breed-accurate styling and professional grooming This completes GRO-395 demo assets expansion using allocated MiniMax credits. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+13
-1
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const settingsRouter = new Hono();
|
||||
|
||||
@@ -23,11 +24,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
|
||||
@@ -58,3 +54,119 @@ 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();
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user