feat: OS-based dark mode across all blocks

This commit is contained in:
Jeffrey 2026-02-20 07:57:47 +01:00
parent b36f7ebe6e
commit 0bf56a1388
13 changed files with 177 additions and 252 deletions

View File

@ -13,7 +13,6 @@ interface HeaderClientProps {
data: Header
}
/** Resolves the logo href from the logoLink group field */
function resolveLogoHref(logoLink: Header['logoLink']): string {
if (!logoLink) return '/'
if (logoLink.type === 'reference') {
@ -28,11 +27,19 @@ function resolveLogoHref(logoLink: Header['logoLink']): string {
}
export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {
/* Storing the value in a useState to avoid hydration errors */
const [theme, setTheme] = useState<string | null>(null)
const [isDark, setIsDark] = useState(false)
const { headerTheme, setHeaderTheme } = useHeaderTheme()
const pathname = usePathname()
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
setIsDark(mq.matches)
const handler = (e: MediaQueryListEvent) => setIsDark(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
useEffect(() => {
setHeaderTheme(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -47,12 +54,12 @@ export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {
return (
<header
className="relative z-20 w-full bg-white"
className="relative z-20 w-full bg-white dark:bg-fd-navy border-b border-transparent dark:border-white/10"
{...(theme ? { 'data-theme': theme } : {})}
>
<div className="container py-5 flex justify-between items-center">
<Link href={logoHref}>
<Logo loading="eager" priority="high" variant="blue" className="max-w-[80px] md:max-w-[100px]" />
<Logo loading="eager" priority="high" variant={isDark ? "white" : "blue"} className="max-w-[80px] md:max-w-[100px]" />
</Link>
<HeaderNav data={data} />
</div>

View File

@ -5,11 +5,9 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { MenuIcon, XIcon, ChevronDownIcon, ChevronRightIcon } from 'lucide-react'
// ─── Types ────────────────────────────────────────────────────────────────────
type NavChild = NonNullable<NonNullable<NonNullable<HeaderType['navItems']>[number]['children']>[number]>
type NavItem = NonNullable<HeaderType['navItems']>[number]
// ─── Resolve href from reference or custom url ────────────────────────────────
function resolveHref(item: {
type?: string | null
url?: string | null
@ -25,7 +23,6 @@ function resolveHref(item: {
return item.url || '#'
}
// ─── Swedish Flag ─────────────────────────────────────────────────────────────
const SwedishFlag = () => (
<svg width="28" height="20" viewBox="0 0 28 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="rounded-[3px]">
<rect width="28" height="20" fill="#006AA7" />
@ -34,7 +31,6 @@ const SwedishFlag = () => (
</svg>
)
// ─── Group children into columns ──────────────────────────────────────────────
function groupChildren(children: NavChild[]): { heading: string | null; links: NavChild[] }[] {
const main: NavChild[] = []
const groups: Record<string, NavChild[]> = {}
@ -58,7 +54,6 @@ function groupChildren(children: NavChild[]): { heading: string | null; links: N
return result
}
// ─── Mega Menu Panel ──────────────────────────────────────────────────────────
const MegaMenuPanel: React.FC<{
item: NavItem
headerRef: React.RefObject<HTMLElement | null>
@ -94,18 +89,18 @@ const MegaMenuPanel: React.FC<{
onClick={onClose}
/>
{/* Panel */}
{/* Panel — white in light, navy in dark */}
<div
ref={panelRef}
className="fixed left-0 right-0 z-40"
style={{ top, backgroundColor: '#ffffff' }}
className="fixed left-0 right-0 z-40 bg-white dark:bg-fd-navy border-b border-fd-navy/10 dark:border-white/10"
style={{ top }}
>
<div className="max-w-[1200px] mx-auto px-6 md:px-8 py-10 md:py-14">
<div className="grid grid-cols-[200px_1fr] gap-12 md:gap-20">
{/* Left: category title */}
<div>
<h2 className="font-joey-heavy text-fd-h1 text-fd-navy leading-tight">
<h2 className="font-joey-heavy text-fd-h1 text-fd-navy dark:text-fd-yellow leading-tight">
{item.label}
</h2>
</div>
@ -115,7 +110,7 @@ const MegaMenuPanel: React.FC<{
{columns.map((col, ci) => (
<div key={ci}>
{col.heading && (
<p className="font-joey-medium text-fd-small text-fd-navy/40 uppercase tracking-widest mb-4">
<p className="font-joey-medium text-fd-small text-fd-navy/40 dark:text-white/40 uppercase tracking-widest mb-4">
{col.heading}
</p>
)}
@ -125,10 +120,10 @@ const MegaMenuPanel: React.FC<{
<Link
href={resolveHref(link)}
onClick={onClose}
className={`block py-1.5 transition-colors duration-150 hover:text-fd-yellow ${
className={`block py-1.5 transition-colors duration-150 ${
col.heading
? 'font-joey text-fd-body text-fd-navy/75 hover:text-fd-navy'
: 'font-joey-bold text-fd-h3 text-fd-navy'
? 'font-joey text-fd-body text-fd-navy/75 dark:text-white/70 hover:text-fd-navy dark:hover:text-fd-yellow'
: 'font-joey-bold text-fd-h3 text-fd-navy dark:text-white hover:text-fd-yellow dark:hover:text-fd-yellow'
}`}
>
{link.label}
@ -147,7 +142,6 @@ const MegaMenuPanel: React.FC<{
)
}
// ─── Main Nav ─────────────────────────────────────────────────────────────────
export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
const navItems = (data?.navItems || []) as NavItem[]
const [mobileOpen, setMobileOpen] = useState(false)
@ -208,7 +202,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
return (
<>
{/* ── Desktop nav ──────────────────────────────────────────────────── */}
{/* ── Desktop nav ── */}
<nav ref={navRef} className="hidden md:flex gap-6 items-center">
{navItems.map((item, i) => {
const hasChildren = item.children && item.children.length > 0
@ -220,7 +214,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
<button
onClick={() => setOpenDropdown(isOpen ? null : item.label ?? null)}
className={`flex items-center gap-1 font-joey-bold transition-colors text-base ${
isOpen ? 'text-fd-yellow' : 'text-fd-navy hover:text-fd-yellow'
isOpen ? 'text-fd-yellow' : 'text-fd-navy dark:text-white hover:text-fd-yellow dark:hover:text-fd-yellow'
}`}
aria-haspopup="true"
aria-expanded={isOpen}
@ -231,10 +225,10 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
/>
</button>
{/* Small card dropdown (non-mega only) */}
{/* Small card dropdown */}
{!isMega && (
<div
className={`absolute top-full left-0 mt-3 min-w-[220px] bg-white rounded-[16px] shadow-lg border border-fd-navy/10 py-2 z-50 transition-all duration-200 origin-top ${
className={`absolute top-full left-0 mt-3 min-w-[220px] bg-white dark:bg-fd-navy rounded-[16px] shadow-lg border border-fd-navy/10 dark:border-white/10 py-2 z-50 transition-all duration-200 origin-top ${
isOpen
? 'opacity-100 scale-y-100 pointer-events-auto'
: 'opacity-0 scale-y-95 pointer-events-none'
@ -245,7 +239,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
key={j}
href={resolveHref(child)}
onClick={() => setOpenDropdown(null)}
className="block px-5 py-2.5 font-joey text-fd-navy hover:bg-fd-yellow/20 transition-colors text-sm"
className="block px-5 py-2.5 font-joey text-fd-navy dark:text-white hover:bg-fd-yellow/20 dark:hover:bg-white/10 transition-colors text-sm"
>
{child.label}
</Link>
@ -257,7 +251,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
<Link
key={i}
href={resolveHref(item)}
className="font-joey-bold text-fd-navy hover:text-fd-yellow transition-colors text-base"
className="font-joey-bold text-fd-navy dark:text-white hover:text-fd-yellow dark:hover:text-fd-yellow transition-colors text-base"
>
{item.label}
</Link>
@ -267,7 +261,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
<SwedishFlag />
</nav>
{/* ── Mega menu panel ───────────────────────────────────────────────── */}
{/* ── Mega menu panel ── */}
{activeItem?.megaMenu && activeItem.children?.length && (
<MegaMenuPanel
item={activeItem}
@ -277,16 +271,16 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
/>
)}
{/* ── Mobile hamburger ─────────────────────────────────────────────── */}
{/* ── Mobile hamburger ── */}
<button
className="md:hidden text-fd-navy p-2"
className="md:hidden text-fd-navy dark:text-white p-2"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={mobileOpen ? 'Stäng meny' : 'Öppna meny'}
>
{mobileOpen ? <XIcon className="w-6 h-6" /> : <MenuIcon className="w-6 h-6" />}
</button>
{/* ── Mobile full-screen overlay ───────────────────────────────────── */}
{/* ── Mobile full-screen overlay — always navy, no dark: needed ── */}
<div
className={`fixed inset-0 z-50 bg-fd-navy flex flex-col transition-transform duration-300 md:hidden ${
mobileOpen ? 'translate-x-0' : 'translate-x-full'

View File

@ -3,7 +3,7 @@
@config '../../../tailwind.config.mjs';
@custom-variant dark (&:is([data-theme='dark'] *));
@custom-variant dark (@media (prefers-color-scheme: dark));
@custom-variant sm (@media (width >= theme(--breakpoint-sm)));
@custom-variant md (@media (width >= theme(--breakpoint-md)));
@custom-variant lg (@media (width >= theme(--breakpoint-lg)));
@ -225,7 +225,8 @@ html {
}
html[data-theme='dark'],
html[data-theme='light'] {
html[data-theme='light'],
html:not([data-theme]) {
opacity: initial;
}
@ -719,6 +720,27 @@ html[data-theme='light'] {
--fd-border-strong: #245580;
}
}
/* Button dark mode overrides
fd-btn-primary hover: navywhite (visible on dark bg)
fd-btn-secondary: flip to white border/text (invisible on navy otherwise)
*/
@media (prefers-color-scheme: dark) {
.fd-btn-primary:hover {
background-color: #ffffff;
color: var(--color-fd-navy);
}
.fd-btn-secondary {
color: #ffffff;
border-color: #ffffff;
}
.fd-btn-secondary:hover {
background-color: #ffffff;
color: var(--color-fd-navy);
}
}
/* FD Prose styles (for Posts rich text)
Add these to your existing globals.css
*/
@ -728,6 +750,30 @@ html[data-theme='light'] {
color: theme('colors.fd-navy');
}
@media (prefers-color-scheme: dark) {
.fd-prose {
color: #ffffff;
}
.fd-prose h1,
.fd-prose h2,
.fd-prose h3,
.fd-prose h4 {
color: theme('colors.fd-yellow');
}
.fd-prose a {
color: theme('colors.fd-yellow');
}
.fd-prose a:hover {
color: #ffffff;
}
.fd-prose blockquote {
color: rgba(255,255,255,0.8);
}
.fd-prose hr {
border-color: rgba(255,255,255,0.15);
}
}
.fd-prose h1,
.fd-prose h2,
.fd-prose h3,

View File

@ -43,13 +43,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
className={cn(GeistSans.variable, GeistMono.variable)}
lang="sv"
suppressHydrationWarning
style={{ colorScheme: 'light' }}
style={{ colorScheme: 'light dark' }}
>
<head>
<InitTheme />
<link href="/favicon.ico" rel="icon" sizes="32x32" />
<link href="/favicon.svg" rel="icon" type="image/svg+xml" />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e2338" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<HeadInjection siteSettings={siteSettings} />
</head>

View File

@ -31,33 +31,28 @@ export default async function PostPage({ params }: Args) {
const summary = post.meta?.description ?? null
return (
<article className="min-h-screen bg-white">
<article className="min-h-screen bg-white dark:bg-fd-navy">
{/* ── Main column ── */}
<div className="max-w-[1200px] mx-auto px-6 md:px-8 py-12 md:py-16 lg:py-20">
<div className="max-w-[640px]">
{/* Back link */}
<a
href="/posts"
className="inline-flex items-center gap-2 font-joey text-sm text-fd-navy/50 hover:text-fd-navy transition-colors mb-8"
className="inline-flex items-center gap-2 font-joey text-sm text-fd-navy/50 dark:text-white/50 hover:text-fd-navy dark:hover:text-white transition-colors mb-8"
>
Tillbaka till nyheter
</a>
{/* Title */}
<h1 className="font-joey-heavy text-[2rem] md:text-[2.5rem] lg:text-[2.875rem] leading-[1.1] text-fd-navy mb-4">
<h1 className="font-joey-heavy text-[2rem] md:text-[2.5rem] lg:text-[2.875rem] leading-[1.1] text-fd-navy dark:text-fd-yellow mb-4">
{post.title}
</h1>
{/* Summary / excerpt (from meta description) */}
{summary && (
<p className="font-joey text-base md:text-lg text-fd-navy/70 leading-relaxed mb-8">
<p className="font-joey text-base md:text-lg text-fd-navy/70 dark:text-white/70 leading-relaxed mb-8">
{summary}
</p>
)}
{/* Hero image */}
{heroImage?.url && (
<figure className="mb-6">
<div className="rounded-[20px] overflow-hidden">
@ -69,26 +64,24 @@ export default async function PostPage({ params }: Args) {
/>
</div>
{heroImage.alt && heroImage.alt.trim() !== '' && (
<figcaption className="mt-3 font-joey text-sm text-fd-navy/50 leading-snug">
<figcaption className="mt-3 font-joey text-sm text-fd-navy/50 dark:text-white/50 leading-snug">
{heroImage.alt}
</figcaption>
)}
</figure>
)}
{/* Author + date */}
<div className="mb-8 font-joey text-sm">
{authors.length > 0 && (
<p className="font-joey-bold text-fd-navy">
<p className="font-joey-bold text-fd-navy dark:text-white">
Av {authors.map((a: any) => a.name).join(', ')}
</p>
)}
{post.publishedAt && (
<p className="text-fd-navy/50">{formatDateTime(post.publishedAt)}</p>
<p className="text-fd-navy/50 dark:text-white/50">{formatDateTime(post.publishedAt)}</p>
)}
</div>
{/* Rich text body */}
{post.content && (
<div className="mb-12">
<RichText
@ -99,7 +92,6 @@ export default async function PostPage({ params }: Args) {
</div>
)}
{/* CTA */}
<a href="/kontakt" className="fd-btn-primary">
Kontakta oss
</a>
@ -110,7 +102,6 @@ export default async function PostPage({ params }: Args) {
)
}
// ─── Metadata ─────────────────────────────────────────────────────────────────
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params
const payload = await getPayload({ config })
@ -128,7 +119,6 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
return generateMeta({ doc: post })
}
// ─── Static params ────────────────────────────────────────────────────────────
export async function generateStaticParams() {
const payload = await getPayload({ config })
const posts = await payload.find({

View File

@ -28,19 +28,17 @@ export default async function Page() {
})
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-white dark:bg-fd-navy">
<PageClient />
<div className="max-w-[1200px] mx-auto px-6 md:px-8 pt-12 md:pt-16 pb-16 md:pb-24">
{/* Page heading */}
<h1 className="font-joey-heavy text-4xl md:text-5xl lg:text-[3.25rem] text-fd-navy mb-10 md:mb-14">
<h1 className="font-joey-heavy text-4xl md:text-5xl lg:text-[3.25rem] text-fd-navy dark:text-fd-yellow mb-10 md:mb-14">
Nyheter!
</h1>
{/* Grid */}
{posts.docs.length === 0 ? (
<p className="font-joey text-fd-navy/50 py-16">Inga inlägg hittades.</p>
<p className="font-joey text-fd-navy/50 dark:text-white/50 py-16">Inga inlägg hittades.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-12">
{posts.docs.map((post) => {
@ -53,20 +51,17 @@ export default async function Page() {
href={`/posts/${p.slug}`}
className="group flex flex-col"
>
{/* Title above image */}
<h2 className="font-joey-bold text-fd-navy text-xl md:text-[1.375rem] leading-snug mb-2 group-hover:text-fd-navy/70 transition-colors line-clamp-2">
<h2 className="font-joey-bold text-fd-navy dark:text-white text-xl md:text-[1.375rem] leading-snug mb-2 group-hover:text-fd-navy/70 dark:group-hover:text-fd-yellow transition-colors line-clamp-2">
{p.title}
</h2>
{/* Date between title and image */}
{p.publishedAt && (
<p className="font-joey text-sm text-fd-navy/50 mb-4">
<p className="font-joey text-sm text-fd-navy/50 dark:text-white/50 mb-4">
{formatDateTime(p.publishedAt)}
</p>
)}
{/* Image with rounded corners */}
<div className="relative aspect-[4/3] rounded-[20px] overflow-hidden bg-fd-navy/5">
<div className="relative aspect-[4/3] rounded-[20px] overflow-hidden bg-fd-navy/5 dark:bg-white/5">
{heroImage?.url ? (
<FDImage
media={heroImage}
@ -78,7 +73,7 @@ export default async function Page() {
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<span className="font-joey text-fd-navy/30 text-sm">Ingen bild</span>
<span className="font-joey text-fd-navy/30 dark:text-white/30 text-sm">Ingen bild</span>
</div>
)}
</div>
@ -88,7 +83,6 @@ export default async function Page() {
</div>
)}
{/* Pagination */}
{posts.totalPages > 1 && posts.page && (
<div className="mt-16">
<Pagination page={posts.page} totalPages={posts.totalPages} />

Binary file not shown.

View File

@ -13,10 +13,10 @@ const cardStyleMap: Record<
border: '',
},
gray: {
bg: 'bg-[#e5e5e5]',
headingText: 'text-fd-navy',
bodyText: 'text-fd-navy',
linkText: 'text-fd-navy hover:text-fd-navy/70',
bg: 'bg-[#e5e5e5] dark:bg-white/10',
headingText: 'text-fd-navy dark:text-fd-yellow',
bodyText: 'text-fd-navy dark:text-white',
linkText: 'text-fd-navy hover:text-fd-navy/70 dark:text-fd-yellow dark:hover:text-fd-yellow/80',
border: '',
},
yellow: {
@ -34,18 +34,18 @@ const cardStyleMap: Record<
border: '',
},
outlined: {
bg: 'bg-white',
headingText: 'text-fd-navy',
bodyText: 'text-fd-navy',
linkText: 'text-fd-navy/70 hover:text-fd-navy',
border: 'border-6 border-gray-200 shadow-sm',
bg: 'bg-white dark:bg-white/10',
headingText: 'text-fd-navy dark:text-fd-yellow',
bodyText: 'text-fd-navy dark:text-white',
linkText: 'text-fd-navy/70 hover:text-fd-navy dark:text-fd-yellow/80 dark:hover:text-fd-yellow',
border: 'border-6 border-gray-200 dark:border-white/10 shadow-sm',
},
}
const sectionBgMap: Record<string, string> = {
white: 'bg-white',
white: 'bg-white dark:bg-fd-navy',
navy: 'bg-fd-navy',
gray: 'bg-fd-gray-light',
gray: 'bg-fd-gray-light dark:bg-fd-navy',
}
const layoutGridMap: Record<string, string> = {

View File

@ -29,8 +29,8 @@ export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
headingColor = 'text-fd-navy'
textBodyColor = 'text-fd-navy'
} else {
headingColor = isDark ? 'text-fd-yellow' : 'text-fd-navy'
textBodyColor = isDark ? 'text-white' : 'text-fd-navy'
headingColor = isDark ? 'text-fd-yellow' : 'text-fd-navy dark:text-fd-yellow'
textBodyColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
}
const overlayClass =
@ -40,7 +40,9 @@ export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
return (
<section
className={`relative w-full py-16 md:py-20 lg:py-[99px] ${hasBgImage ? '' : isDark ? 'bg-fd-navy' : 'bg-white'} overflow-hidden`}
className={`relative w-full py-16 md:py-20 lg:py-[99px] ${
hasBgImage ? '' : isDark ? 'bg-fd-navy' : 'bg-white dark:bg-fd-navy'
} overflow-hidden`}
>
{hasBgImage && (
<>

View File

@ -13,10 +13,10 @@ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
const prevIndex = useRef(0)
const isDark = sectionBackground === 'navy'
const bgClass = isDark ? 'bg-fd-navy' : sectionBackground === 'gray' ? 'bg-fd-surface-alt' : 'bg-white'
const titleClass = isDark ? 'text-fd-yellow' : 'text-fd-navy'
const bodyClass = isDark ? 'text-white' : 'text-fd-navy'
const cardBg = isDark ? 'bg-white/10 border border-white/10' : 'bg-white border border-fd-navy/10'
const bgClass = isDark ? 'bg-fd-navy' : sectionBackground === 'gray' ? 'bg-fd-surface-alt dark:bg-fd-navy' : 'bg-white dark:bg-fd-navy'
const titleClass = isDark ? 'text-fd-yellow' : 'text-fd-navy dark:text-fd-yellow'
const bodyClass = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
const cardBg = isDark ? 'bg-white/10 border border-white/10' : 'bg-white border border-fd-navy/10 dark:bg-white/10 dark:border-white/10'
const handleTabChange = (i: number) => {
if (i === activeIndex) return
@ -57,7 +57,7 @@ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
? 'bg-fd-yellow border-fd-yellow text-fd-navy shadow-sm'
: isDark
? 'border-white/30 text-white hover:border-fd-yellow hover:text-fd-yellow'
: 'border-fd-navy/20 text-fd-navy hover:border-fd-yellow hover:bg-fd-yellow/10'
: 'border-fd-navy/20 text-fd-navy hover:border-fd-yellow hover:bg-fd-yellow/10 dark:border-white/30 dark:text-white dark:hover:border-fd-yellow dark:hover:text-fd-yellow'
}`}
>
{cat.label}
@ -87,7 +87,7 @@ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
transform: animating ? 'translateY(8px)' : 'translateY(0)',
}}
>
<h3 className={`font-joey-bold text-fd-h3 ${isDark ? 'text-fd-yellow' : 'text-fd-navy'}`}>
<h3 className={`font-joey-bold text-fd-h3 ${isDark ? 'text-fd-yellow' : 'text-fd-navy dark:text-fd-yellow'}`}>
{service.title}
</h3>
{service.description && (
@ -99,7 +99,7 @@ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
<div className="mt-auto pt-2">
<a
href={service.ctaLink || '/kontakt'}
className={isDark ? 'fd-btn-secondary-dark' : 'fd-btn-secondary'}
className={isDark ? 'fd-btn-secondary-dark' : 'fd-btn-secondary dark:fd-btn-secondary-dark'}
>
{service.ctaText}
</a>

View File

@ -12,104 +12,59 @@ const DEFAULT_PRICING = {
adminFee: 200,
}
const formatKr = (n: number) => {
// Format like "2 495 kr" with space as thousands separator
return Math.round(n).toLocaleString('sv-SE') + ' kr'
}
const formatKr = (n: number) => Math.round(n).toLocaleString('sv-SE') + ' kr'
// ─── Toggle switch ───────────────────────────────────────────────────────────
function Toggle({ active, onToggle }: { active: boolean; onToggle: () => void }) {
return (
<button
type="button"
onClick={onToggle}
style={{ display: 'block', width: '3.5rem', height: '2rem', flexShrink: 0 }}
className={`relative rounded-full transition-all duration-300 ${
active ? 'bg-fd-yellow' : 'bg-[#e2e8f0]'
}`}
className={`relative rounded-full transition-all duration-300 ${active ? 'bg-fd-yellow' : 'bg-[#e2e8f0] dark:bg-white/20'}`}
role="switch"
aria-checked={active}
>
<div
className={`absolute top-1 w-6 h-6 rounded-full bg-white shadow-sm transition-all duration-300 ${
active ? 'left-7' : 'left-1'
}`}
/>
<div className={`absolute top-1 w-6 h-6 rounded-full bg-white shadow-sm transition-all duration-300 ${active ? 'left-7' : 'left-1'}`} />
</button>
)
}
// ─── Number stepper row ──────────────────────────────────────────────────────
function ResourceRow({
label,
value,
onChange,
min = 0,
max = 999,
step = 1,
unit,
priceLabel,
isDark,
label, value, onChange, min = 0, max = 999, step = 1, unit, priceLabel, isDark,
}: {
label: string
value: number
onChange: (v: number) => void
min?: number
max?: number
step?: number
unit: string
priceLabel: string
isDark: boolean
label: string; value: number; onChange: (v: number) => void
min?: number; max?: number; step?: number; unit: string; priceLabel: string; isDark: boolean
}) {
const borderColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(14,35,56,0.08)'
const labelColor = isDark ? 'text-white' : 'text-fd-navy'
const subColor = isDark ? 'text-white/50' : 'text-fd-navy/50'
const btnColor = isDark
? 'border-white/30 text-white hover:border-white/70'
: 'border-fd-navy/20 text-fd-navy hover:border-fd-navy/60'
const inputColor = isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-fd-surface-alt border-fd-navy/15 text-fd-navy'
const unitColor = isDark ? 'text-white/40' : 'text-fd-navy/40'
return (
<div
className="flex items-center justify-between py-4"
style={{ borderBottom: `1px solid ${borderColor}` }}
>
<div className="flex items-center justify-between py-4 border-b border-fd-navy/10 dark:border-white/10">
<div className="flex flex-col">
<span className={`font-joey-bold text-fd-body ${labelColor}`}>{label}</span>
<span className={`font-joey text-fd-small ${subColor}`}>{priceLabel}</span>
<span className="font-joey-bold text-fd-body text-fd-navy dark:text-white">{label}</span>
<span className="font-joey text-fd-small text-fd-navy/50 dark:text-white/50">{priceLabel}</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onChange(Math.max(min, value - step))}
className={`w-9 h-9 rounded-full border-2 flex items-center justify-center font-joey-bold text-base transition-colors ${btnColor}`}
>
</button>
className="w-9 h-9 rounded-full border-2 flex items-center justify-center font-joey-bold text-base transition-colors border-fd-navy/20 text-fd-navy hover:border-fd-navy/60 dark:border-white/30 dark:text-white dark:hover:border-white/70"
></button>
<input
type="number"
value={value}
onChange={(e) => onChange(Math.max(min, Math.min(max, Number(e.target.value) || 0)))}
className={`w-16 text-center font-joey-medium text-fd-body rounded-full px-2 py-1.5 border-2 ${inputColor} [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none`}
className="w-16 text-center font-joey-medium text-fd-body rounded-full px-2 py-1.5 border-2 bg-fd-surface-alt border-fd-navy/15 text-fd-navy dark:bg-white/10 dark:border-white/20 dark:text-white [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
style={{ appearance: 'textfield' }}
/>
<span className={`font-joey text-fd-small w-7 ${unitColor}`}>{unit}</span>
<span className="font-joey text-fd-small w-7 text-fd-navy/40 dark:text-white/40">{unit}</span>
<button
type="button"
onClick={() => onChange(Math.min(max, value + step))}
className={`w-9 h-9 rounded-full border-2 flex items-center justify-center font-joey-bold text-base transition-colors ${btnColor}`}
>
+
</button>
className="w-9 h-9 rounded-full border-2 flex items-center justify-center font-joey-bold text-base transition-colors border-fd-navy/20 text-fd-navy hover:border-fd-navy/60 dark:border-white/30 dark:text-white dark:hover:border-white/70"
>+</button>
</div>
</div>
)
}
// ─── Main component ──────────────────────────────────────────────────────────
export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps> = ({
heading = 'Virtuell server — kalkylator',
description,
@ -153,46 +108,35 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
const ramCost = disc(ramGb * pricing.ramPerGb)
const ssdCost = disc(ssdGb * pricing.ssdPerGb)
const hddCost = disc(hddGb * pricing.hddPerGb)
// Admin fee is fixed — always on if enabled in CMS, not customer-controlled
const feeCost = showAdminFee ? feeAmount : 0
const extraCosts = (additionalServices ?? []).map((svc, i) =>
extraToggles[i] ? (svc.price ?? 0) : 0
)
const extraCosts = (additionalServices ?? []).map((svc, i) => extraToggles[i] ? (svc.price ?? 0) : 0)
const extraTotal = extraCosts.reduce((a, b) => a + b, 0)
const total = licenseCost + cpuCost + ramCost + ssdCost + hddCost + feeCost + extraTotal
return { licenseCost, cpuCost, ramCost, ssdCost, hddCost, feeCost, extraCosts, total }
}, [os, cpuCores, ramGb, ssdGb, hddGb, extraToggles, pricing, discount, feeAmount, showAdminFee, additionalServices])
const isDark = sectionBackground === 'navy'
const bgClass = isDark ? 'bg-fd-navy' : sectionBackground === 'gray' ? 'bg-fd-surface-alt' : 'bg-white'
const bgClass = isDark ? 'bg-fd-navy' : sectionBackground === 'gray' ? 'bg-fd-surface-alt dark:bg-fd-navy' : 'bg-white dark:bg-fd-navy'
// Card styling
const cardBg = isDark ? 'rgba(255,255,255,0.05)' : '#ffffff'
const cardBorder = isDark ? 'rgba(255,255,255,0.12)' : '#e2e8f0'
const cardStyle: React.CSSProperties = {
background: cardBg,
border: `6px solid ${cardBorder}`,
borderRadius: 'clamp(28px, 4vw, 60px)',
}
// Use Tailwind classes for card styling instead of inline styles
const cardClass = isDark
? 'bg-white/5 border-[6px] border-white/10 rounded-[clamp(28px,4vw,60px)]'
: 'bg-white border-[6px] border-[#e2e8f0] rounded-[clamp(28px,4vw,60px)] dark:bg-white/5 dark:border-white/10'
// Colors
const headingColor = isDark ? 'text-white' : 'text-fd-navy'
const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60'
const sectionLabelColor = isDark ? 'text-white/40' : 'text-fd-navy/35'
const dividerColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(14,35,56,0.08)'
const summaryLabelColor = isDark ? 'text-white' : 'text-fd-navy'
const summaryValueColor = isDark ? 'text-white' : 'text-fd-navy'
const categoryLabelColor = isDark ? 'text-white/40' : 'text-fd-navy/40'
const headingColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60 dark:text-white/60'
const sectionLabel = isDark ? 'text-white/40' : 'text-fd-navy/35 dark:text-white/40'
const summaryLabel = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
const categoryLabel = isDark ? 'text-white/40' : 'text-fd-navy/40 dark:text-white/40'
const dividerClass = isDark ? 'border-white/10' : 'border-fd-navy/8 dark:border-white/10'
// OS toggle
const osActiveClass = 'bg-fd-yellow text-fd-navy border-fd-yellow font-joey-bold'
const osInactiveClass = isDark
? 'bg-transparent text-white/70 border-white/20 hover:border-white/50 font-joey'
: 'bg-transparent text-fd-navy/70 border-fd-navy/20 hover:border-fd-navy/50 font-joey'
: 'bg-transparent text-fd-navy/70 border-fd-navy/20 hover:border-fd-navy/50 font-joey dark:text-white/70 dark:border-white/20 dark:hover:border-white/50'
const discStr = discount > 0 ? ` (${discountPercent}% rabatt)` : ''
// Summary rows
const baseRows = [
...(os === 'windows' ? [{ label: 'Licens (Windows)', cost: costs.licenseCost }] : []),
{ label: `CPU (${cpuCores} ${cpuCores === 1 ? 'kärna' : 'kärnor'})`, cost: costs.cpuCost },
@ -212,17 +156,10 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
<section className={`fd-section ${bgClass}`}>
<div className="fd-container">
{/* Section heading */}
{(heading || description) && (
<div className="text-center mb-10 md:mb-14">
{heading && (
<h2 className={`font-joey-heavy text-fd-h1 mb-3 ${headingColor}`}>{heading}</h2>
)}
{description && (
<p className={`font-joey text-fd-body-lg max-w-[600px] mx-auto ${descColor}`}>
{description}
</p>
)}
{heading && <h2 className={`font-joey-heavy text-fd-h1 mb-3 ${headingColor}`}>{heading}</h2>}
{description && <p className={`font-joey text-fd-body-lg max-w-[600px] mx-auto ${descColor}`}>{description}</p>}
{discount > 0 && (
<span className="inline-block mt-3 px-4 py-1 bg-fd-mint text-fd-navy font-joey-bold text-fd-small rounded-full">
{discountPercent}% rabatt alla resurser
@ -233,11 +170,10 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6 lg:gap-8">
{/* ── Left: Config ── */}
<div className="lg:col-span-3 p-7 md:p-9" style={cardStyle}>
{/* Left: Config */}
<div className={`lg:col-span-3 p-7 md:p-9 ${cardClass}`}>
{/* OS */}
<p className={`font-joey-medium text-fd-small tracking-widest uppercase mb-3 ${sectionLabelColor}`}>
<p className={`font-joey-medium text-fd-small tracking-widest uppercase mb-3 ${sectionLabel}`}>
Operativsystem
</p>
<div className="flex gap-3 mb-6">
@ -246,16 +182,13 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
key={opt}
type="button"
onClick={() => setOs(opt)}
className={`flex-1 py-3 rounded-full text-fd-body border-2 transition-all duration-200 ${
os === opt ? osActiveClass : osInactiveClass
}`}
className={`flex-1 py-3 rounded-full text-fd-body border-2 transition-all duration-200 ${os === opt ? osActiveClass : osInactiveClass}`}
>
{opt === 'linux' ? 'Linux (gratis)' : `Windows (+${pricing.windows}kr)`}
</button>
))}
</div>
{/* Resources */}
<div>
<ResourceRow label="CPU-kärnor" value={cpuCores} onChange={setCpuCores} min={1} max={32} unit="st" priceLabel={`${pricing.cpuPerCore} kr/kärna${discStr}`} isDark={isDark} />
<ResourceRow label="RAM" value={ramGb} onChange={setRamGb} min={1} max={256} unit="GB" priceLabel={`${pricing.ramPerGb} kr/GB${discStr}`} isDark={isDark} />
@ -263,38 +196,22 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
<ResourceRow label="HDD" value={hddGb} onChange={setHddGb} min={0} max={10000} step={100} unit="GB" priceLabel={`${pricing.hddPerGb} kr/GB${discStr}`} isDark={isDark} />
</div>
{/* Admin fee — fixed line, not customer-controlled */}
{showAdminFee && (
<div className="flex items-center justify-between py-4" style={{ borderBottom: `1px solid ${dividerColor}` }}>
<span className={`font-joey-bold text-fd-body ${isDark ? 'text-white' : 'text-fd-navy'}`}>
Adminavgift
</span>
<span className={`font-joey-medium text-fd-body ${isDark ? 'text-white/70' : 'text-fd-navy/70'}`}>
{formatKr(feeAmount)}
</span>
<div className={`flex items-center justify-between py-4 border-b ${dividerClass}`}>
<span className={`font-joey-bold text-fd-body ${summaryLabel}`}>Adminavgift</span>
<span className={`font-joey-medium text-fd-body ${categoryLabel}`}>{formatKr(feeAmount)}</span>
</div>
)}
{/* Tillvalstjänster */}
{hasTillval && (
<div className="mt-7">
<p className={`font-joey-medium text-fd-small tracking-widest uppercase mb-1 ${sectionLabelColor}`}>
Tillvalstjänster
</p>
<p className={`font-joey-medium text-fd-small tracking-widest uppercase mb-1 ${sectionLabel}`}>Tillvalstjänster</p>
{(additionalServices ?? []).map((svc, i) => (
<div
key={i}
className="flex items-center gap-4 py-4"
style={{ borderBottom: `1px solid ${dividerColor}` }}
>
<div key={i} className={`flex items-center gap-4 py-4 border-b ${dividerClass}`}>
<div className="flex-1 min-w-0">
<span className={`font-joey-bold text-fd-body ${isDark ? 'text-white' : 'text-fd-navy'}`}>
{svc.label}
</span>
<span className={`font-joey-bold text-fd-body ${summaryLabel}`}>{svc.label}</span>
{svc.price != null && (
<span className={`block font-joey text-fd-small ${isDark ? 'text-white/50' : 'text-fd-navy/50'}`}>
{svc.price} kr/mån
</span>
<span className={`block font-joey text-fd-small ${categoryLabel}`}>{svc.price} kr/mån</span>
)}
</div>
<div className="flex-shrink-0 w-14">
@ -306,69 +223,44 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
)}
</div>
{/* ── Right: Summary ── */}
<div className="lg:col-span-2 p-7 md:p-9 flex flex-col" style={cardStyle}>
<h3 className={`font-joey-heavy text-fd-h2 mb-6 ${headingColor}`}>
Kostnadsöversikt
</h3>
{/* Right: Summary */}
<div className={`lg:col-span-2 p-7 md:p-9 flex flex-col ${cardClass}`}>
<h3 className={`font-joey-heavy text-fd-h2 mb-6 ${headingColor}`}>Kostnadsöversikt</h3>
{/* Base cost rows */}
<div className="flex-1 space-y-0">
{baseRows.map((row, i) => (
<div
key={i}
className="flex justify-between items-center py-2.5"
style={{ borderBottom: `1px solid ${dividerColor}` }}
>
<span className={`font-joey text-fd-body ${summaryLabelColor}`}>{row.label}</span>
<span className={`font-joey-bold text-fd-body ${summaryValueColor}`}>
{formatKr(row.cost)}
</span>
<div key={i} className={`flex justify-between items-center py-2.5 border-b ${dividerClass}`}>
<span className={`font-joey text-fd-body ${summaryLabel}`}>{row.label}</span>
<span className={`font-joey-bold text-fd-body ${summaryLabel}`}>{formatKr(row.cost)}</span>
</div>
))}
{/* Tillval section in summary */}
{tillvalRows.length > 0 && (
<>
<div className="pt-4 pb-1">
<span className={`font-joey text-fd-small ${categoryLabelColor}`}>Tillval</span>
<span className={`font-joey text-fd-small ${categoryLabel}`}>Tillval</span>
</div>
{tillvalRows.map((row, i) => (
<div
key={i}
className="flex justify-between items-center py-2.5"
style={{ borderBottom: `1px solid ${dividerColor}` }}
>
<span className={`font-joey text-fd-body ${summaryLabelColor}`}>{row.label}</span>
<span className={`font-joey-bold text-fd-body ${summaryValueColor}`}>
{formatKr(row.cost)}
</span>
<div key={i} className={`flex justify-between items-center py-2.5 border-b ${dividerClass}`}>
<span className={`font-joey text-fd-body ${summaryLabel}`}>{row.label}</span>
<span className={`font-joey-bold text-fd-body ${summaryLabel}`}>{formatKr(row.cost)}</span>
</div>
))}
</>
)}
</div>
{/* Total */}
<div className="mt-6 pt-5" style={{ borderTop: `2px solid ${dividerColor}` }}>
<div className={`mt-6 pt-5 border-t-2 ${dividerClass}`}>
<div className="flex justify-between items-baseline">
<span className={`font-joey text-fd-body ${summaryLabelColor}`}>Totalt per månad</span>
<span className={`font-joey-heavy text-fd-h1 ${isDark ? 'text-white' : 'text-fd-navy'}`}>
{formatKr(costs.total)}
</span>
<span className={`font-joey text-fd-body ${summaryLabel}`}>Totalt per månad</span>
<span className={`font-joey-heavy text-fd-h1 ${headingColor}`}>{formatKr(costs.total)}</span>
</div>
<p className={`text-right font-joey text-fd-small mt-1 ${categoryLabelColor}`}>exkl. moms</p>
<p className={`text-right font-joey text-fd-small mt-1 ${categoryLabel}`}>exkl. moms</p>
</div>
{/* CTAs */}
<div className="mt-7 space-y-3">
<a href={orderCtaLink || '#'} className="fd-btn-primary w-full justify-center">
{orderCtaText}
</a>
<a
href={contactCtaLink || '#'}
className={`${isDark ? 'fd-btn-secondary-dark' : 'fd-btn-secondary'} w-full justify-center`}
>
<a href={orderCtaLink || '#'} className="fd-btn-primary w-full justify-center">{orderCtaText}</a>
<a href={contactCtaLink || '#'} className={`${isDark ? 'fd-btn-secondary-dark' : 'fd-btn-secondary dark:fd-btn-secondary-dark'} w-full justify-center`}>
{contactCtaText}
</a>
</div>

View File

@ -35,7 +35,7 @@ import { FDCtaBannerBlockComponent } from './FDCtaBannerBlock/Component'
import { FDTestimonialBlockComponent } from './FDTestimonialBlock/Component'
import { FDTeamBlockComponent } from './FDTeamBlock/Component'
const blockComponents = {
const blockComponents: Record<string, React.FC<any>> = {
formBlock: FormBlock,
fdHero: FDHeroBlockComponent,
fdCtaSideImage: FDCtaSideImageBlockComponent,
@ -126,7 +126,6 @@ export const RenderBlocks: React.FC<{
return (
<div className={isFullWidth ? '' : 'my-16'} key={index}>
{/* @ts-expect-error there may be some mismatch between the expected types here */}
<Block {...block} disableInnerContainer />
</div>
)

File diff suppressed because one or more lines are too long