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> = {};
+99 -28
View File
@@ -1,56 +1,127 @@
--- ---
import "../styles/global.css"; 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 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="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" /> <link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.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> </head>
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col"> <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"> <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 justify-between items-center"> <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 Images by Olofsson
</a> </a>
<ul class="flex space-x-6 text-sm font-medium text-gray-400"> <ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
<li><a href="/portfolio" class="hover:text-white transition">Portfolio</a></li> {nav.map((item) => (
<li><a href="/about" class="hover:text-white transition">About</a></li> <li><a href={item.href} class="hover:text-white transition">{item.label}</a></li>
<li><a href="/contact" class="hover:text-white transition">Contact</a></li> ))}
</ul> </ul>
</div> </div>
</nav> </nav>
<main class="flex-grow pt-24"> <main class="flex-grow pt-28 md:pt-24">
<slot /> <slot />
</main> </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"> <p class="mb-2">
Crafting digital experiences for {business.brand} – {business.person.jobTitle.toLowerCase()} {business.person.name}, {business.country.name}
<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> &middot; <a href={`mailto:${business.email}`} class="text-gray-300 hover:text-white transition-colors">{business.email}</a>
&amp; &middot; <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</a>
<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>
</p> </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> </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"> <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"> <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. 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">Read more</a> <a href="/privacy" class="text-white underline hover:text-gray-300">Läs mer</a>
</p> </p>
<div class="flex gap-3 justify-end"> <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="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">Accept</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>
</div> </div>
@@ -78,7 +149,7 @@ const { title } = Astro.props;
/* End Meta Pixel Code */ /* End Meta Pixel Code */
} }
// Aterkallat samtycke: ta bort Metas forsta-parts-cookies. // Återkallat samtycke: ta bort Metas förstapartscookies.
function clearMetaCookies() { function clearMetaCookies() {
['_fbp', '_fbc'].forEach(function (name) { ['_fbp', '_fbc'].forEach(function (name) {
document.cookie = name + '=; Max-Age=0; path=/'; document.cookie = name + '=; Max-Age=0; path=/';
@@ -108,4 +179,4 @@ const { title } = Astro.props;
</script> </script>
</body> </body>
</html> </html>
+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 || '/';
}