feat: OS-based dark mode across all blocks
This commit is contained in:
parent
b36f7ebe6e
commit
0bf56a1388
@ -13,7 +13,6 @@ interface HeaderClientProps {
|
|||||||
data: Header
|
data: Header
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves the logo href from the logoLink group field */
|
|
||||||
function resolveLogoHref(logoLink: Header['logoLink']): string {
|
function resolveLogoHref(logoLink: Header['logoLink']): string {
|
||||||
if (!logoLink) return '/'
|
if (!logoLink) return '/'
|
||||||
if (logoLink.type === 'reference') {
|
if (logoLink.type === 'reference') {
|
||||||
@ -28,11 +27,19 @@ function resolveLogoHref(logoLink: Header['logoLink']): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {
|
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 [theme, setTheme] = useState<string | null>(null)
|
||||||
|
const [isDark, setIsDark] = useState(false)
|
||||||
const { headerTheme, setHeaderTheme } = useHeaderTheme()
|
const { headerTheme, setHeaderTheme } = useHeaderTheme()
|
||||||
const pathname = usePathname()
|
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(() => {
|
useEffect(() => {
|
||||||
setHeaderTheme(null)
|
setHeaderTheme(null)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -47,12 +54,12 @@ export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<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 } : {})}
|
{...(theme ? { 'data-theme': theme } : {})}
|
||||||
>
|
>
|
||||||
<div className="container py-5 flex justify-between items-center">
|
<div className="container py-5 flex justify-between items-center">
|
||||||
<Link href={logoHref}>
|
<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>
|
</Link>
|
||||||
<HeaderNav data={data} />
|
<HeaderNav data={data} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,11 +5,9 @@ import Link from 'next/link'
|
|||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { MenuIcon, XIcon, ChevronDownIcon, ChevronRightIcon } from 'lucide-react'
|
import { MenuIcon, XIcon, ChevronDownIcon, ChevronRightIcon } from 'lucide-react'
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
type NavChild = NonNullable<NonNullable<NonNullable<HeaderType['navItems']>[number]['children']>[number]>
|
type NavChild = NonNullable<NonNullable<NonNullable<HeaderType['navItems']>[number]['children']>[number]>
|
||||||
type NavItem = NonNullable<HeaderType['navItems']>[number]
|
type NavItem = NonNullable<HeaderType['navItems']>[number]
|
||||||
|
|
||||||
// ─── Resolve href from reference or custom url ────────────────────────────────
|
|
||||||
function resolveHref(item: {
|
function resolveHref(item: {
|
||||||
type?: string | null
|
type?: string | null
|
||||||
url?: string | null
|
url?: string | null
|
||||||
@ -25,7 +23,6 @@ function resolveHref(item: {
|
|||||||
return item.url || '#'
|
return item.url || '#'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Swedish Flag ─────────────────────────────────────────────────────────────
|
|
||||||
const SwedishFlag = () => (
|
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]">
|
<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" />
|
<rect width="28" height="20" fill="#006AA7" />
|
||||||
@ -34,7 +31,6 @@ const SwedishFlag = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Group children into columns ──────────────────────────────────────────────
|
|
||||||
function groupChildren(children: NavChild[]): { heading: string | null; links: NavChild[] }[] {
|
function groupChildren(children: NavChild[]): { heading: string | null; links: NavChild[] }[] {
|
||||||
const main: NavChild[] = []
|
const main: NavChild[] = []
|
||||||
const groups: Record<string, NavChild[]> = {}
|
const groups: Record<string, NavChild[]> = {}
|
||||||
@ -58,7 +54,6 @@ function groupChildren(children: NavChild[]): { heading: string | null; links: N
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mega Menu Panel ──────────────────────────────────────────────────────────
|
|
||||||
const MegaMenuPanel: React.FC<{
|
const MegaMenuPanel: React.FC<{
|
||||||
item: NavItem
|
item: NavItem
|
||||||
headerRef: React.RefObject<HTMLElement | null>
|
headerRef: React.RefObject<HTMLElement | null>
|
||||||
@ -94,18 +89,18 @@ const MegaMenuPanel: React.FC<{
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Panel */}
|
{/* Panel — white in light, navy in dark */}
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="fixed left-0 right-0 z-40"
|
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, backgroundColor: '#ffffff' }}
|
style={{ top }}
|
||||||
>
|
>
|
||||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 py-10 md:py-14">
|
<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">
|
<div className="grid grid-cols-[200px_1fr] gap-12 md:gap-20">
|
||||||
|
|
||||||
{/* Left: category title */}
|
{/* Left: category title */}
|
||||||
<div>
|
<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}
|
{item.label}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -115,7 +110,7 @@ const MegaMenuPanel: React.FC<{
|
|||||||
{columns.map((col, ci) => (
|
{columns.map((col, ci) => (
|
||||||
<div key={ci}>
|
<div key={ci}>
|
||||||
{col.heading && (
|
{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}
|
{col.heading}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -125,10 +120,10 @@ const MegaMenuPanel: React.FC<{
|
|||||||
<Link
|
<Link
|
||||||
href={resolveHref(link)}
|
href={resolveHref(link)}
|
||||||
onClick={onClose}
|
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
|
col.heading
|
||||||
? 'font-joey text-fd-body text-fd-navy/75 hover: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'
|
: 'font-joey-bold text-fd-h3 text-fd-navy dark:text-white hover:text-fd-yellow dark:hover:text-fd-yellow'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
@ -147,7 +142,6 @@ const MegaMenuPanel: React.FC<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main Nav ─────────────────────────────────────────────────────────────────
|
|
||||||
export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
||||||
const navItems = (data?.navItems || []) as NavItem[]
|
const navItems = (data?.navItems || []) as NavItem[]
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
@ -208,7 +202,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Desktop nav ──────────────────────────────────────────────────── */}
|
{/* ── Desktop nav ── */}
|
||||||
<nav ref={navRef} className="hidden md:flex gap-6 items-center">
|
<nav ref={navRef} className="hidden md:flex gap-6 items-center">
|
||||||
{navItems.map((item, i) => {
|
{navItems.map((item, i) => {
|
||||||
const hasChildren = item.children && item.children.length > 0
|
const hasChildren = item.children && item.children.length > 0
|
||||||
@ -220,7 +214,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setOpenDropdown(isOpen ? null : item.label ?? null)}
|
onClick={() => setOpenDropdown(isOpen ? null : item.label ?? null)}
|
||||||
className={`flex items-center gap-1 font-joey-bold transition-colors text-base ${
|
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-haspopup="true"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
@ -231,10 +225,10 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Small card dropdown (non-mega only) */}
|
{/* Small card dropdown */}
|
||||||
{!isMega && (
|
{!isMega && (
|
||||||
<div
|
<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
|
isOpen
|
||||||
? 'opacity-100 scale-y-100 pointer-events-auto'
|
? 'opacity-100 scale-y-100 pointer-events-auto'
|
||||||
: 'opacity-0 scale-y-95 pointer-events-none'
|
: 'opacity-0 scale-y-95 pointer-events-none'
|
||||||
@ -245,7 +239,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
|||||||
key={j}
|
key={j}
|
||||||
href={resolveHref(child)}
|
href={resolveHref(child)}
|
||||||
onClick={() => setOpenDropdown(null)}
|
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}
|
{child.label}
|
||||||
</Link>
|
</Link>
|
||||||
@ -257,7 +251,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
|||||||
<Link
|
<Link
|
||||||
key={i}
|
key={i}
|
||||||
href={resolveHref(item)}
|
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}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
@ -267,7 +261,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
|||||||
<SwedishFlag />
|
<SwedishFlag />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ── Mega menu panel ───────────────────────────────────────────────── */}
|
{/* ── Mega menu panel ── */}
|
||||||
{activeItem?.megaMenu && activeItem.children?.length && (
|
{activeItem?.megaMenu && activeItem.children?.length && (
|
||||||
<MegaMenuPanel
|
<MegaMenuPanel
|
||||||
item={activeItem}
|
item={activeItem}
|
||||||
@ -277,16 +271,16 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Mobile hamburger ─────────────────────────────────────────────── */}
|
{/* ── Mobile hamburger ── */}
|
||||||
<button
|
<button
|
||||||
className="md:hidden text-fd-navy p-2"
|
className="md:hidden text-fd-navy dark:text-white p-2"
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
aria-label={mobileOpen ? 'Stäng meny' : 'Öppna meny'}
|
aria-label={mobileOpen ? 'Stäng meny' : 'Öppna meny'}
|
||||||
>
|
>
|
||||||
{mobileOpen ? <XIcon className="w-6 h-6" /> : <MenuIcon className="w-6 h-6" />}
|
{mobileOpen ? <XIcon className="w-6 h-6" /> : <MenuIcon className="w-6 h-6" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* ── Mobile full-screen overlay ───────────────────────────────────── */}
|
{/* ── Mobile full-screen overlay — always navy, no dark: needed ── */}
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 z-50 bg-fd-navy flex flex-col transition-transform duration-300 md:hidden ${
|
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'
|
mobileOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
@config '../../../tailwind.config.mjs';
|
@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 sm (@media (width >= theme(--breakpoint-sm)));
|
||||||
@custom-variant md (@media (width >= theme(--breakpoint-md)));
|
@custom-variant md (@media (width >= theme(--breakpoint-md)));
|
||||||
@custom-variant lg (@media (width >= theme(--breakpoint-lg)));
|
@custom-variant lg (@media (width >= theme(--breakpoint-lg)));
|
||||||
@ -225,7 +225,8 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='dark'],
|
html[data-theme='dark'],
|
||||||
html[data-theme='light'] {
|
html[data-theme='light'],
|
||||||
|
html:not([data-theme]) {
|
||||||
opacity: initial;
|
opacity: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -719,6 +720,27 @@ html[data-theme='light'] {
|
|||||||
--fd-border-strong: #245580;
|
--fd-border-strong: #245580;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* ── Button dark mode overrides ───────────────────────────────────────────
|
||||||
|
fd-btn-primary hover: navy→white (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) ────────────────────────────────
|
/* ── FD Prose styles (for Posts rich text) ────────────────────────────────
|
||||||
Add these to your existing globals.css
|
Add these to your existing globals.css
|
||||||
─────────────────────────────────────────────────────────────────────── */
|
─────────────────────────────────────────────────────────────────────── */
|
||||||
@ -728,6 +750,30 @@ html[data-theme='light'] {
|
|||||||
color: theme('colors.fd-navy');
|
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 h1,
|
||||||
.fd-prose h2,
|
.fd-prose h2,
|
||||||
.fd-prose h3,
|
.fd-prose h3,
|
||||||
|
|||||||
@ -43,13 +43,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
className={cn(GeistSans.variable, GeistMono.variable)}
|
className={cn(GeistSans.variable, GeistMono.variable)}
|
||||||
lang="sv"
|
lang="sv"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
style={{ colorScheme: 'light' }}
|
style={{ colorScheme: 'light dark' }}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<InitTheme />
|
<InitTheme />
|
||||||
<link href="/favicon.ico" rel="icon" sizes="32x32" />
|
<link href="/favicon.ico" rel="icon" sizes="32x32" />
|
||||||
<link href="/favicon.svg" rel="icon" type="image/svg+xml" />
|
<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" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<HeadInjection siteSettings={siteSettings} />
|
<HeadInjection siteSettings={siteSettings} />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -31,33 +31,28 @@ export default async function PostPage({ params }: Args) {
|
|||||||
const summary = post.meta?.description ?? null
|
const summary = post.meta?.description ?? null
|
||||||
|
|
||||||
return (
|
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-[1200px] mx-auto px-6 md:px-8 py-12 md:py-16 lg:py-20">
|
||||||
<div className="max-w-[640px]">
|
<div className="max-w-[640px]">
|
||||||
|
|
||||||
{/* Back link */}
|
|
||||||
<a
|
<a
|
||||||
href="/posts"
|
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
|
← Tillbaka till nyheter
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Title */}
|
<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">
|
||||||
<h1 className="font-joey-heavy text-[2rem] md:text-[2.5rem] lg:text-[2.875rem] leading-[1.1] text-fd-navy mb-4">
|
|
||||||
{post.title}
|
{post.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Summary / excerpt (from meta description) */}
|
|
||||||
{summary && (
|
{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}
|
{summary}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hero image */}
|
|
||||||
{heroImage?.url && (
|
{heroImage?.url && (
|
||||||
<figure className="mb-6">
|
<figure className="mb-6">
|
||||||
<div className="rounded-[20px] overflow-hidden">
|
<div className="rounded-[20px] overflow-hidden">
|
||||||
@ -69,26 +64,24 @@ export default async function PostPage({ params }: Args) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{heroImage.alt && heroImage.alt.trim() !== '' && (
|
{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}
|
{heroImage.alt}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
)}
|
)}
|
||||||
</figure>
|
</figure>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Author + date */}
|
|
||||||
<div className="mb-8 font-joey text-sm">
|
<div className="mb-8 font-joey text-sm">
|
||||||
{authors.length > 0 && (
|
{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(', ')}
|
Av {authors.map((a: any) => a.name).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{post.publishedAt && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Rich text body */}
|
|
||||||
{post.content && (
|
{post.content && (
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<RichText
|
<RichText
|
||||||
@ -99,7 +92,6 @@ export default async function PostPage({ params }: Args) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<a href="/kontakt" className="fd-btn-primary">
|
<a href="/kontakt" className="fd-btn-primary">
|
||||||
Kontakta oss
|
Kontakta oss
|
||||||
</a>
|
</a>
|
||||||
@ -110,7 +102,6 @@ export default async function PostPage({ params }: Args) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
|
||||||
export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
@ -128,7 +119,6 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
|||||||
return generateMeta({ doc: post })
|
return generateMeta({ doc: post })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Static params ────────────────────────────────────────────────────────────
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
const posts = await payload.find({
|
const posts = await payload.find({
|
||||||
|
|||||||
@ -28,19 +28,17 @@ export default async function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white dark:bg-fd-navy">
|
||||||
<PageClient />
|
<PageClient />
|
||||||
|
|
||||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 pt-12 md:pt-16 pb-16 md:pb-24">
|
<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 dark:text-fd-yellow mb-10 md:mb-14">
|
||||||
<h1 className="font-joey-heavy text-4xl md:text-5xl lg:text-[3.25rem] text-fd-navy mb-10 md:mb-14">
|
|
||||||
Nyheter!
|
Nyheter!
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
{posts.docs.length === 0 ? (
|
{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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-12">
|
||||||
{posts.docs.map((post) => {
|
{posts.docs.map((post) => {
|
||||||
@ -53,20 +51,17 @@ export default async function Page() {
|
|||||||
href={`/posts/${p.slug}`}
|
href={`/posts/${p.slug}`}
|
||||||
className="group flex flex-col"
|
className="group flex flex-col"
|
||||||
>
|
>
|
||||||
{/* Title above image */}
|
<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">
|
||||||
<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">
|
|
||||||
{p.title}
|
{p.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Date between title and image */}
|
|
||||||
{p.publishedAt && (
|
{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)}
|
{formatDateTime(p.publishedAt)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Image with rounded corners */}
|
<div className="relative aspect-[4/3] rounded-[20px] overflow-hidden bg-fd-navy/5 dark:bg-white/5">
|
||||||
<div className="relative aspect-[4/3] rounded-[20px] overflow-hidden bg-fd-navy/5">
|
|
||||||
{heroImage?.url ? (
|
{heroImage?.url ? (
|
||||||
<FDImage
|
<FDImage
|
||||||
media={heroImage}
|
media={heroImage}
|
||||||
@ -78,7 +73,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -88,7 +83,6 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{posts.totalPages > 1 && posts.page && (
|
{posts.totalPages > 1 && posts.page && (
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<Pagination page={posts.page} totalPages={posts.totalPages} />
|
<Pagination page={posts.page} totalPages={posts.totalPages} />
|
||||||
|
|||||||
BIN
src/blocks.zip
BIN
src/blocks.zip
Binary file not shown.
@ -13,10 +13,10 @@ const cardStyleMap: Record<
|
|||||||
border: '',
|
border: '',
|
||||||
},
|
},
|
||||||
gray: {
|
gray: {
|
||||||
bg: 'bg-[#e5e5e5]',
|
bg: 'bg-[#e5e5e5] dark:bg-white/10',
|
||||||
headingText: 'text-fd-navy',
|
headingText: 'text-fd-navy dark:text-fd-yellow',
|
||||||
bodyText: 'text-fd-navy',
|
bodyText: 'text-fd-navy dark:text-white',
|
||||||
linkText: 'text-fd-navy hover:text-fd-navy/70',
|
linkText: 'text-fd-navy hover:text-fd-navy/70 dark:text-fd-yellow dark:hover:text-fd-yellow/80',
|
||||||
border: '',
|
border: '',
|
||||||
},
|
},
|
||||||
yellow: {
|
yellow: {
|
||||||
@ -34,18 +34,18 @@ const cardStyleMap: Record<
|
|||||||
border: '',
|
border: '',
|
||||||
},
|
},
|
||||||
outlined: {
|
outlined: {
|
||||||
bg: 'bg-white',
|
bg: 'bg-white dark:bg-white/10',
|
||||||
headingText: 'text-fd-navy',
|
headingText: 'text-fd-navy dark:text-fd-yellow',
|
||||||
bodyText: 'text-fd-navy',
|
bodyText: 'text-fd-navy dark:text-white',
|
||||||
linkText: 'text-fd-navy/70 hover:text-fd-navy',
|
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 shadow-sm',
|
border: 'border-6 border-gray-200 dark:border-white/10 shadow-sm',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionBgMap: Record<string, string> = {
|
const sectionBgMap: Record<string, string> = {
|
||||||
white: 'bg-white',
|
white: 'bg-white dark:bg-fd-navy',
|
||||||
navy: '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> = {
|
const layoutGridMap: Record<string, string> = {
|
||||||
|
|||||||
@ -29,8 +29,8 @@ export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
|
|||||||
headingColor = 'text-fd-navy'
|
headingColor = 'text-fd-navy'
|
||||||
textBodyColor = 'text-fd-navy'
|
textBodyColor = 'text-fd-navy'
|
||||||
} else {
|
} else {
|
||||||
headingColor = isDark ? 'text-fd-yellow' : 'text-fd-navy'
|
headingColor = isDark ? 'text-fd-yellow' : 'text-fd-navy dark:text-fd-yellow'
|
||||||
textBodyColor = isDark ? 'text-white' : 'text-fd-navy'
|
textBodyColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlayClass =
|
const overlayClass =
|
||||||
@ -40,7 +40,9 @@ export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<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 && (
|
{hasBgImage && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -13,10 +13,10 @@ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
|
|||||||
const prevIndex = useRef(0)
|
const prevIndex = useRef(0)
|
||||||
|
|
||||||
const isDark = sectionBackground === 'navy'
|
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'
|
||||||
const titleClass = isDark ? 'text-fd-yellow' : 'text-fd-navy'
|
const titleClass = isDark ? 'text-fd-yellow' : 'text-fd-navy dark:text-fd-yellow'
|
||||||
const bodyClass = isDark ? 'text-white' : 'text-fd-navy'
|
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'
|
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) => {
|
const handleTabChange = (i: number) => {
|
||||||
if (i === activeIndex) return
|
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'
|
? 'bg-fd-yellow border-fd-yellow text-fd-navy shadow-sm'
|
||||||
: isDark
|
: isDark
|
||||||
? 'border-white/30 text-white hover:border-fd-yellow hover:text-fd-yellow'
|
? '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}
|
{cat.label}
|
||||||
@ -87,7 +87,7 @@ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
|
|||||||
transform: animating ? 'translateY(8px)' : 'translateY(0)',
|
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}
|
{service.title}
|
||||||
</h3>
|
</h3>
|
||||||
{service.description && (
|
{service.description && (
|
||||||
@ -99,7 +99,7 @@ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
|
|||||||
<div className="mt-auto pt-2">
|
<div className="mt-auto pt-2">
|
||||||
<a
|
<a
|
||||||
href={service.ctaLink || '/kontakt'}
|
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}
|
{service.ctaText}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -12,104 +12,59 @@ const DEFAULT_PRICING = {
|
|||||||
adminFee: 200,
|
adminFee: 200,
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatKr = (n: number) => {
|
const formatKr = (n: number) => Math.round(n).toLocaleString('sv-SE') + ' kr'
|
||||||
// Format like "2 495 kr" with space as thousands separator
|
|
||||||
return Math.round(n).toLocaleString('sv-SE') + ' kr'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Toggle switch ───────────────────────────────────────────────────────────
|
|
||||||
function Toggle({ active, onToggle }: { active: boolean; onToggle: () => void }) {
|
function Toggle({ active, onToggle }: { active: boolean; onToggle: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
style={{ display: 'block', width: '3.5rem', height: '2rem', flexShrink: 0 }}
|
style={{ display: 'block', width: '3.5rem', height: '2rem', flexShrink: 0 }}
|
||||||
className={`relative rounded-full transition-all duration-300 ${
|
className={`relative rounded-full transition-all duration-300 ${active ? 'bg-fd-yellow' : 'bg-[#e2e8f0] dark:bg-white/20'}`}
|
||||||
active ? 'bg-fd-yellow' : 'bg-[#e2e8f0]'
|
|
||||||
}`}
|
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
>
|
>
|
||||||
<div
|
<div className={`absolute top-1 w-6 h-6 rounded-full bg-white shadow-sm transition-all duration-300 ${active ? 'left-7' : 'left-1'}`} />
|
||||||
className={`absolute top-1 w-6 h-6 rounded-full bg-white shadow-sm transition-all duration-300 ${
|
|
||||||
active ? 'left-7' : 'left-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Number stepper row ──────────────────────────────────────────────────────
|
|
||||||
function ResourceRow({
|
function ResourceRow({
|
||||||
label,
|
label, value, onChange, min = 0, max = 999, step = 1, unit, priceLabel, isDark,
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
min = 0,
|
|
||||||
max = 999,
|
|
||||||
step = 1,
|
|
||||||
unit,
|
|
||||||
priceLabel,
|
|
||||||
isDark,
|
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string; value: number; onChange: (v: number) => void
|
||||||
value: number
|
min?: number; max?: number; step?: number; unit: string; priceLabel: string; isDark: boolean
|
||||||
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 (
|
return (
|
||||||
<div
|
<div className="flex items-center justify-between py-4 border-b border-fd-navy/10 dark:border-white/10">
|
||||||
className="flex items-center justify-between py-4"
|
|
||||||
style={{ borderBottom: `1px solid ${borderColor}` }}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className={`font-joey-bold text-fd-body ${labelColor}`}>{label}</span>
|
<span className="font-joey-bold text-fd-body text-fd-navy dark:text-white">{label}</span>
|
||||||
<span className={`font-joey text-fd-small ${subColor}`}>{priceLabel}</span>
|
<span className="font-joey text-fd-small text-fd-navy/50 dark:text-white/50">{priceLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(Math.max(min, value - step))}
|
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}`}
|
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>
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(Math.max(min, Math.min(max, Number(e.target.value) || 0)))}
|
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' }}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(Math.min(max, value + step))}
|
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}`}
|
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>
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main component ──────────────────────────────────────────────────────────
|
|
||||||
export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps> = ({
|
export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps> = ({
|
||||||
heading = 'Virtuell server — kalkylator',
|
heading = 'Virtuell server — kalkylator',
|
||||||
description,
|
description,
|
||||||
@ -153,46 +108,35 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
|
|||||||
const ramCost = disc(ramGb * pricing.ramPerGb)
|
const ramCost = disc(ramGb * pricing.ramPerGb)
|
||||||
const ssdCost = disc(ssdGb * pricing.ssdPerGb)
|
const ssdCost = disc(ssdGb * pricing.ssdPerGb)
|
||||||
const hddCost = disc(hddGb * pricing.hddPerGb)
|
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 feeCost = showAdminFee ? feeAmount : 0
|
||||||
const extraCosts = (additionalServices ?? []).map((svc, i) =>
|
const extraCosts = (additionalServices ?? []).map((svc, i) => extraToggles[i] ? (svc.price ?? 0) : 0)
|
||||||
extraToggles[i] ? (svc.price ?? 0) : 0
|
|
||||||
)
|
|
||||||
const extraTotal = extraCosts.reduce((a, b) => a + b, 0)
|
const extraTotal = extraCosts.reduce((a, b) => a + b, 0)
|
||||||
const total = licenseCost + cpuCost + ramCost + ssdCost + hddCost + feeCost + extraTotal
|
const total = licenseCost + cpuCost + ramCost + ssdCost + hddCost + feeCost + extraTotal
|
||||||
return { licenseCost, cpuCost, ramCost, ssdCost, hddCost, feeCost, extraCosts, total }
|
return { licenseCost, cpuCost, ramCost, ssdCost, hddCost, feeCost, extraCosts, total }
|
||||||
}, [os, cpuCores, ramGb, ssdGb, hddGb, extraToggles, pricing, discount, feeAmount, showAdminFee, additionalServices])
|
}, [os, cpuCores, ramGb, ssdGb, hddGb, extraToggles, pricing, discount, feeAmount, showAdminFee, additionalServices])
|
||||||
|
|
||||||
const isDark = sectionBackground === 'navy'
|
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
|
// Use Tailwind classes for card styling instead of inline styles
|
||||||
const cardBg = isDark ? 'rgba(255,255,255,0.05)' : '#ffffff'
|
const cardClass = isDark
|
||||||
const cardBorder = isDark ? 'rgba(255,255,255,0.12)' : '#e2e8f0'
|
? 'bg-white/5 border-[6px] border-white/10 rounded-[clamp(28px,4vw,60px)]'
|
||||||
const cardStyle: React.CSSProperties = {
|
: 'bg-white border-[6px] border-[#e2e8f0] rounded-[clamp(28px,4vw,60px)] dark:bg-white/5 dark:border-white/10'
|
||||||
background: cardBg,
|
|
||||||
border: `6px solid ${cardBorder}`,
|
|
||||||
borderRadius: 'clamp(28px, 4vw, 60px)',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Colors
|
const headingColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
|
||||||
const headingColor = isDark ? 'text-white' : 'text-fd-navy'
|
const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60 dark:text-white/60'
|
||||||
const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60'
|
const sectionLabel = isDark ? 'text-white/40' : 'text-fd-navy/35 dark:text-white/40'
|
||||||
const sectionLabelColor = isDark ? 'text-white/40' : 'text-fd-navy/35'
|
const summaryLabel = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
|
||||||
const dividerColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(14,35,56,0.08)'
|
const categoryLabel = isDark ? 'text-white/40' : 'text-fd-navy/40 dark:text-white/40'
|
||||||
const summaryLabelColor = isDark ? 'text-white' : 'text-fd-navy'
|
const dividerClass = isDark ? 'border-white/10' : 'border-fd-navy/8 dark:border-white/10'
|
||||||
const summaryValueColor = isDark ? 'text-white' : 'text-fd-navy'
|
|
||||||
const categoryLabelColor = isDark ? 'text-white/40' : 'text-fd-navy/40'
|
|
||||||
|
|
||||||
// OS toggle
|
const osActiveClass = 'bg-fd-yellow text-fd-navy border-fd-yellow font-joey-bold'
|
||||||
const osActiveClass = 'bg-fd-yellow text-fd-navy border-fd-yellow font-joey-bold'
|
|
||||||
const osInactiveClass = isDark
|
const osInactiveClass = isDark
|
||||||
? 'bg-transparent text-white/70 border-white/20 hover:border-white/50 font-joey'
|
? '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)` : ''
|
const discStr = discount > 0 ? ` (${discountPercent}% rabatt)` : ''
|
||||||
|
|
||||||
// Summary rows
|
|
||||||
const baseRows = [
|
const baseRows = [
|
||||||
...(os === 'windows' ? [{ label: 'Licens (Windows)', cost: costs.licenseCost }] : []),
|
...(os === 'windows' ? [{ label: 'Licens (Windows)', cost: costs.licenseCost }] : []),
|
||||||
{ label: `CPU (${cpuCores} ${cpuCores === 1 ? 'kärna' : 'kärnor'})`, cost: costs.cpuCost },
|
{ 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}`}>
|
<section className={`fd-section ${bgClass}`}>
|
||||||
<div className="fd-container">
|
<div className="fd-container">
|
||||||
|
|
||||||
{/* Section heading */}
|
|
||||||
{(heading || description) && (
|
{(heading || description) && (
|
||||||
<div className="text-center mb-10 md:mb-14">
|
<div className="text-center mb-10 md:mb-14">
|
||||||
{heading && (
|
{heading && <h2 className={`font-joey-heavy text-fd-h1 mb-3 ${headingColor}`}>{heading}</h2>}
|
||||||
<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>}
|
||||||
)}
|
|
||||||
{description && (
|
|
||||||
<p className={`font-joey text-fd-body-lg max-w-[600px] mx-auto ${descColor}`}>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{discount > 0 && (
|
{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">
|
<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 på alla resurser
|
{discountPercent}% rabatt på 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">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6 lg:gap-8">
|
||||||
|
|
||||||
{/* ── Left: Config ── */}
|
{/* Left: Config */}
|
||||||
<div className="lg:col-span-3 p-7 md:p-9" style={cardStyle}>
|
<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 ${sectionLabel}`}>
|
||||||
<p className={`font-joey-medium text-fd-small tracking-widest uppercase mb-3 ${sectionLabelColor}`}>
|
|
||||||
Operativsystem
|
Operativsystem
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 mb-6">
|
<div className="flex gap-3 mb-6">
|
||||||
@ -246,16 +182,13 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
|
|||||||
key={opt}
|
key={opt}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOs(opt)}
|
onClick={() => setOs(opt)}
|
||||||
className={`flex-1 py-3 rounded-full text-fd-body border-2 transition-all duration-200 ${
|
className={`flex-1 py-3 rounded-full text-fd-body border-2 transition-all duration-200 ${os === opt ? osActiveClass : osInactiveClass}`}
|
||||||
os === opt ? osActiveClass : osInactiveClass
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{opt === 'linux' ? 'Linux (gratis)' : `Windows (+${pricing.windows}kr)`}
|
{opt === 'linux' ? 'Linux (gratis)' : `Windows (+${pricing.windows}kr)`}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resources */}
|
|
||||||
<div>
|
<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="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} />
|
<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} />
|
<ResourceRow label="HDD" value={hddGb} onChange={setHddGb} min={0} max={10000} step={100} unit="GB" priceLabel={`${pricing.hddPerGb} kr/GB${discStr}`} isDark={isDark} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin fee — fixed line, not customer-controlled */}
|
|
||||||
{showAdminFee && (
|
{showAdminFee && (
|
||||||
<div className="flex items-center justify-between py-4" style={{ borderBottom: `1px solid ${dividerColor}` }}>
|
<div className={`flex items-center justify-between py-4 border-b ${dividerClass}`}>
|
||||||
<span className={`font-joey-bold text-fd-body ${isDark ? 'text-white' : 'text-fd-navy'}`}>
|
<span className={`font-joey-bold text-fd-body ${summaryLabel}`}>Adminavgift</span>
|
||||||
Adminavgift
|
<span className={`font-joey-medium text-fd-body ${categoryLabel}`}>{formatKr(feeAmount)}</span>
|
||||||
</span>
|
|
||||||
<span className={`font-joey-medium text-fd-body ${isDark ? 'text-white/70' : 'text-fd-navy/70'}`}>
|
|
||||||
{formatKr(feeAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tillvalstjänster */}
|
|
||||||
{hasTillval && (
|
{hasTillval && (
|
||||||
<div className="mt-7">
|
<div className="mt-7">
|
||||||
<p className={`font-joey-medium text-fd-small tracking-widest uppercase mb-1 ${sectionLabelColor}`}>
|
<p className={`font-joey-medium text-fd-small tracking-widest uppercase mb-1 ${sectionLabel}`}>Tillvalstjänster</p>
|
||||||
Tillvalstjänster
|
|
||||||
</p>
|
|
||||||
{(additionalServices ?? []).map((svc, i) => (
|
{(additionalServices ?? []).map((svc, i) => (
|
||||||
<div
|
<div key={i} className={`flex items-center gap-4 py-4 border-b ${dividerClass}`}>
|
||||||
key={i}
|
|
||||||
className="flex items-center gap-4 py-4"
|
|
||||||
style={{ borderBottom: `1px solid ${dividerColor}` }}
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className={`font-joey-bold text-fd-body ${isDark ? 'text-white' : 'text-fd-navy'}`}>
|
<span className={`font-joey-bold text-fd-body ${summaryLabel}`}>{svc.label}</span>
|
||||||
{svc.label}
|
|
||||||
</span>
|
|
||||||
{svc.price != null && (
|
{svc.price != null && (
|
||||||
<span className={`block font-joey text-fd-small ${isDark ? 'text-white/50' : 'text-fd-navy/50'}`}>
|
<span className={`block font-joey text-fd-small ${categoryLabel}`}>{svc.price} kr/mån</span>
|
||||||
{svc.price} kr/mån
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 w-14">
|
<div className="flex-shrink-0 w-14">
|
||||||
@ -306,69 +223,44 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right: Summary ── */}
|
{/* Right: Summary */}
|
||||||
<div className="lg:col-span-2 p-7 md:p-9 flex flex-col" style={cardStyle}>
|
<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}`}>
|
<h3 className={`font-joey-heavy text-fd-h2 mb-6 ${headingColor}`}>Kostnadsöversikt</h3>
|
||||||
Kostnadsöversikt
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Base cost rows */}
|
|
||||||
<div className="flex-1 space-y-0">
|
<div className="flex-1 space-y-0">
|
||||||
{baseRows.map((row, i) => (
|
{baseRows.map((row, i) => (
|
||||||
<div
|
<div key={i} className={`flex justify-between items-center py-2.5 border-b ${dividerClass}`}>
|
||||||
key={i}
|
<span className={`font-joey text-fd-body ${summaryLabel}`}>{row.label}</span>
|
||||||
className="flex justify-between items-center py-2.5"
|
<span className={`font-joey-bold text-fd-body ${summaryLabel}`}>{formatKr(row.cost)}</span>
|
||||||
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Tillval section in summary */}
|
|
||||||
{tillvalRows.length > 0 && (
|
{tillvalRows.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="pt-4 pb-1">
|
<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>
|
</div>
|
||||||
{tillvalRows.map((row, i) => (
|
{tillvalRows.map((row, i) => (
|
||||||
<div
|
<div key={i} className={`flex justify-between items-center py-2.5 border-b ${dividerClass}`}>
|
||||||
key={i}
|
<span className={`font-joey text-fd-body ${summaryLabel}`}>{row.label}</span>
|
||||||
className="flex justify-between items-center py-2.5"
|
<span className={`font-joey-bold text-fd-body ${summaryLabel}`}>{formatKr(row.cost)}</span>
|
||||||
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Total */}
|
<div className={`mt-6 pt-5 border-t-2 ${dividerClass}`}>
|
||||||
<div className="mt-6 pt-5" style={{ borderTop: `2px solid ${dividerColor}` }}>
|
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
<span className={`font-joey text-fd-body ${summaryLabelColor}`}>Totalt per månad</span>
|
<span className={`font-joey text-fd-body ${summaryLabel}`}>Totalt per månad</span>
|
||||||
<span className={`font-joey-heavy text-fd-h1 ${isDark ? 'text-white' : 'text-fd-navy'}`}>
|
<span className={`font-joey-heavy text-fd-h1 ${headingColor}`}>{formatKr(costs.total)}</span>
|
||||||
{formatKr(costs.total)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* CTAs */}
|
|
||||||
<div className="mt-7 space-y-3">
|
<div className="mt-7 space-y-3">
|
||||||
<a href={orderCtaLink || '#'} className="fd-btn-primary w-full justify-center">
|
<a href={orderCtaLink || '#'} className="fd-btn-primary w-full justify-center">{orderCtaText}</a>
|
||||||
{orderCtaText}
|
<a href={contactCtaLink || '#'} className={`${isDark ? 'fd-btn-secondary-dark' : 'fd-btn-secondary dark:fd-btn-secondary-dark'} w-full justify-center`}>
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={contactCtaLink || '#'}
|
|
||||||
className={`${isDark ? 'fd-btn-secondary-dark' : 'fd-btn-secondary'} w-full justify-center`}
|
|
||||||
>
|
|
||||||
{contactCtaText}
|
{contactCtaText}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ import { FDCtaBannerBlockComponent } from './FDCtaBannerBlock/Component'
|
|||||||
import { FDTestimonialBlockComponent } from './FDTestimonialBlock/Component'
|
import { FDTestimonialBlockComponent } from './FDTestimonialBlock/Component'
|
||||||
import { FDTeamBlockComponent } from './FDTeamBlock/Component'
|
import { FDTeamBlockComponent } from './FDTeamBlock/Component'
|
||||||
|
|
||||||
const blockComponents = {
|
const blockComponents: Record<string, React.FC<any>> = {
|
||||||
formBlock: FormBlock,
|
formBlock: FormBlock,
|
||||||
fdHero: FDHeroBlockComponent,
|
fdHero: FDHeroBlockComponent,
|
||||||
fdCtaSideImage: FDCtaSideImageBlockComponent,
|
fdCtaSideImage: FDCtaSideImageBlockComponent,
|
||||||
@ -126,7 +126,6 @@ export const RenderBlocks: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isFullWidth ? '' : 'my-16'} key={index}>
|
<div className={isFullWidth ? '' : 'my-16'} key={index}>
|
||||||
{/* @ts-expect-error there may be some mismatch between the expected types here */}
|
|
||||||
<Block {...block} disableInnerContainer />
|
<Block {...block} disableInnerContainer />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user