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
+181
View File
@@ -0,0 +1,181 @@
// src/data/business.ts
// ALLA fakta om Images by Olofsson på ett ställe. Sidor, JSON-LD, llms.txt och
// sitemap läser härifrån – skriv aldrig in fakta direkt i en sida.
//
// Regel: det vi inte vet är `null`. Allt som läser filen hoppar över null-värden
// (ingen "TODO", inga påhittade siffror syns på sajten). När Linus lämnar ett
// värde: fyll i det här, så dyker det upp på /tjanster, i FAQ och i JSON-LD.
export type Genre = {
/** Kort id, används som ankare på /tjanster (#portratt osv.) */
id: string;
name: string;
description: string;
};
export type Business = {
brand: string;
url: string;
person: {
name: string;
jobTitle: string;
};
email: string;
/** Telefonnummer i format +46... – okänt */
phone: string | null;
country: { code: string; name: string };
/** Ort Linus utgår från – okänt */
city: string | null;
/** Län/region – okänt */
region: string | null;
/** Område han tar uppdrag i (fritext, t.ex. "Stockholm med omnejd") – okänt */
serviceArea: string | null;
/** Lägsta pris i SEK för en fotografering – okänt */
priceFromSek: number | null;
/** schema.org priceRange, t.ex. "$$" eller "1500–5000 kr" – okänt */
priceRange: string | null;
/** Paket och vad som ingår – okänt */
packages: { name: string; includes: string[]; priceSek: number | null }[] | null;
/** Typisk leveranstid, fritext (t.ex. "inom två veckor") – okänt */
deliveryTime: string | null;
/** Antal år som fotograf – okänt */
yearsOfExperience: number | null;
/** Kundomdömen – inga kända */
testimonials: { name: string; text: string }[] | null;
camera: string;
lenses: string[];
editing: string;
/** Linus egen formulering: "featured by Adobe three times". Inga datum/detaljer. Jag-form för sidtext. */
recognition: string;
/** Samma fakta i tredje person (llms.txt, JSON-LD, meta descriptions). */
recognitionThirdPerson: string;
portal: {
url: string;
bookingUrl: string;
};
social: {
instagram: { handle: string; url: string };
flickr: string;
lightroom: string;
linkedin: string;
github: string;
};
genres: Genre[];
bookingSteps: { title: string; text: string }[];
};
export const business: Business = {
brand: 'Images by Olofsson',
url: 'https://imagesbyolofsson.se',
person: {
name: 'Linus Olofsson',
jobTitle: 'Fotograf',
},
email: 'info@imagesbyolofsson.se',
phone: null,
country: { code: 'SE', name: 'Sverige' },
city: null,
region: null,
serviceArea: null,
priceFromSek: null,
priceRange: null,
packages: null,
deliveryTime: null,
yearsOfExperience: null,
testimonials: null,
camera: 'Sony A7R V',
lenses: ['Sony FE 70-200mm f/4 G OSS', 'Sony FE 20mm f/1.8 G'],
editing: 'Adobe Lightroom Classic',
recognition: 'Mina bilder har lyfts fram av Adobe tre gånger.',
recognitionThirdPerson: 'Hans bilder har lyfts fram av Adobe tre gånger.',
portal: {
url: 'https://customer.imagesbyolofsson.se',
bookingUrl: 'https://customer.imagesbyolofsson.se/boka',
},
social: {
instagram: { handle: '@imagesbyolofsson', url: 'https://www.instagram.com/imagesbyolofsson/' },
flickr: 'https://www.flickr.com/photos/imagesbyolofsson/',
lightroom: 'https://lightroom.adobe.com/u/linusolofsson1',
linkedin: 'https://www.linkedin.com/in/linus-olofsson-/',
github: 'https://github.com/LinusOlofsson-maker',
},
// Bara genrer som redan finns i sajtens texter och gamla sidnamn
// (/my-people, /animales-and-nature, /celebrations-with-family, /northen-lights).
genres: [
{
id: 'portratt',
name: 'Porträtt och människor',
description: 'Porträtt och ögonblick med människor – uttryck som berättar något utan ord.',
},
{
id: 'natur',
name: 'Natur och djur',
description: 'Skog, landskap och djur – naturens stillhet och de små detaljerna i den.',
},
{
id: 'stad',
name: 'Stad och arkitektur',
description: 'Gator, byggnader, strukturer och kontraster – stadens energi och dess tomma stunder.',
},
{
id: 'makro',
name: 'Makro och detaljer',
description: 'Mönster, texturer och små detaljer som de flesta går förbi.',
},
{
id: 'norrsken',
name: 'Norrsken',
description: 'Norrskenets ljus och färger över himlen.',
},
{
id: 'fest',
name: 'Fester och familjehögtider',
description: 'Bilder från fester och högtider tillsammans med familj och vänner.',
},
],
bookingSteps: [
{
title: 'Skicka en förfrågan',
text: 'Berätta vad du vill ha fotograferat, när och var – via kundportalen.',
},
{
title: 'Få en offert',
text: 'Jag återkommer med ett förslag och en offert för just ditt uppdrag.',
},
{
title: 'Bokning',
text: 'När du har godkänt offerten bokar vi in fotograferingen.',
},
{
title: 'Leverans i ett privat galleri',
text: 'Du får bilderna i ett eget, privat galleri i kundportalen.',
},
{
title: 'Originalfilerna efter betalning',
text: 'När betalningen är klar kan du ladda ner originalfilerna i full upplösning.',
},
],
};
/** Genrer som läsbar lista: "a, b, c samt d". */
export function genreList(lowercase = true): string {
const names = business.genres.map((g) => (lowercase ? g.name.toLowerCase() : g.name));
if (names.length <= 1) return names.join('');
// "samt" före sista ledet, eftersom genrenamnen själva innehåller "och".
return `${names.slice(0, -1).join(', ')} samt ${names[names.length - 1]}`;
}
/** "Sverige", eller "Ort, Sverige" när orten är känd. */
export function basedIn(): string {
return [business.city, business.country.name].filter(Boolean).join(', ');
}
/** Formaterat pris ("1 500 kr") eller null. */
export function formatSek(value: number | null): string | null {
if (value == null) return null;
return `${value.toLocaleString('sv-SE')} kr`;
}
export function absoluteUrl(path: string): string {
return new URL(path, business.url).toString();
}