@morphika/andami 0.6.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/robots.ts CHANGED
@@ -1,54 +1,73 @@
1
- import type { MetadataRoute } from "next";
2
- import { getSiteConfig } from "../lib/config";
3
-
4
- const cfg = getSiteConfig();
5
-
6
- /**
7
- * robots.txt — Controls crawler access and rate.
8
- *
9
- * Crawl-delay (seconds between requests) is honoured by Bing, Yandex, Baidu
10
- * and most well-behaved bots. Googlebot ignores it but respects the rate
11
- * configured in Search Console. The 10-second delay drastically reduces
12
- * serverless CPU usage from bot traffic on Hobby-tier hosting.
13
- *
14
- * Aggressive AI scrapers (GPTBot, CCBot, etc.) are blocked entirely.
15
- */
16
- export default function robots(): MetadataRoute.Robots {
17
- return {
18
- rules: [
19
- // Block known AI scrapers / aggressive bots
20
- {
21
- userAgent: "GPTBot",
22
- disallow: ["/"],
23
- },
24
- {
25
- userAgent: "CCBot",
26
- disallow: ["/"],
27
- },
28
- {
29
- userAgent: "anthropic-ai",
30
- disallow: ["/"],
31
- },
32
- {
33
- userAgent: "ClaudeBot",
34
- disallow: ["/"],
35
- },
36
- {
37
- userAgent: "Bytespider",
38
- disallow: ["/"],
39
- },
40
- {
41
- userAgent: "PetalBot",
42
- disallow: ["/"],
43
- },
44
- // Default: allow with crawl delay
45
- {
46
- userAgent: "*",
47
- allow: "/",
48
- disallow: ["/admin/", "/studio/", "/api/admin/", "/api/"],
49
- crawlDelay: 10,
50
- },
51
- ],
52
- sitemap: `${cfg.domain}/sitemap.xml`,
53
- };
54
- }
1
+ import type { MetadataRoute } from "next";
2
+ import { getSiteConfig } from "../lib/config";
3
+
4
+ const cfg = getSiteConfig();
5
+
6
+ /**
7
+ * robots.txt — Controls crawler access and rate.
8
+ *
9
+ * Two-tier AI bot strategy:
10
+ *
11
+ * 1. TRAINING bots (block) crawl pages to train LLMs on the content.
12
+ * Blocking them protects IP from being absorbed into model weights.
13
+ * Examples: GPTBot, CCBot, Google-Extended, ClaudeBot, anthropic-ai.
14
+ *
15
+ * 2. CITATION / ON-DEMAND bots (allow) — fetch URLs in real time when a
16
+ * user asks an AI assistant a question. Blocking them means we lose
17
+ * citation opportunities in ChatGPT, Perplexity, Claude responses.
18
+ * Examples: ChatGPT-User, Perplexity-User, Claude-User, OAI-SearchBot.
19
+ *
20
+ * Distinction matters because we want LLMs to *cite* us, not *learn from*
21
+ * us. The bots have different User-Agent strings; granular control via
22
+ * per-UA rules.
23
+ *
24
+ * Crawl-delay (seconds between requests) is honoured by Bing, Yandex,
25
+ * Baidu and most well-behaved bots. Googlebot ignores it but respects the
26
+ * rate configured in Search Console.
27
+ *
28
+ * Last reviewed: 2026-05-15.
29
+ */
30
+ export default function robots(): MetadataRoute.Robots {
31
+ return {
32
+ rules: [
33
+ // ─────────────────────────────────────────────
34
+ // TRAINING BOTS — explicitly blocked
35
+ // ─────────────────────────────────────────────
36
+ { userAgent: "GPTBot", disallow: ["/"] }, // OpenAI training crawler
37
+ { userAgent: "CCBot", disallow: ["/"] }, // Common Crawl (feeds most LLMs)
38
+ { userAgent: "Google-Extended", disallow: ["/"] }, // Google's AI training (separate from Googlebot)
39
+ { userAgent: "anthropic-ai", disallow: ["/"] }, // Older Anthropic training UA
40
+ { userAgent: "ClaudeBot", disallow: ["/"] }, // Anthropic Claude training
41
+ { userAgent: "FacebookBot", disallow: ["/"] }, // Meta/LLaMA training
42
+ { userAgent: "Applebot-Extended", disallow: ["/"] }, // Apple Intelligence training (separate from Applebot which indexes for Siri/Spotlight — that one is allowed implicitly)
43
+ { userAgent: "Bytespider", disallow: ["/"] }, // ByteDance / TikTok training crawler
44
+ { userAgent: "PetalBot", disallow: ["/"] }, // Huawei / Petal Search aggressive crawler
45
+
46
+ // ─────────────────────────────────────────────
47
+ // CITATION / ON-DEMAND BOTS — explicitly allowed
48
+ // (they're allowed by default anyway, but we list
49
+ // them to make the policy explicit and to override
50
+ // any future changes to the catch-all rule.)
51
+ // ─────────────────────────────────────────────
52
+ { userAgent: "ChatGPT-User", allow: "/" }, // ChatGPT user-triggered fetches
53
+ { userAgent: "OAI-SearchBot", allow: "/" }, // OpenAI search index
54
+ { userAgent: "PerplexityBot", allow: "/" }, // Perplexity index
55
+ { userAgent: "Perplexity-User", allow: "/" }, // Perplexity user-triggered fetches
56
+ { userAgent: "Claude-User", allow: "/" }, // Claude user-triggered fetches
57
+ { userAgent: "Claude-Web", allow: "/" }, // Claude web search
58
+ { userAgent: "Google-CloudVertexBot", allow: "/" }, // Vertex AI on-demand fetch
59
+ { userAgent: "YouBot", allow: "/" }, // You.com AI search
60
+
61
+ // ─────────────────────────────────────────────
62
+ // DEFAULT — Googlebot, Bingbot, and everyone else
63
+ // ─────────────────────────────────────────────
64
+ {
65
+ userAgent: "*",
66
+ allow: "/",
67
+ disallow: ["/admin/", "/studio/", "/api/admin/", "/api/"],
68
+ crawlDelay: 10,
69
+ },
70
+ ],
71
+ sitemap: `${cfg.domain}/sitemap.xml`,
72
+ };
73
+ }
package/app/sitemap.ts CHANGED
@@ -2,28 +2,43 @@ import type { MetadataRoute } from "next";
2
2
  import { client } from "../lib/sanity/client";
