Spaces:
Running
Running
| 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, | |
| ); | |
| } | |