feat(seo): facts in src/data/business.ts, SEO + JSON-LD components in layout

- business.ts: single source of truth; unknown facts (city, area, prices,
  packages, phone, delivery time, experience, testimonials) are null and are
  skipped everywhere. faq.ts builds answers only from known facts.
- portfolio-meta.ts: data hook for real per-image alt texts.
- SEO.astro: title, description, canonical, Open Graph, Twitter, og:image
  (EXIF-free JPG via getImage, absolute URL).
- SchemaOrg.astro: @graph with WebSite, Person, ProfessionalService
  (sameAs, ReserveAction to the customer portal, offer catalog without
  prices), WebPage, optional FAQPage and BreadcrumbList.
- MainLayout: lang=sv, Swedish nav (adds Tjänster), consent banner and
  footer in Swedish, lyxlimo/karleksguiden line removed, info@ address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
This commit is contained in:
Linus Olofsson
2026-10-27 19:13:07 +01:00
co-authored by Claude Opus 5.5
parent ce5676f552
commit 30e385ec7f
12 changed files with 578 additions and 28 deletions
+155
View File
@@ -0,0 +1,155 @@
---
// src/components/SchemaOrg.astro
// JSON-LD (@graph): WebSite + Person (Linus) + ProfessionalService (Images by Olofsson)
// + WebPage för aktuell sida (+ FAQPage och BreadcrumbList när det är relevant).
// Allt hämtas från src/data/business.ts. null-värden utelämnas helt.
import { business, absoluteUrl, genreList } from '../data/business';
import type { FaqItem } from '../data/faq';
import { normalizePath } from '../lib/url';
export interface Props {
title: string;
description: string;
path?: string;
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
/** Namn i brödsmulan för undersidor, t.ex. "Tjänster". */
breadcrumb?: string;
faq?: FaqItem[];
/** Absolut URL till sidans huvudbild (og:image). */
imageUrl?: string;
}
const { title, description, path, pageType = 'WebPage', breadcrumb, faq, imageUrl } = Astro.props;
const site = (Astro.site ?? new URL(business.url)).toString().replace(/\/$/, '');
const pagePath = normalizePath(path ?? Astro.url.pathname);
const pageUrl = absoluteUrl(pagePath);
const ids = {
website: `${site}/#website`,
person: `${site}/#linus-olofsson`,
business: `${site}/#images-by-olofsson`,
webpage: `${pageUrl}#webpage`,
breadcrumb: `${pageUrl}#breadcrumb`,
};
// Tar bort null/undefined/tomma listor så att okända fakta aldrig hamnar i JSON-LD.
function clean<T>(value: T): T {
if (Array.isArray(value)) {
return value.map(clean).filter((v) => v !== null && v !== undefined) as unknown as T;
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (v === null || v === undefined) continue;
const c = clean(v);
if (Array.isArray(c) && c.length === 0) continue;
out[k] = c;
}
return out as T;
}
return value;
}
// Landet är känt (Sverige); ort och region läggs till när de fylls i business.ts.
const address = {
'@type': 'PostalAddress',
addressLocality: business.city,
addressRegion: business.region,
addressCountry: business.country.code,
};
const graph: Record<string, unknown>[] = [
{
'@type': 'WebSite',
'@id': ids.website,
url: `${site}/`,
name: business.brand,
inLanguage: 'sv-SE',
publisher: { '@id': ids.business },
},
{
'@type': 'Person',
'@id': ids.person,
name: business.person.name,
jobTitle: business.person.jobTitle,
url: absoluteUrl('/about'),
description: `${business.person.jobTitle} baserad i ${business.country.name}. ${business.recognitionThirdPerson}`,
knowsAbout: business.genres.map((g) => g.name),
worksFor: { '@id': ids.business },
sameAs: [business.social.linkedin, business.social.lightroom],
},
{
'@type': 'ProfessionalService',
'@id': ids.business,
name: business.brand,
url: `${site}/`,
description: `${business.person.name} fotograferar ${genreList()}.`,
email: business.email,
telephone: business.phone,
image: imageUrl,
address,
areaServed: business.serviceArea,
priceRange: business.priceRange,
founder: { '@id': ids.person },
employee: { '@id': ids.person },
sameAs: [business.social.instagram.url, business.social.flickr, business.social.lightroom],
potentialAction: {
'@type': 'ReserveAction',
name: 'Skicka en bokningsförfrågan',
target: business.portal.bookingUrl,
},
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: 'Fotografering',
itemListElement: business.genres.map((g) => ({
'@type': 'Offer',
priceCurrency: business.priceFromSek != null ? 'SEK' : null,
price: business.priceFromSek,
itemOffered: {
'@type': 'Service',
name: g.name,
description: g.description,
provider: { '@id': ids.business },
},
})),
},
},
];
const webpage: Record<string, unknown> = {
'@type': faq?.length ? [pageType, 'FAQPage'] : pageType,
'@id': ids.webpage,
url: pageUrl,
name: title,
description,
inLanguage: 'sv-SE',
isPartOf: { '@id': ids.website },
about: { '@id': ids.business },
primaryImageOfPage: imageUrl ? { '@type': 'ImageObject', url: imageUrl } : null,
breadcrumb: breadcrumb ? { '@id': ids.breadcrumb } : null,
mainEntity: faq?.length
? faq.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: { '@type': 'Answer', text: item.answer },
}))
: null,
};
graph.push(webpage);
if (breadcrumb) {
graph.push({
'@type': 'BreadcrumbList',
'@id': ids.breadcrumb,
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Hem', item: `${site}/` },
{ '@type': 'ListItem', position: 2, name: breadcrumb, item: pageUrl },
],
});
}
const json = JSON.stringify(clean({ '@context': 'https://schema.org', '@graph': graph })).replace(/</g, '\\u003c');
---
<script type="application/ld+json" is:inline set:html={json} />