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:
co-authored by
Claude Opus 5.5
parent
ce5676f552
commit
30e385ec7f
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 |
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}).`,
|
||||
},
|
||||
];
|
||||
@@ -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> = {};
|
||||
@@ -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">
|
||||
|
||||
<a href="/" class="text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50">
|
||||
|
||||
<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-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">
|
||||
<slot />
|
||||
<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>
|
||||
&
|
||||
<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}
|
||||
· <a href={`mailto:${business.email}`} class="text-gray-300 hover:text-white transition-colors">{business.email}</a>
|
||||
· <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</a>
|
||||
</p>
|
||||
<p>© {new Date().getFullYear()} Images by Olofsson. All rights reserved. · <a href="/privacy" class="text-gray-400 hover:text-white transition-colors">Privacy</a> · <button type="button" data-consent-open class="text-gray-400 hover:text-white transition-colors">Cookie settings</button></p>
|
||||
<p>© {new Date().getFullYear()} {business.brand}. Alla rättigheter förbehållna. · <a href="/privacy" class="text-gray-300 hover:text-white transition-colors">Integritetspolicy</a> · <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=/';
|
||||
@@ -108,4 +179,4 @@ const { title } = Astro.props;
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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 || '/';
|
||||
}
|
||||
Reference in New Issue
Block a user