/** * Badge layout types and helpers — shared between preview and API payload. */ export type BadgeFieldId = "name" | "title" | "org" | "badge_type" | "qr" | "photo"; export type BadgeTextAlign = "left" | "center" | "right"; export type BadgeLayoutKind = "field" | "custom_text" | "custom_image"; export type BadgeBackgroundImageLayout = { x: number; y: number; w: number; h: number; }; /** Preview selection id for the optional background image layer. */ export const BADGE_BACKGROUND_IMAGE_ID = "__badge_bg__"; const BG_IMAGE_MIN_SIZE = 10; const BG_IMAGE_MAX_SIZE = 500; const BG_IMAGE_MIN_POS = -400; const BG_IMAGE_MAX_POS = 400; export function defaultBackgroundImageLayout(): BadgeBackgroundImageLayout { return { x: 0, y: 0, w: 100, h: 100 }; } export function parseBackgroundImageLayout(raw: unknown): BadgeBackgroundImageLayout | null { if (!raw || typeof raw !== "object") return null; const o = raw as Record; if ( typeof o.x !== "number" || typeof o.y !== "number" || typeof o.w !== "number" || typeof o.h !== "number" ) { return null; } return applyBackgroundImageLayoutPatch({ x: o.x, y: o.y, w: o.w, h: o.h }); } /** Allow bleed outside the badge — only soft sanity limits for save/drag. */ export function applyBackgroundImageLayoutPatch( layout: BadgeBackgroundImageLayout, patch: Partial = {}, ): BadgeBackgroundImageLayout { const w = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.w ?? layout.w)); const h = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.h ?? layout.h)); const x = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.x ?? layout.x)); const y = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.y ?? layout.y)); return { x, y, w, h }; } /** @deprecated Use applyBackgroundImageLayoutPatch */ export function clampBackgroundImageLayout( layout: BadgeBackgroundImageLayout, patch: Partial = {}, ): BadgeBackgroundImageLayout { return applyBackgroundImageLayoutPatch(layout, patch); } export type BadgeLayoutElement = { id: string; kind: BadgeLayoutKind; field?: BadgeFieldId; text?: string; imageUrl?: string; /** Uploaded image width ÷ height */ imageAspectRatio?: number; x: number; y: number; w?: number; h?: number; fontSizePct?: number; fontFamily?: string; color?: string; fontWeight?: "normal" | "bold"; /** Default true — span full badge width with centered text */ fullWidthCenter?: boolean; /** Default true — reduce font size when text overflows */ shrinkToFit?: boolean; textAlign?: BadgeTextAlign; }; export const DEFAULT_TEXT_LAYOUT = { fullWidthCenter: true, shrinkToFit: true, textAlign: "center" as BadgeTextAlign, x: 0, w: 100, }; /** Treat undefined as true for backward-compatible defaults */ export function usesFullWidthCenter(el: BadgeLayoutElement): boolean { return el.fullWidthCenter !== false; } export function usesShrinkToFit(el: BadgeLayoutElement): boolean { return el.shrinkToFit !== false; } export function textAlignFor(el: BadgeLayoutElement): BadgeTextAlign { if (usesFullWidthCenter(el)) return el.textAlign ?? "center"; return el.textAlign ?? "left"; } export function isResizableElement(el: BadgeLayoutElement): boolean { return el.field === "qr" || el.field === "photo" || el.kind === "custom_image"; } export function isSquareResizable(el: BadgeLayoutElement): boolean { return el.field === "qr"; } export function elementSizePct(el: BadgeLayoutElement): number { return el.w ?? (el.field === "qr" ? 28 : el.kind === "custom_image" ? 25 : 18); } /** Square elements sized by width % of badge — height as % of badge height */ export function squareElementHeightPct(widthPct: number, badgeAspectRatio: number): number { const ar = badgeAspectRatio > 0 ? badgeAspectRatio : 1; return widthPct * ar; } export function clampElementSize(el: BadgeLayoutElement, w: number): number { return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, w)); } export function clampHeightPct(h: number): number { return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, h)); } /** Height % of badge for a given width % preserving uploaded image aspect */ export function heightPctForImageWidth( w: number, imageAspectRatio: number, badgeAspectRatio: number, ): number { const ar = imageAspectRatio > 0 ? imageAspectRatio : 1; return clampHeightPct((w * badgeAspectRatio) / ar); } export function loadImageAspectRatio(url: string): Promise { return new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 1); img.onerror = () => resolve(1); img.src = url; }); } export function readFileImageAspectRatio(file: File): Promise { const blob = URL.createObjectURL(file); return loadImageAspectRatio(blob).finally(() => URL.revokeObjectURL(blob)); } /** Apply new width/height and re-clamp position so element stays on badge */ export function applyElementSize( el: BadgeLayoutElement, w: number, badgeAspectRatio: number, ): Partial { const size = clampElementSize(el, w); const patch: Partial = { w: size }; if (isSquareResizable(el) || el.field === "photo") { patch.h = squareElementHeightPct(size, badgeAspectRatio); } else if (el.kind === "custom_image") { const ar = el.imageAspectRatio ?? 1; patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio); } const merged = { ...el, ...patch }; return { ...patch, ...clampElementPosition(merged, merged.x, merged.y, badgeAspectRatio) }; } /** Size in % used for drag clamping (w=0 → no horizontal clamp) */ export function elementBoundsPct(el: BadgeLayoutElement, badgeAspectRatio = 1): { w: number; h: number } { if (el.field === "qr" || el.field === "photo") { const s = elementSizePct(el); return { w: s, h: squareElementHeightPct(s, badgeAspectRatio) }; } if (el.kind === "custom_image") { return { w: el.w ?? 25, h: el.h ?? 25 }; } if (isTextElement(el)) { if (usesFullWidthCenter(el)) { return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) }; } return { w: el.w ?? 72, h: Math.max(4, (el.fontSizePct ?? 5) * 2.5) }; } return { w: 0, h: 0 }; } export function clampElementPosition( el: BadgeLayoutElement, x: number, y: number, badgeAspectRatio = 1, ): { x: number; y: number } { const bounds = elementBoundsPct(el, badgeAspectRatio); const maxX = bounds.w > 0 ? Math.max(0, 100 - bounds.w) : 100; const maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100; let cx = Math.min(maxX, Math.max(0, x)); const cy = Math.min(maxY, Math.max(0, y)); if (isTextElement(el) && usesFullWidthCenter(el)) cx = 0; return { x: cx, y: cy }; } export const BADGE_FONT_FAMILIES = [ { id: "Inter", label: "Inter", css: "var(--font-inter), Inter, sans-serif" }, { id: "Noto Sans", label: "Noto Sans", css: "var(--font-noto-sans), 'Noto Sans', sans-serif" }, { id: "Arial", label: "Arial", css: "Arial, sans-serif" }, { id: "Georgia", label: "Georgia", css: "Georgia, serif" }, { id: "Times New Roman", label: "Times New Roman", css: "'Times New Roman', Times, serif" }, ] as const; export const DEFAULT_TEXT_COLOR = "#0f172a"; export const MAX_CUSTOM_TEXT_FIELDS = 5; export const MAX_CUSTOM_IMAGE_FIELDS = 5; export const RESIZABLE_SIZE_MIN = 8; export const RESIZABLE_SIZE_MAX = 65; const PRESETS_MM: Record = { a6: { w: 105, h: 148 }, a7: { w: 74, h: 105 }, "4x3in": { w: 101.6, h: 76.2 }, "4x6in": { w: 101.6, h: 152.4 }, }; export function getBadgeAspectRatio( sizePreset: "a6" | "a7" | "4x3in" | "4x6in" | "custom", orientation: "portrait" | "landscape", customW?: string, customH?: string, ): number { let w: number; let h: number; if (sizePreset === "custom") { w = parseFloat(customW || "105") || 105; h = parseFloat(customH || "148") || 148; } else { const p = PRESETS_MM[sizePreset] || PRESETS_MM.a6; w = p.w; h = p.h; } if (orientation === "landscape") [w, h] = [h, w]; return w / h; } export function contrastTextColor(hex: string): string { const h = (hex || "#000000").replace("#", ""); const r = parseInt(h.slice(0, 2), 16) || 0; const g = parseInt(h.slice(2, 4), 16) || 0; const b = parseInt(h.slice(4, 6), 16) || 0; const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return lum > 0.55 ? "#0f172a" : "#ffffff"; } function textDefaults(bgColor: string, bgMode: "color" | "image") { const color = bgMode === "color" ? contrastTextColor(bgColor) : DEFAULT_TEXT_COLOR; return { color, fontFamily: "Inter", fontWeight: "normal" as const, ...DEFAULT_TEXT_LAYOUT, }; } function defaultElementForField(fieldId: BadgeFieldId, bgColor: string, bgMode: "color" | "image"): BadgeLayoutElement { const base = { kind: "field" as const, field: fieldId, id: fieldId, ...textDefaults(bgColor, bgMode) }; switch (fieldId) { case "name": return { ...base, y: 8, fontSizePct: 7, fontWeight: "bold" }; case "title": return { ...base, y: 22, fontSizePct: 4.5 }; case "org": return { ...base, y: 32, fontSizePct: 4 }; case "badge_type": return { ...base, y: 44, fontSizePct: 3.5 }; case "photo": return { kind: "field", field: fieldId, id: fieldId, x: 74, y: 6, w: 18, h: 18 }; case "qr": return { kind: "field", field: fieldId, id: fieldId, x: 36, y: 68, w: 28, h: 28 }; default: return { ...base, y: 8, fontSizePct: 5 }; } } export function defaultLayoutForFields( fieldIds: string[], bgColor: string, bgMode: "color" | "image", ): BadgeLayoutElement[] { return fieldIds .filter((id): id is BadgeFieldId => ["name", "title", "org", "badge_type", "qr", "photo"].includes(id)) .map((id) => defaultElementForField(id, bgColor, bgMode)); } export function defaultCustomTextElement( id: string, text: string, bgColor: string, bgMode: "color" | "image", index: number, ): BadgeLayoutElement { return { id, kind: "custom_text", text, y: 55 + index * 8, fontSizePct: 4, fontWeight: "normal", ...textDefaults(bgColor, bgMode), }; } export function newCustomTextId(): string { const hex = Math.random().toString(16).slice(2, 10); return `custom_${hex}`; } export function newCustomImageId(): string { const hex = Math.random().toString(16).slice(2, 10); return `cimg_${hex}`; } export function buildCustomImageElement( id: string, imageUrl: string, index: number, badgeAspectRatio: number, imageAspectRatio: number, ): BadgeLayoutElement { const w = 25; const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio); return { id, kind: "custom_image", imageUrl, imageAspectRatio, x: 10 + index * 4, y: 50 + index * 6, w, h, }; } export function imageUrlForElement( el: BadgeLayoutElement, customImages: { id: string; imageUrl: string }[], ): string { return (el.imageUrl ?? customImages.find((c) => c.id === el.id)?.imageUrl ?? "").trim(); } export function fontCss(family?: string): string { const f = BADGE_FONT_FAMILIES.find((x) => x.id === family); return f?.css ?? BADGE_FONT_FAMILIES[0].css; } export type BadgePreviewSample = { name: string; title: string; org: string; badgeType: string; }; export function samplePreviewData(badgeTypeLabel: string): BadgePreviewSample { return { name: "Jane Doe", title: "Keynote Speaker", org: "Acme Inc.", badgeType: badgeTypeLabel.trim() || "Attendee", }; } const PREVIEW_AVATAR_COLORS = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#0891b2", "#be185d", "#0d9488", "#b45309"]; export function previewInitialsFromName(name: string): string { const parts = name.split(/\s+/).filter(Boolean); if (parts.length >= 2) return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase(); return (parts[0]?.charAt(0) || "J").toUpperCase(); } export function previewAvatarColor(seed: string): string { let n = 0; for (let i = 0; i < seed.length; i++) n += seed.charCodeAt(i); return PREVIEW_AVATAR_COLORS[n % PREVIEW_AVATAR_COLORS.length]; } export function previewTextForElement( el: BadgeLayoutElement, sample: BadgePreviewSample, ): string { if (el.kind === "custom_text") return el.text?.trim() || "Your text"; switch (el.field) { case "name": return sample.name; case "title": return sample.title; case "org": return sample.org; case "badge_type": return sample.badgeType; default: return ""; } } export function isTextElement(el: BadgeLayoutElement): boolean { if (el.kind === "custom_text") return true; return el.field === "name" || el.field === "title" || el.field === "org" || el.field === "badge_type"; } export function syncLayoutWithFields( prev: BadgeLayoutElement[], enabledFields: string[], customTexts: { id: string; text: string }[], customImages: { id: string; imageUrl: string }[], bgColor: string, bgMode: "color" | "image", badgeAspectRatio: number, ): BadgeLayoutElement[] { const fieldSet = new Set(enabledFields); const customTextMap = new Map(customTexts.map((c) => [c.id, c.text])); const customImageMap = new Map(customImages.map((c) => [c.id, c.imageUrl])); const keptFields = prev.filter((el) => el.kind === "field" && el.field && fieldSet.has(el.field)); const keptCustomText = prev.filter((el) => el.kind === "custom_text" && customTextMap.has(el.id)); const keptCustomImage = prev.filter((el) => el.kind === "custom_image" && customImageMap.has(el.id)); const existingFieldIds = new Set(keptFields.map((e) => e.field)); const addedFields = enabledFields .filter((id): id is BadgeFieldId => ["name", "title", "org", "badge_type", "qr", "photo"].includes(id)) .filter((id) => !existingFieldIds.has(id)) .map((id) => defaultElementForField(id, bgColor, bgMode)); const existingTextIds = new Set(keptCustomText.map((e) => e.id)); const addedCustomText = customTexts .filter((c) => !existingTextIds.has(c.id)) .map((c, i) => defaultCustomTextElement(c.id, c.text, bgColor, bgMode, i)); const existingImageIds = new Set(keptCustomImage.map((e) => e.id)); const addedCustomImage = customImages .filter((c) => !existingImageIds.has(c.id)) .map((c, i) => buildCustomImageElement(c.id, c.imageUrl, i, badgeAspectRatio, 1)); const mergedCustomText = [ ...keptCustomText.map((el) => ({ ...el, text: customTextMap.get(el.id) ?? el.text })), ...addedCustomText, ]; const mergedCustomImage = [ ...keptCustomImage.map((el) => { const imageUrl = customImageMap.get(el.id) ?? el.imageUrl; const ar = el.imageAspectRatio ?? 1; const w = el.w ?? 25; return { ...el, imageUrl, h: heightPctForImageWidth(w, ar, badgeAspectRatio), }; }), ...addedCustomImage, ]; return [...keptFields, ...addedFields, ...mergedCustomText, ...mergedCustomImage]; }