@morphika/andami 0.5.11 → 0.8.1
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/(site)/[slug]/page.tsx +2 -0
- package/app/(site)/layout.tsx +4 -15
- package/app/(site)/work/[slug]/page.tsx +4 -0
- package/app/admin/settings/page.tsx +136 -132
- package/app/api/admin/settings/route.ts +69 -0
- package/app/llms.txt/route.ts +142 -0
- package/app/robots.ts +73 -54
- package/app/sitemap.ts +85 -48
- package/components/admin/MetadataEditor.tsx +487 -173
- package/components/admin/SERPPreview.tsx +85 -0
- package/components/builder/settings-panel/PageSettings.tsx +79 -23
- package/components/seo/JsonLd.tsx +50 -0
- package/components/seo/ProjectJsonLd.tsx +44 -0
- package/components/seo/SiteSeoHead.tsx +66 -0
- package/lib/builder/serializer/serializers.ts +1 -0
- package/lib/sanity/queries.ts +35 -0
- package/lib/sanity/types.ts +30 -0
- package/lib/seo/jsonld.ts +174 -0
- package/lib/seo/site-settings.ts +37 -0
- package/lib/version.ts +1 -1
- package/package.json +2 -1
- package/sanity/schemas/page.ts +7 -0
- package/sanity/schemas/siteSettings.ts +102 -0
- package/site/llms-txt.ts +10 -0
|
@@ -1,173 +1,487 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect } from "react";
|
|
4
|
-
import { getSiteConfig } from "../../lib/config";
|
|
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
|
-
className=
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
</
|
|
170
|
-
</div>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { getSiteConfig } from "../../lib/config";
|
|
5
|
+
import { AssetPathInput } from "../builder/editors/shared";
|
|
6
|
+
import SERPPreview from "./SERPPreview";
|
|
7
|
+
|
|
8
|
+
const TITLE_LIMIT = 60;
|
|
9
|
+
const DESCRIPTION_LIMIT = 160;
|
|
10
|
+
|
|
11
|
+
export interface SocialLinkInput {
|
|
12
|
+
_key?: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
url: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MetadataData {
|
|
18
|
+
default_title: string;
|
|
19
|
+
default_description: string;
|
|
20
|
+
default_og_image: string;
|
|
21
|
+
favicon_path: string;
|
|
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;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface MetadataEditorProps {
|
|
36
|
+
initialData: MetadataData;
|
|
37
|
+
onSave: (data: MetadataData) => Promise<void>;
|
|
38
|
+
saving: boolean;
|
|
39
|
+
}
|
|
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
|
+
|
|
47
|
+
export default function MetadataEditor({
|
|
48
|
+
initialData,
|
|
49
|
+
onSave,
|
|
50
|
+
saving,
|
|
51
|
+
}: MetadataEditorProps) {
|
|
52
|
+
const [title, setTitle] = useState(initialData.default_title);
|
|
53
|
+
const [description, setDescription] = useState(initialData.default_description);
|
|
54
|
+
const [ogImage, setOgImage] = useState(initialData.default_og_image);
|
|
55
|
+
const [favicon, setFavicon] = useState(initialData.favicon_path);
|
|
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);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
setTitle(initialData.default_title);
|
|
76
|
+
setDescription(initialData.default_description);
|
|
77
|
+
setOgImage(initialData.default_og_image);
|
|
78
|
+
setFavicon(initialData.favicon_path);
|
|
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);
|
|
90
|
+
}, [initialData]);
|
|
91
|
+
|
|
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
|
+
);
|
|
102
|
+
onSave({
|
|
103
|
+
default_title: title,
|
|
104
|
+
default_description: description,
|
|
105
|
+
default_og_image: ogImage,
|
|
106
|
+
favicon_path: favicon,
|
|
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,
|
|
118
|
+
});
|
|
119
|
+
};
|
|
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
|
+
|
|
131
|
+
const titleOver = title.length > TITLE_LIMIT;
|
|
132
|
+
const descOver = description.length > DESCRIPTION_LIMIT;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<section className="space-y-4">
|
|
136
|
+
<div className="flex items-center justify-between border-b border-neutral-200 pb-2">
|
|
137
|
+
<h2 className="text-base font-semibold text-neutral-800">
|
|
138
|
+
Site Metadata
|
|
139
|
+
</h2>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<p className="text-xs text-neutral-400">
|
|
143
|
+
Default SEO metadata used when pages don't specify their own.
|
|
144
|
+
Affects search results and social sharing.
|
|
145
|
+
</p>
|
|
146
|
+
|
|
147
|
+
{/* ============================================================
|
|
148
|
+
DEFAULTS
|
|
149
|
+
============================================================ */}
|
|
150
|
+
|
|
151
|
+
<div className="space-y-1">
|
|
152
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
153
|
+
Default Page Title
|
|
154
|
+
</label>
|
|
155
|
+
<input
|
|
156
|
+
type="text"
|
|
157
|
+
value={title}
|
|
158
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
159
|
+
placeholder={getSiteConfig().defaults.metaTitle}
|
|
160
|
+
className={INPUT_CLASS}
|
|
161
|
+
/>
|
|
162
|
+
<div className="flex justify-between">
|
|
163
|
+
<p className="text-xs text-neutral-400">
|
|
164
|
+
Shown in browser tabs and search results when no page-specific title is set.
|
|
165
|
+
</p>
|
|
166
|
+
<span className={`text-xs ${titleOver ? "text-red-400" : "text-neutral-400"}`}>
|
|
167
|
+
{title.length}/{TITLE_LIMIT}
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div className="space-y-1">
|
|
173
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
174
|
+
Default Description
|
|
175
|
+
</label>
|
|
176
|
+
<textarea
|
|
177
|
+
value={description}
|
|
178
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
179
|
+
placeholder="Motion graphics studio based in Barcelona..."
|
|
180
|
+
rows={3}
|
|
181
|
+
className={`${INPUT_CLASS} resize-none`}
|
|
182
|
+
/>
|
|
183
|
+
<div className="flex justify-between">
|
|
184
|
+
<p className="text-xs text-neutral-400">
|
|
185
|
+
Used in search results and social media previews.
|
|
186
|
+
</p>
|
|
187
|
+
<span className={`text-xs ${descOver ? "text-red-400" : "text-neutral-400"}`}>
|
|
188
|
+
{description.length}/{DESCRIPTION_LIMIT}
|
|
189
|
+
</span>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<SERPPreview title={title} description={description} />
|
|
194
|
+
|
|
195
|
+
<div className="space-y-1">
|
|
196
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
197
|
+
Default OG Image
|
|
198
|
+
</label>
|
|
199
|
+
<AssetPathInput
|
|
200
|
+
value={ogImage}
|
|
201
|
+
onChange={setOgImage}
|
|
202
|
+
placeholder="meta/og-image.jpg"
|
|
203
|
+
filterType="image"
|
|
204
|
+
/>
|
|
205
|
+
<p className="text-xs text-neutral-400">
|
|
206
|
+
Default image used when sharing on social media (Facebook, Twitter, LinkedIn). Recommended size: 1200×630px. Pages can override per-page.
|
|
207
|
+
</p>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div className="space-y-1">
|
|
211
|
+
<label className="text-xs font-medium text-neutral-500">Favicon</label>
|
|
212
|
+
<AssetPathInput
|
|
213
|
+
value={favicon}
|
|
214
|
+
onChange={setFavicon}
|
|
215
|
+
placeholder="meta/favicon.ico"
|
|
216
|
+
filterType="image"
|
|
217
|
+
/>
|
|
218
|
+
<p className="text-xs text-neutral-400">
|
|
219
|
+
Icon shown in browser tabs and bookmarks. Square images recommended (16×16 .ico or 32×32+ .png/.svg).
|
|
220
|
+
</p>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div className="space-y-1">
|
|
224
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
225
|
+
Analytics ID (optional)
|
|
226
|
+
</label>
|
|
227
|
+
<input
|
|
228
|
+
type="text"
|
|
229
|
+
value={analyticsId}
|
|
230
|
+
onChange={(e) => setAnalyticsId(e.target.value)}
|
|
231
|
+
placeholder="G-XXXXXXXXXX or plausible domain"
|
|
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'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}
|
|
404
|
+
/>
|
|
405
|
+
<p className="text-xs text-neutral-400">
|
|
406
|
+
Comma-separated list of services / specialties. Emitted as
|
|
407
|
+
schema.org knowsAbout — helps LLMs cite you for related queries.
|
|
408
|
+
</p>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
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><meta></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">
|
|
477
|
+
<button
|
|
478
|
+
onClick={handleSave}
|
|
479
|
+
disabled={saving}
|
|
480
|
+
className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors disabled:opacity-50"
|
|
481
|
+
>
|
|
482
|
+
{saving ? "Saving..." : "Save Metadata"}
|
|
483
|
+
</button>
|
|
484
|
+
</div>
|
|
485
|
+
</section>
|
|
486
|
+
);
|
|
487
|
+
}
|