File size: 8,799 Bytes
0bc2c6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
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,
  );
}