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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

+46
View File
@@ -0,0 +1,46 @@
---
// src/components/SEO.astro
// <title>, meta description, canonical, Open Graph och Twitter-kort för en sida.
import { business } from '../data/business';
import { normalizePath } from '../lib/url';
export interface Props {
title: string;
description: string;
/** Sökväg utan avslutande snedstreck, t.ex. "/about". Annars härleds den från URL:en. */
path?: string;
/** Absolut URL till og:image (MainLayout tar fram en EXIF-fri JPG via getImage). */
imageUrl: string;
imageWidth?: number;
imageHeight?: number;
imageAlt: string;
type?: 'website' | 'profile' | 'article';
noindex?: boolean;
}
const { title, description, path, imageUrl, imageWidth, imageHeight, imageAlt, type = 'website', noindex = false } = Astro.props;
const site = Astro.site ?? new URL(business.url);
const canonical = new URL(normalizePath(path ?? Astro.url.pathname), site).toString();
---
<title>{title}</title>
<meta name="description" content={description} />
{noindex ? <meta name="robots" content="noindex, follow" /> : <link rel="canonical" href={canonical} />}
<meta property="og:type" content={type} />
<meta property="og:site_name" content={business.brand} />
<meta property="og:locale" content="sv_SE" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={imageUrl} />
{imageWidth && <meta property="og:image:width" content={String(imageWidth)} />}
{imageHeight && <meta property="og:image:height" content={String(imageHeight)} />}
<meta property="og:image:alt" content={imageAlt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageUrl} />
<meta name="twitter:image:alt" content={imageAlt} />
+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} />
+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();
}
+63
View File
@@ -0,0 +1,63 @@
// src/data/faq.ts
// Vanliga frågor för /tjanster. Samma text används på sidan och i FAQPage-JSON-LD.
// Svaren byggs ENBART av fakta i business.ts. Okända fakta (null) ger ett
// "kontakta mig"-svar i stället för en gissning.
import { business, basedIn, formatSek, genreList } from './business';
export type FaqItem = { question: string; answer: string };
function areaAnswer(): string {
if (business.serviceArea) {
return `Jag utgår från ${basedIn()} och tar uppdrag i ${business.serviceArea}. Hör av dig om du är osäker på om jag kan komma till dig.`;
}
return `Jag är baserad i ${basedIn()}. Kontakta mig så stämmer vi av om jag kan fotografera där du befinner dig.`;
}
function priceAnswer(): string {
const from = formatSek(business.priceFromSek);
if (from) {
return `En fotografering kostar från ${from}. Det slutliga priset beror på uppdraget och står i offerten du får efter din förfrågan.`;
}
return 'Priset beror på uppdraget. Kontakta mig eller skicka en förfrågan i kundportalen, så får du en offert för just det du vill ha fotograferat.';
}
function deliveryAnswer(): string {
const base = 'Du får bilderna i ett privat galleri i kundportalen. När betalningen är klar kan du ladda ner originalfilerna i full upplösning.';
if (business.deliveryTime) return `${base} Normal leveranstid är ${business.deliveryTime}.`;
return base;
}
export const faq: FaqItem[] = [
{
question: 'Vad fotograferar du?',
answer: `Jag fotograferar ${genreList()}.`,
},
{
question: 'Var finns du och var kan du fotografera?',
answer: areaAnswer(),
},
{
question: 'Vad kostar en fotografering?',
answer: priceAnswer(),
},
{
question: 'Hur bokar jag en fotografering?',
answer: `Skicka en förfrågan i kundportalen (${business.portal.bookingUrl}). Jag återkommer med en offert, och när du har godkänt den bokar vi in fotograferingen.`,
},
{
question: 'Hur får jag mina bilder?',
answer: deliveryAnswer(),
},
{
question: 'Vilken utrustning använder du?',
answer: `Jag fotograferar med en ${business.camera} och redigerar i ${business.editing}.`,
},
{
question: 'Har dina bilder uppmärksammats?',
answer: business.recognition,
},
{
question: 'Hur kontaktar jag dig?',
answer: `Mejla ${business.email} eller skicka ett meddelande på Instagram (${business.social.instagram.handle}).`,
},
];
+26
View File
@@ -0,0 +1,26 @@
// src/data/portfolio-meta.ts
// DATAKROK för riktiga alt-texter (och ev. titel/kategori) per portfoliobild.
// Fylls i senare (t.ex. av Imogen-agenten). Tom post = sidan använder en
// neutral svensk reservtext byggd på datum, aldrig kameramodell eller "undefined".
//
// Nyckel = bildens filnamn utan ändelse, exakt som i src/assets/images/portfolio/,
// t.ex. "immich-02533d10-07ca-4a39-8b8a-6648fe2ffcef-20260905-DSC07734"
// eller "20250510-WJ9E1439-Enhanced-NR".
//
// Exempel:
// "immich-02533d10-...-20260905-DSC07734": {
// alt: "Norrsken i grönt och lila över en mörk skogssiluett",
// title: "Norrsken i september",
// category: "Norrsken",
// },
export type PortfolioMeta = {
/** Beskriv vad bilden visar, på svenska, ca 8–16 ord. */
alt?: string;
/** Kort bildtitel (visas i lightboxen om den finns). */
title?: string;
/** Helst ett av namnen i business.genres, t.ex. "Natur och djur". */
category?: string;
};
export const portfolioMeta: Record<string, PortfolioMeta> = {};
+95 -24
View File
@@ -1,56 +1,127 @@
---
import "../styles/global.css";
const { title } = Astro.props;
import type { ImageMetadata } from 'astro';
import { getImage } from 'astro:assets';
import SEO from '../components/SEO.astro';
import SchemaOrg from '../components/SchemaOrg.astro';
import type { FaqItem } from '../data/faq';
import { business } from '../data/business';
import defaultOgImage from '../assets/images/3d-scroll/img5.jpg';
export interface Props {
/** Unik svensk titel för sidan. */
title: string;
/** Unik svensk meta description, ca 120–160 tecken. */
description: string;
/** Kanonisk sökväg, t.ex. "/tjanster". Utelämnas = härleds från URL:en. */
path?: string;
image?: ImageMetadata;
imageAlt?: string;
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
/** Namn i brödsmulan (BreadcrumbList) för undersidor. */
breadcrumb?: string;
faq?: FaqItem[];
noindex?: boolean;
}
const {
title,
description,
path,
image = defaultOgImage,
imageAlt = 'Svartvit bild av en man som bär ett litet barn bland såpbubblor i motljus',
pageType,
breadcrumb,
faq,
noindex = false,
} = Astro.props;
// og:image: ny JPG via Astros bildtjänst (EXIF/GPS rensas bort), absolut URL.
const site = Astro.site ?? new URL(business.url);
const og = await getImage({ src: image, width: 1200, format: 'jpg', quality: 82 });
const ogImageUrl = new URL(og.src, site).toString();
const ogWidth = Number(og.attributes.width) || undefined;
const ogHeight = Number(og.attributes.height) || undefined;
const nav = [
{ href: '/portfolio', label: 'Portfolio' },
{ href: '/tjanster', label: 'Tjänster' },
{ href: '/about', label: 'Om mig' },
{ href: '/contact', label: 'Kontakt' },
];
---
<html lang="sv" class="scroll-smooth"> <head>
<!doctype html>
<html lang="sv" class="scroll-smooth">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#171717" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
<title>{title}</title>
<link rel="sitemap" type="application/xml" href="/sitemap-index.xml" />
<SEO
title={title}
description={description}
path={path}
imageUrl={ogImageUrl}
imageWidth={ogWidth}
imageHeight={ogHeight}
imageAlt={imageAlt}
noindex={noindex}
/>
{!noindex && (
<SchemaOrg
title={title}
description={description}
path={path}
pageType={pageType}
breadcrumb={breadcrumb}
faq={faq}
imageUrl={ogImageUrl}
/>
)}
</head>
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col">
<nav class="fixed top-0 w-full z-50 border-b border-white/10 py-6 bg-neutral-900/80 backdrop-blur-md transition-all duration-300">
<div class="container mx-auto px-4 flex justify-between items-center">
<nav class="fixed top-0 w-full z-50 border-b border-white/10 py-4 md:py-6 bg-neutral-900/80 backdrop-blur-md transition-all duration-300">
<div class="container mx-auto px-4 flex flex-wrap gap-y-2 justify-between items-center">
<a href="/" class="text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50">
<a href="/" class="text-lg md:text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50">
Images by Olofsson
</a>
<ul class="flex space-x-6 text-sm font-medium text-gray-400">
<li><a href="/portfolio" class="hover:text-white transition">Portfolio</a></li>
<li><a href="/about" class="hover:text-white transition">About</a></li>
<li><a href="/contact" class="hover:text-white transition">Contact</a></li>
<ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
{nav.map((item) => (
<li><a href={item.href} class="hover:text-white transition">{item.label}</a></li>
))}
</ul>
</div>
</nav>
<main class="flex-grow pt-24">
<main class="flex-grow pt-28 md:pt-24">
<slot />
</main>
<footer class="border-t border-neutral-800 py-8 text-center text-gray-500 text-sm bg-neutral-900 relative z-40">
<footer class="border-t border-neutral-800 py-8 text-center text-gray-400 text-sm bg-neutral-900 relative z-40">
<p class="mb-2">
Crafting digital experiences for
<a href="https://lyxlimo.se/" target="_blank" rel="noopener noreferrer" data-glow-link="green" class="text-gray-400 hover:text-white transition-colors">lyxlimo.se</a>
&amp;
<a href="https://karleksguiden.se/" target="_blank" rel="noopener noreferrer" data-glow-link="magenta" class="text-gray-400 hover:text-white transition-colors">karleksguiden.se</a>
{business.brand} – {business.person.jobTitle.toLowerCase()} {business.person.name}, {business.country.name}
&middot; <a href={`mailto:${business.email}`} class="text-gray-300 hover:text-white transition-colors">{business.email}</a>
&middot; <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</a>
</p>
<p>&copy; {new Date().getFullYear()} Images by Olofsson. All rights reserved. &middot; <a href="/privacy" class="text-gray-400 hover:text-white transition-colors">Privacy</a> &middot; <button type="button" data-consent-open class="text-gray-400 hover:text-white transition-colors">Cookie settings</button></p>
<p>&copy; {new Date().getFullYear()} {business.brand}. Alla rättigheter förbehållna. &middot; <a href="/privacy" class="text-gray-300 hover:text-white transition-colors">Integritetspolicy</a> &middot; <button type="button" data-consent-open class="text-gray-300 hover:text-white transition-colors">Cookie-inställningar</button></p>
</footer>
<!-- Cookie consent: Meta Pixel laddas forst efter "Accept" -->
<!-- Cookie consent: Meta Pixel laddas först efter "Godkänn" -->
<div data-consent-banner hidden class="fixed bottom-4 inset-x-4 md:left-auto md:right-6 md:max-w-md z-[60] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-2xl p-5 shadow-2xl text-sm text-gray-300">
<p class="mb-4">
I use the Meta Pixel to see how visitors from Instagram and Facebook use this site. It sets cookies and sends data to Meta.
<a href="/privacy" class="text-white underline hover:text-gray-300">Read more</a>
Jag använder Meta Pixel för att se hur besökare från Instagram och Facebook använder sajten. Den sätter cookies och skickar data till Meta.
<a href="/privacy" class="text-white underline hover:text-gray-300">Läs mer</a>
</p>
<div class="flex gap-3 justify-end">
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors">Decline</button>
<button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors">Accept</button>
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors">Avböj</button>
<button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors">Godkänn</button>
</div>
</div>
@@ -78,7 +149,7 @@ const { title } = Astro.props;
/* End Meta Pixel Code */
}
// Aterkallat samtycke: ta bort Metas forsta-parts-cookies.
// Återkallat samtycke: ta bort Metas förstapartscookies.
function clearMetaCookies() {
['_fbp', '_fbc'].forEach(function (name) {
document.cookie = name + '=; Max-Age=0; path=/';
+8
View File
@@ -0,0 +1,8 @@
// src/lib/url.ts
// Gör om en byggsökväg till kanonisk form: "/", "/about", "/tjanster".
// Klarar både build.format "file" (/about.html) och "directory" (/about/).
export function normalizePath(p: string): string {
let out = p.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
if (out.length > 1) out = out.replace(/\/+$/, '');
return out || '/';
}