diff --git a/public/3d-scroll/img1.jpg b/public/3d-scroll/img1.jpg deleted file mode 100644 index aaa04dd..0000000 Binary files a/public/3d-scroll/img1.jpg and /dev/null differ diff --git a/public/3d-scroll/img2.jpg b/public/3d-scroll/img2.jpg deleted file mode 100644 index fd3ced4..0000000 Binary files a/public/3d-scroll/img2.jpg and /dev/null differ diff --git a/public/3d-scroll/img3.jpg b/public/3d-scroll/img3.jpg deleted file mode 100644 index 07ec679..0000000 Binary files a/public/3d-scroll/img3.jpg and /dev/null differ diff --git a/public/3d-scroll/img4.jpg b/public/3d-scroll/img4.jpg deleted file mode 100644 index 4d61f9d..0000000 Binary files a/public/3d-scroll/img4.jpg and /dev/null differ diff --git a/public/3d-scroll/img5.jpg b/public/3d-scroll/img5.jpg deleted file mode 100644 index 6947880..0000000 Binary files a/public/3d-scroll/img5.jpg and /dev/null differ diff --git a/src/components/SEO.astro b/src/components/SEO.astro new file mode 100644 index 0000000..cfe2c32 --- /dev/null +++ b/src/components/SEO.astro @@ -0,0 +1,46 @@ +--- +// src/components/SEO.astro +// , 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} + +{noindex ? : } + + + + + + + + +{imageWidth && } +{imageHeight && } + + + + + + + diff --git a/src/components/SchemaOrg.astro b/src/components/SchemaOrg.astro new file mode 100644 index 0000000..eed2731 --- /dev/null +++ b/src/components/SchemaOrg.astro @@ -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(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 = {}; + for (const [k, v] of Object.entries(value as Record)) { + 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[] = [ + { + '@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 = { + '@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(/ diff --git a/src/data/business.ts b/src/data/business.ts new file mode 100644 index 0000000..289d421 --- /dev/null +++ b/src/data/business.ts @@ -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(); +} diff --git a/src/data/faq.ts b/src/data/faq.ts new file mode 100644 index 0000000..fe6f9eb --- /dev/null +++ b/src/data/faq.ts @@ -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}).`, + }, +]; diff --git a/src/data/portfolio-meta.ts b/src/data/portfolio-meta.ts new file mode 100644 index 0000000..a279658 --- /dev/null +++ b/src/data/portfolio-meta.ts @@ -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 = {}; diff --git a/src/layouts/MainLayout.astro b/src/layouts/MainLayout.astro index d026ac6..0132447 100644 --- a/src/layouts/MainLayout.astro +++ b/src/layouts/MainLayout.astro @@ -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' }, +]; --- - + + + - + + - {title} + + + {!noindex && ( + + )} - -