3
3
  import { allPagesForSitemapQuery, allProjectsForSitemapQuery } from "../lib/sanity/queries";
4
4
  import { getSiteConfig } from "../lib/config";
5
+ import { assetUrl } from "../lib/assets";
6
+ import { toAbsoluteUrl } from "../lib/seo/site-settings";
5
7
 
6
8
  const cfg = getSiteConfig();
7
9
 
8
- interface SitemapEntry {
10
+ interface PageSitemapEntry {
9
11
  slug: string;
10
12
  updatedAt: string;
11
13
  }
12
14
 
15
+ interface ProjectSitemapEntry extends PageSitemapEntry {
16
+ thumbnail_path?: string;
17
+ title?: string;
18
+ description?: string;
19
+ published_at?: string;
20
+ }
21
+
13
22
  /**
14
23
  * sitemap.xml — Dynamic generation from Sanity content.
15
24
  *
16
25
  * Each entry's <lastmod> uses Sanity's _updatedAt field (not the build time),
17
26
  * which is the correct SEO signal: Google deprioritizes sitemaps with all
18
- * identical timestamps. Pages/projects with metadata.noindex == true are
19
- * excluded at the GROQ level (see lib/sanity/queries.ts).
27
+ * identical timestamps.
28
+ *
29
+ * Project entries include their thumbnail in the `images` array — Next.js
30
+ * emits this as <image:image> nodes per the Google Image Sitemap protocol,
31
+ * allowing Google Images to index project thumbnails alongside the page URL.
32
+ *
33
+ * Pages/projects with metadata.noindex == true are excluded at the GROQ
34
+ * level (see lib/sanity/queries.ts).
20
35
  */
21
36
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
22
37
  const baseUrl = cfg.domain;
23
38
 
24
39
  const [pages, projects] = await Promise.all([
25
- client.fetch<SitemapEntry[]>(allPagesForSitemapQuery).catch(() => [] as SitemapEntry[]),
26
- client.fetch<SitemapEntry[]>(allProjectsForSitemapQuery).catch(() => [] as SitemapEntry[]),
40
+ client.fetch<PageSitemapEntry[]>(allPagesForSitemapQuery).catch(() => [] as PageSitemapEntry[]),
41
+ client.fetch<ProjectSitemapEntry[]>(allProjectsForSitemapQuery).catch(() => [] as ProjectSitemapEntry[]),
27
42
  ]);
28
43
 
29
44
  // Homepage — lastModified derived from the most recent page or project update
@@ -53,12 +68,16 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
53
68
  });
