Feature: header footer update links

This commit is contained in:
Jeffrey 2026-02-19 18:46:33 +01:00
parent cdf90d0c39
commit a410143ce7
8 changed files with 255 additions and 39 deletions

11
public/robots.txt Normal file
View File

@ -0,0 +1,11 @@
# *
User-agent: *
Disallow: /admin/*
# Host
Host: http://localhost:3000
# Sitemaps
Sitemap: http://localhost:3000/sitemap.xml
Sitemap: http://localhost:3000/pages-sitemap.xml
Sitemap: http://localhost:3000/posts-sitemap.xml

5
public/sitemap.xml Normal file
View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>http://localhost:3000/pages-sitemap.xml</loc></sitemap>
<sitemap><loc>http://localhost:3000/posts-sitemap.xml</loc></sitemap>
</sitemapindex>

View File

@ -1,16 +1,31 @@
import { getCachedGlobal } from '@/utilities/getGlobals' import { getCachedGlobal } from '@/utilities/getGlobals'
import Link from 'next/link' import Link from 'next/link'
import React from 'react' import React from 'react'
import type { Footer } from '@/payload-types' import type { Footer, Page } from '@/payload-types'
import { CMSLink } from '@/components/Link' import { CMSLink } from '@/components/Link'
import { Logo } from '@/components/Logo/Logo' import { Logo } from '@/components/Logo/Logo'
/** Resolves the logo href from the logoLink group field */
function resolveLogoHref(logoLink: Footer['logoLink']): string {
if (!logoLink) return '/'
if (logoLink.type === 'reference') {
const ref = logoLink.reference
if (ref && typeof ref === 'object' && 'value' in ref) {
const page = ref.value
return typeof page === 'object' && page !== null ? `/${(page as Page).slug}` : '/'
}
return '/'
}
return logoLink.url || '/'
}
export async function Footer() { export async function Footer() {
const footerData = await getCachedGlobal("footer", 1)() as unknown as Footer const footerData = await getCachedGlobal('footer', 1)() as unknown as Footer
const columns = footerData?.columns || [] const columns = footerData?.columns || []
const navItems = footerData?.navItems || [] const navItems = footerData?.navItems || []
const hasColumns = columns.length > 0 const hasColumns = columns.length > 0
const logoHref = resolveLogoHref(footerData?.logoLink)
const bottomLeft = (footerData?.bottomLeftText || '© {year} Fiber Direkt. Alla rättigheter förbehållna.').replace('{year}', new Date().getFullYear().toString()) const bottomLeft = (footerData?.bottomLeftText || '© {year} Fiber Direkt. Alla rättigheter förbehållna.').replace('{year}', new Date().getFullYear().toString())
const bottomRight = footerData?.bottomRightText || 'Svenskt datacenter · ISO 27001 · ISO 14001' const bottomRight = footerData?.bottomRightText || 'Svenskt datacenter · ISO 27001 · ISO 14001'
@ -21,7 +36,7 @@ export async function Footer() {
<div className="flex flex-col lg:flex-row gap-10 lg:gap-16"> <div className="flex flex-col lg:flex-row gap-10 lg:gap-16">
{/* Logo column */} {/* Logo column */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Link className="flex items-center" href="/"> <Link className="flex items-center" href={logoHref}>
<Logo variant="white" className="max-w-[120px]" /> <Logo variant="white" className="max-w-[120px]" />
</Link> </Link>
</div> </div>

View File

@ -8,6 +8,49 @@ export const Footer: GlobalConfig = {
read: () => true, read: () => true,
}, },
fields: [ fields: [
// ── Logo link ─────────────────────────────────────────────────────────────
{
name: 'logoLink',
type: 'group',
label: 'Logotyplänk',
admin: {
description: 'Vart man hamnar när man klickar på logotypen i footern. Standard är startsidan (/).',
},
fields: [
{
name: 'type',
type: 'radio',
label: 'Länktyp',
options: [
{ label: 'Intern sida', value: 'reference' },
{ label: 'Anpassad URL', value: 'custom' },
],
defaultValue: 'custom',
admin: { layout: 'horizontal' },
},
{
name: 'reference',
type: 'relationship',
label: 'Intern sida',
relationTo: ['pages'] as const,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
type: 'text',
label: 'URL',
defaultValue: '/',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
description: 'T.ex. / eller /startsida',
},
},
],
},
// ── Sitemap columns ───────────────────────────────────────────────────────
{ {
name: 'columns', name: 'columns',
type: 'array', type: 'array',
@ -47,6 +90,8 @@ export const Footer: GlobalConfig = {
}, },
], ],
}, },
// ── Simple nav (legacy) ───────────────────────────────────────────────────
{ {
name: 'navItems', name: 'navItems',
type: 'array', type: 'array',
@ -62,6 +107,8 @@ export const Footer: GlobalConfig = {
], ],
maxRows: 6, maxRows: 6,
}, },
// ── Bottom text ───────────────────────────────────────────────────────────
{ {
name: 'bottomLeftText', name: 'bottomLeftText',
type: 'text', type: 'text',

View File

@ -4,7 +4,7 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import type { Header } from '@/payload-types' import type { Header, Page } from '@/payload-types'
import { Logo } from '@/components/Logo/Logo' import { Logo } from '@/components/Logo/Logo'
import { HeaderNav } from './Nav' import { HeaderNav } from './Nav'
@ -13,6 +13,20 @@ interface HeaderClientProps {
data: Header data: Header
} }
/** Resolves the logo href from the logoLink group field */
function resolveLogoHref(logoLink: Header['logoLink']): string {
if (!logoLink) return '/'
if (logoLink.type === 'reference') {
const ref = logoLink.reference
if (ref && typeof ref === 'object' && 'value' in ref) {
const page = ref.value
return typeof page === 'object' && page !== null ? `/${(page as Page).slug}` : '/'
}
return '/'
}
return logoLink.url || '/'
}
export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => { export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {
/* Storing the value in a useState to avoid hydration errors */ /* Storing the value in a useState to avoid hydration errors */
const [theme, setTheme] = useState<string | null>(null) const [theme, setTheme] = useState<string | null>(null)
@ -29,13 +43,15 @@ export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [headerTheme]) }, [headerTheme])
const logoHref = resolveLogoHref(data?.logoLink)
return ( return (
<header <header
className="relative z-20 w-full bg-white" className="relative z-20 w-full bg-white"
{...(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="/"> <Link href={logoHref}>
<Logo loading="eager" priority="high" variant="blue" className="max-w-[80px] md:max-w-[100px]" /> <Logo loading="eager" priority="high" variant="blue" className="max-w-[80px] md:max-w-[100px]" />
</Link> </Link>
<HeaderNav data={data} /> <HeaderNav data={data} />

View File

@ -1,22 +1,28 @@
'use client' 'use client'
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import type { Header as HeaderType } from '@/payload-types' import type { Header as HeaderType, Page } from '@/payload-types'
import Link from 'next/link' 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 ─────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
type NavChild = { type NavChild = NonNullable<NonNullable<NonNullable<HeaderType['navItems']>[number]['children']>[number]>
label: string type NavItem = NonNullable<HeaderType['navItems']>[number]
url: string
group?: string | null
}
type NavItem = { // ─── Resolve href from reference or custom url ────────────────────────────────
label: string function resolveHref(item: {
type?: string | null
url?: string | null url?: string | null
megaMenu?: boolean | null reference?: { relationTo?: string; value?: number | Page | null } | null
children?: NavChild[] | null }): string {
if (item.type === 'reference' && item.reference?.value) {
const page = item.reference.value
if (typeof page === 'object' && page !== null) {
return page.slug === 'home' || page.slug === 'startsida' ? '/' : `/${(page as Page).slug}`
}
return '#'
}
return item.url || '#'
} }
// ─── Swedish Flag ───────────────────────────────────────────────────────────── // ─── Swedish Flag ─────────────────────────────────────────────────────────────
@ -81,14 +87,14 @@ const MegaMenuPanel: React.FC<{
return ( return (
<> <>
{/* Blur backdrop — covers page below, click to close */} {/* Blur backdrop */}
<div <div
className="fixed inset-0 z-30" className="fixed inset-0 z-30"
style={{ top, backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', backgroundColor: 'rgba(14, 35, 56, 0.08)' }} style={{ top, backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', backgroundColor: 'rgba(14, 35, 56, 0.08)' }}
onClick={onClose} onClick={onClose}
/> />
{/* Panel — same white as header, no top border so they read as one */} {/* Panel */}
<div <div
ref={panelRef} ref={panelRef}
className="fixed left-0 right-0 z-40" className="fixed left-0 right-0 z-40"
@ -117,7 +123,7 @@ const MegaMenuPanel: React.FC<{
{col.links.map((link, li) => ( {col.links.map((link, li) => (
<li key={li}> <li key={li}>
<Link <Link
href={link.url} 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 hover:text-fd-yellow ${
col.heading col.heading
@ -237,7 +243,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
{item.children!.map((child, j) => ( {item.children!.map((child, j) => (
<Link <Link
key={j} key={j}
href={child.url} 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 hover:bg-fd-yellow/20 transition-colors text-sm"
> >
@ -250,7 +256,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
) : ( ) : (
<Link <Link
key={i} key={i}
href={item.url || '#'} 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 hover:text-fd-yellow transition-colors text-base"
> >
{item.label} {item.label}
@ -326,7 +332,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
{item.children!.map((child, j) => ( {item.children!.map((child, j) => (
<Link <Link
key={j} key={j}
href={child.url} href={resolveHref(child)}
onClick={closeMobile} onClick={closeMobile}
className="block pl-4 py-3 text-fd-yellow font-joey text-lg border-b border-white/5 hover:text-white transition-colors" className="block pl-4 py-3 text-fd-yellow font-joey text-lg border-b border-white/5 hover:text-white transition-colors"
> >
@ -338,7 +344,7 @@ export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
) : ( ) : (
<Link <Link
key={i} key={i}
href={item.url || '#'} href={resolveHref(item)}
onClick={closeMobile} onClick={closeMobile}
className="py-4 text-white font-joey-medium text-xl border-b border-white/10 hover:text-fd-yellow transition-colors block" className="py-4 text-white font-joey-medium text-xl border-b border-white/10 hover:text-fd-yellow transition-colors block"
> >

View File

@ -7,6 +7,49 @@ export const Header: GlobalConfig = {
read: () => true, read: () => true,
}, },
fields: [ fields: [
// ── Logo link ─────────────────────────────────────────────────────────────
{
name: 'logoLink',
type: 'group',
label: 'Logotyplänk',
admin: {
description: 'Vart man hamnar när man klickar på logotypen. Standard är startsidan (/).',
},
fields: [
{
name: 'type',
type: 'radio',
label: 'Länktyp',
options: [
{ label: 'Intern sida', value: 'reference' },
{ label: 'Anpassad URL', value: 'custom' },
],
defaultValue: 'custom',
admin: { layout: 'horizontal' },
},
{
name: 'reference',
type: 'relationship',
label: 'Intern sida',
relationTo: ['pages'] as const,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
type: 'text',
label: 'URL',
defaultValue: '/',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
description: 'T.ex. / eller /startsida',
},
},
],
},
// ── Nav items ─────────────────────────────────────────────────────────────
{ {
name: 'navItems', name: 'navItems',
type: 'array', type: 'array',
@ -22,13 +65,43 @@ export const Header: GlobalConfig = {
name: 'label', name: 'label',
type: 'text', type: 'text',
required: true, required: true,
label: 'Label', label: 'Etikett',
},
// ── Link type toggle ────────────────────────────────────────────────
{
name: 'type',
type: 'radio',
label: 'Länktyp',
options: [
{ label: 'Intern sida', value: 'reference' },
{ label: 'Anpassad URL', value: 'custom' },
],
defaultValue: 'custom',
admin: {
layout: 'horizontal',
description:
'Lämna tomt / ignoreras om detta objekt har en undermeny.',
},
},
{
name: 'reference',
type: 'relationship',
label: 'Intern sida',
relationTo: ['pages'] as const,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
}, },
{ {
name: 'url', name: 'url',
type: 'text', type: 'text',
label: 'URL (leave empty if this item has a submenu)', label: 'URL',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
description: 'T.ex. /bredband eller https://extern-sida.se',
},
}, },
// ── Mega menu toggle ────────────────────────────────────────────────
{ {
name: 'megaMenu', name: 'megaMenu',
type: 'checkbox', type: 'checkbox',
@ -36,17 +109,18 @@ export const Header: GlobalConfig = {
defaultValue: false, defaultValue: false,
admin: { admin: {
description: description:
'When checked, hovering/clicking this item opens a full-width mega menu instead of a small dropdown card.', 'När ikryssad öppnar hover/klick ett fullbredds mega menu istället för en liten dropdown.',
}, },
}, },
// ── Children ────────────────────────────────────────────────────────
{ {
name: 'children', name: 'children',
type: 'array', type: 'array',
label: 'Submenu links', label: 'Undermenylänkar',
maxRows: 16, maxRows: 16,
admin: { admin: {
description: description:
'Add submenu links. If these exist, the parent URL is ignored. Use "Group" to create columns in the mega menu.', 'Lägg till undermenylänkar. Om dessa finns ignoreras förälderlänken. Använd "Grupp" för att skapa kolumner i mega menu.',
initCollapsed: true, initCollapsed: true,
}, },
fields: [ fields: [
@ -54,21 +128,43 @@ export const Header: GlobalConfig = {
name: 'label', name: 'label',
type: 'text', type: 'text',
required: true, required: true,
label: 'Label', label: 'Etikett',
},
{
name: 'type',
type: 'radio',
label: 'Länktyp',
options: [
{ label: 'Intern sida', value: 'reference' },
{ label: 'Anpassad URL', value: 'custom' },
],
defaultValue: 'custom',
admin: { layout: 'horizontal' },
},
{
name: 'reference',
type: 'relationship',
label: 'Intern sida',
relationTo: ['pages'] as const,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
}, },
{ {
name: 'url', name: 'url',
type: 'text', type: 'text',
required: true,
label: 'URL', label: 'URL',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
}, },
{ {
name: 'group', name: 'group',
type: 'text', type: 'text',
label: 'Group (optional)', label: 'Grupp (valfri)',
admin: { admin: {
description: description:
'Links with the same group name appear together in a separate column in the mega menu. Leave empty for the main column.', 'Länkar med samma gruppnamn visas tillsammans i en separat kolumn i mega menu. Lämna tomt för huvudkolumnen.',
}, },
}, },
], ],

View File

@ -46,8 +46,8 @@ import {
PreviewField, PreviewField,
} from '@payloadcms/plugin-seo/fields' } from '@payloadcms/plugin-seo/fields'
// ── Slug generator — handles Swedish characters ──────────────────────────── // ── Slug generator — handles Swedish characters ────────────────────────────────
const generateSlug = (value: string): string => const generateSlugFromTitle = (value: string): string =>
value value
.toLowerCase() .toLowerCase()
.trim() .trim()
@ -131,7 +131,7 @@ export const Pages: CollectionConfig<'pages'> = {
FDVideoBlock, FDVideoBlock,
FDCtaBannerBlock, FDCtaBannerBlock,
FDTestimonialBlock, FDTestimonialBlock,
FDTeamBlock FDTeamBlock,
], ],
required: true, required: true,
admin: { admin: {
@ -173,7 +173,7 @@ export const Pages: CollectionConfig<'pages'> = {
position: 'sidebar', position: 'sidebar',
}, },
}, },
// ── Slug ────────────────────────────────────────────────────────────── // ── Slug ─────────────────────────────────────────────────────────────────
{ {
name: 'slug', name: 'slug',
type: 'text', type: 'text',
@ -181,14 +181,34 @@ export const Pages: CollectionConfig<'pages'> = {
index: true, index: true,
admin: { admin: {
position: 'sidebar', position: 'sidebar',
description: 'Genereras automatiskt från titeln.', description:
'Sidans URL-slug, t.ex. "om-oss" → fiberdirekt.se/om-oss. Kan alltid redigeras manuellt. Bocka i "Generera slug" nedan för att skriva om automatiskt från titeln.',
}, },
hooks: { hooks: {
beforeChange: [ beforeChange: [
({ data }) => generateSlug(data?.title || ''), ({ data, siblingData, value }) => {
// Only auto-generate if the checkbox is explicitly checked
if (siblingData?.generateSlug) {
return generateSlugFromTitle(data?.title || siblingData?.title || '')
}
// Otherwise keep whatever value the editor typed (or the existing value)
return value
},
], ],
}, },
}, // ← slug object closes here, then straight into ], },
// ── Auto-generate toggle ──────────────────────────────────────────────────
{
name: 'generateSlug',
type: 'checkbox',
label: 'Generera slug automatiskt från titeln',
defaultValue: true,
admin: {
position: 'sidebar',
description:
'När ikryssad skrivs slugen om från titeln vid varje sparning. Avbocka för att låsa slugen och redigera den manuellt.',
},
},
], ],
hooks: { hooks: {
afterChange: [revalidatePage], afterChange: [revalidatePage],