import { createSpace, el } from "./shared/ui.js"; import { callTrpcMutation } from "./shared/api-client.js"; import { ENDPOINTS, siteUrl } from "./shared/config.js"; const TOOL_KEY = "logo"; const TRPC_PATH = ENDPOINTS[TOOL_KEY].trpcPath; // "/api/trpc/logo.generate" const FULL_TOOL_URL = siteUrl(ENDPOINTS[TOOL_KEY].fullToolPath); // mesmer.tools/free-tools/ai-logo-maker // Description length bounds enforced server-side; we mirror them client-side. const DESC = { min: 3, max: 500 }; const BRAND_MAX = 100; // Style + colorMood are small, stable enums — defined inline (not fetched). const STYLES = [ { value: "minimal", label: "Minimal", desc: "Clean, geometric, lots of whitespace" }, { value: "bold", label: "Bold", desc: "Heavy, high-contrast, commanding" }, { value: "playful", label: "Playful", desc: "Friendly, rounded, colorful" }, { value: "corporate", label: "Corporate", desc: "Professional, refined, trustworthy" }, { value: "vintage", label: "Vintage", desc: "Nostalgic, badge/emblem, artisanal" }, ]; const COLOR_MOODS = [ { value: "auto", label: "Auto — let the AI choose" }, { value: "light", label: "Light — bright, airy palette" }, { value: "dark", label: "Dark — deep, dramatic tones" }, { value: "vibrant", label: "Vibrant — bold, saturated colors" }, { value: "muted", label: "Muted — soft, understated hues" }, { value: "monochrome", label: "Monochrome — single colour / black & white" }, ]; const space = createSpace({ toolKey: TOOL_KEY, emoji: "🎨", title: "Free AI Logo Maker", subtitle: "Describe your brand and get an AI-generated logo in seconds. A logo generator from text — pick a style, choose a colour mood, download. Free, no signup.", intro: "Turn a one-line brief into AI logo design right in your browser. Great for startups, side projects, and quick brand mockups.", }); /* --- Form ----------------------------------------------------------------- */ const descInput = el("textarea", { className: "ms-textarea", id: "description", name: "description", required: "", maxlength: String(DESC.max), placeholder: "e.g. A cozy neighbourhood coffee shop that roasts its own beans — warm, welcoming, a little artisanal.", }); const descCount = el("div", { className: "ms-char-count", text: `0 / ${DESC.max}` }); const descHint = el("span", { className: "ms-hint", text: `Describe your brand — what it does and the feel you want (${DESC.min}–${DESC.max} characters).` }); const brandInput = el("input", { className: "ms-input", type: "text", id: "brandName", name: "brandName", maxlength: String(BRAND_MAX), placeholder: "Bean There", autocomplete: "off", }); // Style chips (single-select; defaults to the first option so the form is always valid). let selectedStyle = STYLES[0].value; const styleChips = STYLES.map((s) => el("button", { className: "ms-chip" + (s.value === selectedStyle ? " is-selected" : ""), type: "button", role: "radio", "aria-checked": s.value === selectedStyle ? "true" : "false", dataset: { value: s.value }, onClick: () => selectStyle(s.value), }, el("span", { className: "ms-chip-name", text: s.label }), el("span", { className: "ms-chip-desc", text: s.desc }), ), ); function selectStyle(value) { selectedStyle = value; for (const chip of styleChips) { const on = chip.dataset.value === value; chip.classList.toggle("is-selected", on); chip.setAttribute("aria-checked", on ? "true" : "false"); } } const colorSelect = el("select", { className: "ms-select", id: "colorMood", name: "colorMood" }, ...COLOR_MOODS.map((m) => el("option", { value: m.value }, m.label)), ); const siteInput = el("input", { className: "ms-input", type: "url", id: "siteUrl", name: "siteUrl", placeholder: "https://beanthere.coffee", autocomplete: "off", autocapitalize: "off", spellcheck: "false", }); const submitBtn = el("button", { className: "ms-btn", type: "submit" }, "Generate logo"); space.form.append( el("div", { className: "ms-field" }, el("label", { for: "description", text: "Describe your brand" }), descInput, el("div", { className: "ms-row", style: { justifyContent: "space-between", alignItems: "center" } }, descHint, descCount, ), ), el("div", { className: "ms-field" }, el("label", { for: "brandName", text: "Brand name" }), brandInput, el("span", { className: "ms-hint", text: "Optional — the text that appears in the logo wordmark." }), ), el("div", { className: "ms-field" }, el("label", { text: "Logo style" }), el("div", { className: "ms-chips", role: "radiogroup", "aria-label": "Logo style" }, ...styleChips), ), el("div", { className: "ms-row" }, el("div", { className: "ms-field" }, el("label", { for: "colorMood", text: "Colour mood" }), colorSelect, ), el("div", { className: "ms-field" }, el("label", { for: "siteUrl", text: "Website" }), siteInput, el("span", { className: "ms-hint", text: "Optional — helps the AI match your brand." }), ), ), submitBtn, el("p", { className: "ms-api-note" }, "Free demo: ", String(ENDPOINTS[TOOL_KEY].freeLimitPerHour), " logos/hour per visitor. Need more, or vector exports? ", el("a", { href: FULL_TOOL_URL, target: "_blank", rel: "noopener", text: "Use the full AI Logo Maker on mesmer.tools →" }), ), ); // Live character count + validity hint for the description. descInput.addEventListener("input", updateDescCount); function updateDescCount() { const len = descInput.value.trim().length; descCount.textContent = `${descInput.value.length} / ${DESC.max}`; descCount.style.color = descInput.value.length > DESC.max ? "#fca5a5" : ""; if (len > 0 && len < DESC.min) { descHint.textContent = `A little more detail, please — at least ${DESC.min} characters.`; descHint.style.color = "#fca5a5"; } else { descHint.textContent = `Describe your brand — what it does and the feel you want (${DESC.min}–${DESC.max} characters).`; descHint.style.color = ""; } } /* --- Submit --------------------------------------------------------------- */ space.form.addEventListener("submit", async (e) => { e.preventDefault(); const description = descInput.value.trim(); if (description.length < DESC.min || description.length > DESC.max) { updateDescCount(); descInput.focus(); return; } // Only send brandName / siteUrl when non-empty (both optional server-side). const input = { description, style: selectedStyle, colorMood: colorSelect.value }; const brandName = brandInput.value.trim(); if (brandName) input.brandName = brandName; const url = siteInput.value.trim(); if (url) input.siteUrl = url; space.clearOutput(); space.setLoading(true, "Designing your logo… this takes ~30 seconds"); submitBtn.disabled = true; try { // Live wire format confirmed: POST {json: input} → {result:{data:{json:{url, enhancedPrompt}}}}. // callTrpcMutation already unwraps that envelope, returning {url, enhancedPrompt}. const data = await callTrpcMutation(TRPC_PATH, input, { timeout: 90000 }); space.clearStatus(); renderResult(data, brandName); } catch (err) { space.clearOutput(); space.showError(err); // RateLimitError (429) auto-renders the cross-sell banner. } finally { submitBtn.disabled = false; } }); /* --- Result --------------------------------------------------------------- */ function slugify(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "logo"; } function renderResult(data, brandName) { // Success shape: { url, enhancedPrompt } const imgUrl = data && data.url; const enhancedPrompt = (data && data.enhancedPrompt) || ""; if (!imgUrl) { space.showError("The logo could not be generated. Please try again."); return; } const filename = `${slugify(brandName || "logo")}-logo.webp`; const altText = brandName ? `AI-generated logo for ${brandName}` : "AI-generated logo"; space.output.append( el("div", { className: "ms-result-frame" }, el("img", { src: imgUrl, alt: altText, loading: "lazy" }), ), brandName ? el("p", { className: "ms-brand-name-result", text: brandName }) : null, el("div", { className: "ms-result-actions" }, el("a", { className: "ms-btn-ghost", href: imgUrl, target: "_blank", rel: "noopener", text: "Open ↗" }), el("a", { className: "ms-btn-ghost", href: imgUrl, download: filename, text: "Download" }), ), enhancedPrompt ? el("details", { className: "ms-prompt-details" }, el("summary", { text: "Show the AI prompt used" }), el("p", { className: "ms-meta", text: enhancedPrompt }), ) : null, ); }