54
69
  }
55
70
 
56
- for (const { slug, updatedAt } of projects) {
71
+ for (const { slug, updatedAt, thumbnail_path } of projects) {
72
+ const thumbnailUrl = thumbnail_path
73
+ ? toAbsoluteUrl(assetUrl(thumbnail_path), baseUrl)
74
+ : undefined;
57
75
  routes.push({
58
76
  url: `${baseUrl}/work/${slug}`,
59
77
  lastModified: updatedAt ? new Date(updatedAt) : new Date(),
60
78
  changeFrequency: "monthly",
61
79
  priority: 0.7,
80
+ ...(thumbnailUrl && { images: [thumbnailUrl] }),
62
81
  });
63
82
  }
64
83
 
@@ -8,12 +8,28 @@ import SERPPreview from "./SERPPreview";
8
8
  const TITLE_LIMIT = 60;
9
9
  const DESCRIPTION_LIMIT = 160;
10
10
 
11
- interface MetadataData {
11
+ export interface SocialLinkInput {
12
+ _key?: string;
13
+ label?: string;
14
+ url: string;
15
+ }
16
+
17
+ export interface MetadataData {
12
18
  default_title: string;
13
19
  default_description: string;
14
20
  default_og_image: string;
15
21
  favicon_path: string;
16
22
  analytics_id: string;
23
+ org_logo: string;
24
+ default_author: string;
25
+ social_links: SocialLinkInput[];
26
+ founding_year: number | undefined;
27
+ address_locality: string;
28
+ address_country: string;
29
+ contact_email: string;
30
+ keywords: string[];
31
+ verification_google: string;
32
+ verification_bing: string;
17
33
  }
18
34
 
19
35
  interface MetadataEditorProps {
@@ -22,18 +38,38 @@ interface MetadataEditorProps {
22
38
  saving: boolean;
23
39
  }
24
40
 
41
+ const INPUT_CLASS =
42
+ "w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 focus:border-[#3580f9] focus:outline-none";
43
+
44
+ const SECTION_TITLE_CLASS =
45
+ "text-sm font-semibold text-neutral-800 pt-6 border-t border-neutral-200";
46
+
25
47
  export default function MetadataEditor({
26
48
  initialData,
27
49
  onSave,
28
50
  saving,
29
51
  }: MetadataEditorProps) {
30
52
  const [title, setTitle] = useState(initialData.default_title);
31
- const [description, setDescription] = useState(
32
- initialData.default_description
33
- );
53
+ const [description, setDescription] = useState(initialData.default_description);
34
54
  const [ogImage, setOgImage] = useState(initialData.default_og_image);
35
55
  const [favicon, setFavicon] = useState(initialData.favicon_path);
36
56
  const [analyticsId, setAnalyticsId] = useState(initialData.analytics_id);
57
+ const [orgLogo, setOrgLogo] = useState(initialData.org_logo);
58
+ const [defaultAuthor, setDefaultAuthor] = useState(initialData.default_author);
59
+ const [socialLinks, setSocialLinks] = useState<SocialLinkInput[]>(
60
+ initialData.social_links || []
61
+ );
62
+ const [foundingYear, setFoundingYear] = useState<number | undefined>(
63
+ initialData.founding_year
64
+ );
65
+ const [addressLocality, setAddressLocality] = useState(initialData.address_locality);
66
+ const [addressCountry, setAddressCountry] = useState(initialData.address_country);
67
+ const [contactEmail, setContactEmail] = useState(initialData.contact_email);
68
+ const [keywordsText, setKeywordsText] = useState(
69
+ (initialData.keywords || []).join(", ")
70
+ );
71
+ const [verGoogle, setVerGoogle] = useState(initialData.verification_google);
72
+ const [verBing, setVerBing] = useState(initialData.verification_bing);
37
73
 
38
74
  useEffect(() => {
39
75
  setTitle(initialData.default_title);
@@ -41,18 +77,57 @@ export default function MetadataEditor({
41
77
  setOgImage(initialData.default_og_image);
42
78
  setFavicon(initialData.favicon_path);
43
79
  setAnalyticsId(initialData.analytics_id);
80
+ setOrgLogo(initialData.org_logo);
81
+ setDefaultAuthor(initialData.default_author);
82
+ setSocialLinks(initialData.social_links || []);
83
+ setFoundingYear(initialData.founding_year);
84
+ setAddressLocality(initialData.address_locality);
85
+ setAddressCountry(initialData.address_country);
86
+ setContactEmail(initialData.contact_email);
87
+ setKeywordsText((initialData.keywords || []).join(", "));
88
+ setVerGoogle(initialData.verification_google);
89
+ setVerBing(initialData.verification_bing);
44
90
  }, [initialData]);
45
91
 
46
92
  const handleSave = () => {
93
+ // Parse keywords from comma-separated input, trim + dedupe
94
+ const keywords = Array.from(
95
+ new Set(
96
+ keywordsText
97
+ .split(",")
98
+ .map((k) => k.trim())
99
+ .filter(Boolean)
100
+ )
101
+ );
47
102
  onSave({
48
103
  default_title: title,
49
104
  default_description: description,
50
105
  default_og_image: ogImage,
51
106
  favicon_path: favicon,
52
107
  analytics_id: analyticsId,
108
+ org_logo: orgLogo,
109
+ default_author: defaultAuthor,
110
+ social_links: socialLinks.filter((l) => l.url?.trim()),
111
+ founding_year: foundingYear,
112
+ address_locality: addressLocality,
113
+ address_country: addressCountry,
114
+ contact_email: contactEmail,
115
+ keywords,
116
+ verification_google: verGoogle,
117
+ verification_bing: verBing,
53
118
  });
54
119
  };
55
120
 
121
+ const updateSocialLink = (i: number, patch: Partial<SocialLinkInput>) => {
122
+ setSocialLinks((arr) =>
123
+ arr.map((l, idx) => (idx === i ? { ...l, ...patch } : l))
124
+ );
125
+ };
126
+ const removeSocialLink = (i: number) =>
127
+ setSocialLinks((arr) => arr.filter((_, idx) => idx !== i));
128
+ const addSocialLink = () =>
129
+ setSocialLinks((arr) => [...arr, { url: "", label: "" }]);
130
+
56
131
  const titleOver = title.length > TITLE_LIMIT;
57
132
  const descOver = description.length > DESCRIPTION_LIMIT;
58
133
 
@@ -69,7 +144,10 @@ export default function MetadataEditor({
69
144
  Affects search results and social sharing.
70
145
  </p>
71
146
 
72
- {/* Title */}
147
+ {/* ============================================================
148
+ DEFAULTS
149
+ ============================================================ */}
150
+
73
151
  <div className="space-y-1">
74
152
  <label className="text-xs font-medium text-neutral-500">
75
153
  Default Page Title
@@ -79,22 +157,18 @@ export default function MetadataEditor({
79
157
  value={title}
80
158
  onChange={(e) => setTitle(e.target.value)}
81
159
  placeholder={getSiteConfig().defaults.metaTitle}
82
- className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 focus:border-[#3580f9] focus:outline-none"
160
+ className={INPUT_CLASS}
83
161
  />
84
162
  <div className="flex justify-between">
85
163
  <p className="text-xs text-neutral-400">
86
- Shown in browser tabs and search results when no page-specific title
87
- is set.
164
+ Shown in browser tabs and search results when no page-specific title is set.
88
165
  </p>
89
- <span
90
- className={`text-xs ${titleOver ? "text-red-400" : "text-neutral-400"}`}
91
- >
166
+ <span className={`text-xs ${titleOver ? "text-red-400" : "text-neutral-400"}`}>
92
167
  {title.length}/{TITLE_LIMIT}
93
168
  </span>
94
169
  </div>
95
170
  </div>
96
171
 
97
- {/* Description */}
98
172
  <div className="space-y-1">
99
173
  <label className="text-xs font-medium text-neutral-500">
100
174
  Default Description
@@ -104,24 +178,20 @@ export default function MetadataEditor({
104
178
  onChange={(e) => setDescription(e.target.value)}
105
179
  placeholder="Motion graphics studio based in Barcelona..."
106
180
  rows={3}
107
- className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 focus:border-[#3580f9] focus:outline-none resize-none"
181
+ className={`${INPUT_CLASS} resize-none`}
108
182
  />
109
183
  <div className="flex justify-between">
110
184
  <p className="text-xs text-neutral-400">
111
185
  Used in search results and social media previews.
112
186
  </p>
113
- <span
114
- className={`text-xs ${descOver ? "text-red-400" : "text-neutral-400"}`}
115
- >
187
+ <span className={`text-xs ${descOver ? "text-red-400" : "text-neutral-400"}`}>
116
188
  {description.length}/{DESCRIPTION_LIMIT}
117
189
  </span>
118
190
  </div>
119
191
  </div>
120
192
 
121
- {/* SERP Preview */}
122
193
  <SERPPreview title={title} description={description} />
123
194
 
124
- {/* OG Image */}
125
195
  <div className="space-y-1">
126
196
  <label className="text-xs font-medium text-neutral-500">
127
197
  Default OG Image
@@ -133,16 +203,12 @@ export default function MetadataEditor({
133
203
  filterType="image"
134
204
  />
135
205
  <p className="text-xs text-neutral-400">
136
- Default image used when sharing on social media (Facebook, Twitter,
137
- LinkedIn). Recommended size: 1200×630px. Pages can override per-page.
206
+ Default image used when sharing on social media (Facebook, Twitter, LinkedIn). Recommended size: 1200×630px. Pages can override per-page.
138
207
  </p>
139
208
  </div>
140
209
 
141
- {/* Favicon */}
142
210
  <div className="space-y-1">
143
- <label className="text-xs font-medium text-neutral-500">
144
- Favicon
145
- </label>
211
+ <label className="text-xs font-medium text-neutral-500">Favicon</label>
146
212
  <AssetPathInput
147
213
  value={favicon}
148
214
  onChange={setFavicon}
@@ -150,12 +216,10 @@ export default function MetadataEditor({
150
216
  filterType="image"
151
217
  />
152
218
  <p className="text-xs text-neutral-400">
153
- Icon shown in browser tabs and bookmarks. Square images recommended
154
- (16×16 .ico or 32×32+ .png/.svg).
219
+ Icon shown in browser tabs and bookmarks. Square images recommended (16×16 .ico or 32×32+ .png/.svg).
155
220
  </p>
156
221
  </div>
157
222
 
158
- {/* Analytics ID */}
159
223
  <div className="space-y-1">
160
224
  <label className="text-xs font-medium text-neutral-500">
161
225
  Analytics ID (optional)
@@ -165,16 +229,251 @@ export default function MetadataEditor({
165
229
  value={analyticsId}
166
230
  onChange={(e) => setAnalyticsId(e.target.value)}
167
231
  placeholder="G-XXXXXXXXXX or plausible domain"
168
- className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 focus:border-[#3580f9] focus:outline-none"
232
+ className={INPUT_CLASS}
233
+ />
234
+ <p className="text-xs text-neutral-400">
235
+ Google Analytics measurement ID or Plausible domain. Leave empty to disable analytics.
236
+ </p>
237
+ </div>
238
+
239
+ {/* ============================================================
240
+ SOCIAL & BRANDING
241
+ ============================================================ */}
242
+
243
+ <h3 className={SECTION_TITLE_CLASS}>Social & Branding</h3>
244
+ <p className="text-xs text-neutral-400">
245
+ Used by structured data (schema.org) so Google can show your brand in
246
+ Knowledge Panels and link your social profiles.
247
+ </p>
248
+
249
+ <div className="space-y-1">
250
+ <label className="text-xs font-medium text-neutral-500">
251
+ Organization Logo
252
+ </label>
253
+ <AssetPathInput
254
+ value={orgLogo}
255
+ onChange={setOrgLogo}
256
+ placeholder="meta/logo.png"
257
+ filterType="image"
258
+ />
259
+ <p className="text-xs text-neutral-400">
260
+ Logo for Google&apos;s Knowledge Panel and rich results. Square (min 112×112, ideal 500×500).
261
+ </p>
262
+ </div>
263
+
264
+ <div className="space-y-1">
265
+ <label className="text-xs font-medium text-neutral-500">
266
+ Default Author
267
+ </label>
268
+ <input
269
+ type="text"
270
+ value={defaultAuthor}
271
+ onChange={(e) => setDefaultAuthor(e.target.value)}
272
+ placeholder={getSiteConfig().name}
273
+ className={INPUT_CLASS}
274
+ />
275
+ <p className="text-xs text-neutral-400">
276
+ Author name used on project pages when not overridden. Typically your studio name.
277
+ </p>
278
+ </div>
279
+
280
+ <div className="space-y-2">
281
+ <label className="text-xs font-medium text-neutral-500">
282
+ Social Profiles
283
+ </label>
284
+ <p className="text-xs text-neutral-400">
285
+ Profile URLs (Instagram, LinkedIn, Behance, Vimeo, etc.). Emitted as schema.org sameAs[] — helps Google link your organization to its social presence.
286
+ </p>
287
+ <div className="space-y-2">
288
+ {socialLinks.map((link, i) => (
289
+ <div key={link._key || i} className="flex gap-1.5 items-start">
290
+ <input
291
+ type="text"
292
+ value={link.label || ""}
293
+ onChange={(e) => updateSocialLink(i, { label: e.target.value })}
294
+ placeholder="Label (e.g. Instagram)"
295
+ className={`${INPUT_CLASS} flex-[1]`}
296
+ />
297
+ <input
298
+ type="url"
299
+ value={link.url || ""}
300
+ onChange={(e) => updateSocialLink(i, { url: e.target.value })}
301
+ placeholder="https://instagram.com/yourhandle"
302
+ className={`${INPUT_CLASS} flex-[2]`}
303
+ />
304
+ <button
305
+ type="button"
306
+ onClick={() => removeSocialLink(i)}
307
+ className="shrink-0 rounded-lg px-2.5 py-2.5 text-xs text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-colors"
308
+ aria-label="Remove social link"
309
+ >
310
+
311
+ </button>
312
+ </div>
313
+ ))}
314
+ </div>
315
+ <button
316
+ type="button"
317
+ onClick={addSocialLink}
318
+ className="text-xs text-[#3580f9] hover:underline"
319
+ >
320
+ + Add Profile
321
+ </button>
322
+ </div>
323
+
324
+ {/* ============================================================
325
+ ORGANIZATION INFO (citation-friendly facts for LLMs + JSON-LD)
326
+ ============================================================ */}
327
+
328
+ <h3 className={SECTION_TITLE_CLASS}>Organization Info</h3>
329
+ <p className="text-xs text-neutral-400">
330
+ Citation-friendly facts emitted as schema.org structured data and in
331
+ the <code>/llms.txt</code> summary. AI assistants quote these directly
332
+ when answering questions about your organization.
333
+ </p>
334
+
335
+ <div className="grid grid-cols-2 gap-4">
336
+ <div className="space-y-1">
337
+ <label className="text-xs font-medium text-neutral-500">
338
+ Founded (year)
339
+ </label>
340
+ <input
341
+ type="number"
342
+ value={foundingYear ?? ""}
343
+ onChange={(e) => {
344
+ const v = e.target.value;
345
+ setFoundingYear(v === "" ? undefined : Number(v));
346
+ }}
347
+ min={1800}
348
+ max={new Date().getFullYear()}
349
+ placeholder="2018"
350
+ className={INPUT_CLASS}
351
+ />
352
+ </div>
353
+ <div className="space-y-1">
354
+ <label className="text-xs font-medium text-neutral-500">
355
+ Contact Email
356
+ </label>
357
+ <input
358
+ type="email"
359
+ value={contactEmail}
360
+ onChange={(e) => setContactEmail(e.target.value)}
361
+ placeholder="hello@example.com"
362
+ className={INPUT_CLASS}
363
+ />
364
+ </div>
365
+ </div>
366
+
367
+ <div className="grid grid-cols-2 gap-4">
368
+ <div className="space-y-1">
369
+ <label className="text-xs font-medium text-neutral-500">
370
+ City
371
+ </label>
372
+ <input
373
+ type="text"
374
+ value={addressLocality}
375
+ onChange={(e) => setAddressLocality(e.target.value)}
376
+ placeholder="Barcelona"
377
+ className={INPUT_CLASS}
378
+ />
379
+ </div>
380
+ <div className="space-y-1">
381
+ <label className="text-xs font-medium text-neutral-500">
382
+ Country
383
+ </label>
384
+ <input
385
+ type="text"
386
+ value={addressCountry}
387
+ onChange={(e) => setAddressCountry(e.target.value)}
388
+ placeholder="Spain"
389
+ className={INPUT_CLASS}
390
+ />
391
+ </div>
392
+ </div>
393
+
394
+ <div className="space-y-1">
395
+ <label className="text-xs font-medium text-neutral-500">
396
+ Areas of Expertise
397
+ </label>
398
+ <input
399
+ type="text"
400
+ value={keywordsText}
401
+ onChange={(e) => setKeywordsText(e.target.value)}
402
+ placeholder="CGI, Motion Graphics, 3D Animation"
403
+ className={INPUT_CLASS}
169
404
  />
170
405
  <p className="text-xs text-neutral-400">
171
- Google Analytics measurement ID or Plausible domain. Leave empty to
172
- disable analytics.
406
+ Comma-separated list of services / specialties. Emitted as
407
+ schema.org knowsAbout — helps LLMs cite you for related queries.
173
408
  </p>
174
409
  </div>
175
410
 
176
- {/* Save */}
177
- <div className="flex justify-end">
411
+ {/* ============================================================
412
+ SEARCH ENGINE VERIFICATION
413
+ ============================================================ */}
414
+
415
+ <h3 className={SECTION_TITLE_CLASS}>Search Engine Verification</h3>
416
+ <p className="text-xs text-neutral-400">
417
+ Connect your site to free webmaster tools to see how it performs in
418
+ search. Paste the verification code from each tool below (just the
419
+ code, not the full <code>&lt;meta&gt;</code> tag).
420
+ </p>
421
+
422
+ <div className="space-y-1">
423
+ <label className="text-xs font-medium text-neutral-500">
424
+ Google Search Console
425
+ </label>
426
+ <input
427
+ type="text"
428
+ value={verGoogle}
429
+ onChange={(e) => setVerGoogle(e.target.value)}
430
+ placeholder="abc123-verification-code-from-google"
431
+ className={INPUT_CLASS}
432
+ />
433
+ <p className="text-xs text-neutral-400">
434
+ From{" "}
435
+ <a
436
+ href="https://search.google.com/search-console"
437
+ target="_blank"
438
+ rel="noopener noreferrer"
439
+ className="text-[#3580f9] hover:underline"
440
+ >
441
+ Search Console
442
+ </a>{" "}
443
+ → Settings → Ownership verification → HTML tag method.
444
+ </p>
445
+ </div>
446
+
447
+ <div className="space-y-1">
448
+ <label className="text-xs font-medium text-neutral-500">
449
+ Bing Webmaster
450
+ </label>
451
+ <input
452
+ type="text"
453
+ value={verBing}
454
+ onChange={(e) => setVerBing(e.target.value)}
455
+ placeholder="abc123-verification-code-from-bing"
456
+ className={INPUT_CLASS}
457
+ />
458
+ <p className="text-xs text-neutral-400">
459
+ From{" "}
460
+ <a
461
+ href="https://www.bing.com/webmasters"
462
+ target="_blank"
463
+ rel="noopener noreferrer"
464
+ className="text-[#3580f9] hover:underline"
465
+ >
466
+ Bing Webmaster Tools
467
+ </a>{" "}
468
+ → Site verification → Meta tag method.
469
+ </p>
470
+ </div>
471
+
472
+ {/* ============================================================
473
+ SAVE
474
+ ============================================================ */}
475
+
476
+ <div className="flex justify-end pt-4">
178
477
  <button
179
478
  onClick={handleSave}
180
479
  disabled={saving}