fix: cast SEO plugin fields override as Field[] to resolve

This commit is contained in:
Jeffrey 2026-02-19 19:55:08 +01:00
parent a410143ce7
commit 1ab4e41c00
20 changed files with 56844 additions and 177 deletions

View File

@ -1,6 +1,4 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import config from '@payload-config'
import { AnnouncementBarComponent } from '@/components/AnnouncementBar/AnnouncementBar'
import { cn } from '@/utilities/ui'
import { GeistMono } from 'geist/font/mono'
@ -21,27 +19,14 @@ import { PopupAnnouncementComponent } from '@/globals/PopupAnnouncement/Componen
import './globals.css'
import { getServerSideURL } from '@/utilities/getURL'
async function AnnouncementBarWrapper() {
const payload = await getPayload({ config })
const announcement = await payload.findGlobal({ slug: 'announcement-bar' })
if (!announcement?.enabled) return null
return (
<AnnouncementBarComponent
text={announcement.text}
buttonLabel={announcement.buttonLabel}
buttonUrl={announcement.buttonUrl}
dismissible={announcement.dismissible}
backgroundColor={announcement.backgroundColor as any}
/>
)
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { isEnabled } = await draftMode()
const [siteSettings, popupData] = await Promise.all([
// All three globals fetched once via the ISR cache — no direct DB hits downstream
const [siteSettings, popupData, announcement] = await Promise.all([
getCachedGlobal('site-settings', 1)() as any,
getCachedGlobal('popup-announcement', 1)() as any,
getCachedGlobal('announcement-bar', 1)() as any,
])
const cc = siteSettings?.cookieConsent
@ -66,12 +51,20 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link href="/favicon.svg" rel="icon" type="image/svg+xml" />
<meta name="theme-color" content="#ffffff" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<HeadInjection />
<HeadInjection siteSettings={siteSettings} />
</head>
<body>
<Providers>
<AdminBar adminBarProps={{ preview: isEnabled }} />
<AnnouncementBarWrapper />
{announcement?.enabled && (
<AnnouncementBarComponent
text={announcement.text}
buttonLabel={announcement.buttonLabel}
buttonLink={announcement.buttonLink}
dismissible={announcement.dismissible}
backgroundColor={announcement.backgroundColor as any}
/>
)}
<Header />
{children}
<Footer />
@ -84,7 +77,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
/>
)}
{matomoEnabled && <MatomoScript code={matomoCode} />}
<FooterInjection />
<FooterInjection siteSettings={siteSettings} />
</Providers>
</body>
</html>

View File

@ -2,11 +2,7 @@ import React, { Fragment } from 'react'
import type { Page } from '@/payload-types'
import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component'
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
import { ContentBlock } from '@/blocks/Content/Component'
import { FormBlock } from '@/blocks/Form/Component'
import { MediaBlock } from '@/blocks/MediaBlock/Component'
import { FDHeroBlockComponent } from '@/blocks/FDHeroBlock/Component'
import { FDCtaSideImageBlockComponent } from '@/blocks/FDCtaSideImageBlock/Component'
import { FDFeatureAnnouncementBlockComponent } from '@/blocks/FDFeatureAnnouncementBlock/Component'
@ -40,11 +36,7 @@ import { FDTestimonialBlockComponent } from './FDTestimonialBlock/Component'
import { FDTeamBlockComponent } from './FDTeamBlock/Component'
const blockComponents = {
archive: ArchiveBlock,
content: ContentBlock,
cta: CallToActionBlock,
formBlock: FormBlock,
mediaBlock: MediaBlock,
fdHero: FDHeroBlockComponent,
fdCtaSideImage: FDCtaSideImageBlockComponent,
fdFeatureAnnouncement: FDFeatureAnnouncementBlockComponent,

View File

@ -222,14 +222,34 @@ export const Posts: CollectionConfig<'posts'> = {
index: true,
admin: {
position: 'sidebar',
description: 'Genereras automatiskt från titeln.',
description:
'Inläggets URL-slug, t.ex. "mitt-inlagg" → fiberdirekt.se/posts/mitt-inlagg. Kan alltid redigeras manuellt. Bocka i "Generera slug" nedan för att skriva om automatiskt från titeln.',
},
hooks: {
beforeChange: [
({ data }) => generateSlug(data?.title || ''),
({ data, siblingData, value }) => {
// Only auto-generate when the checkbox is explicitly checked
if (siblingData?.generateSlug) {
return generateSlug(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: {
afterChange: [revalidatePost],

View File

@ -1,18 +1,34 @@
'use client'
import React, { useState, useEffect } from 'react'
type LinkGroup = {
type?: 'reference' | 'custom' | null
reference?: { slug?: string | null; relationTo?: string } | null
url?: string | null
newTab?: boolean | null
}
type Props = {
text?: string | null
buttonLabel?: string | null
buttonUrl?: string | null
buttonLink?: LinkGroup | null
dismissible?: boolean | null
backgroundColor?: 'yellow' | 'navy' | 'mint' | null
}
function resolveUrl(link?: LinkGroup | null): string | null {
if (!link) return null
if (link.type === 'reference' && link.reference) {
const slug = link.reference.slug
return slug ? `/${slug}` : null
}
return link.url ?? null
}
export const AnnouncementBarComponent: React.FC<Props> = ({
text,
buttonLabel,
buttonUrl,
buttonLink,
dismissible = true,
backgroundColor = 'yellow',
}) => {
@ -38,11 +54,22 @@ export const AnnouncementBarComponent: React.FC<Props> = ({
setDismissed(true)
}
const href = resolveUrl(buttonLink)
const newTab = buttonLink?.newTab ?? false
return (
<div className={`w-full px-4 py-2 flex items-center justify-center gap-4 text-sm font-joey relative ${bgClass}`} role="status">
<div
className={`w-full px-4 py-2 flex items-center justify-center gap-4 text-sm font-joey relative ${bgClass}`}
role="status"
>
<span>{text}</span>
{buttonLabel && buttonUrl && (
<a href={buttonUrl} className="underline font-joey-bold hover:opacity-70 transition-opacity">
{buttonLabel && href && (
<a
href={href}
target={newTab ? '_blank' : undefined}
rel={newTab ? 'noopener noreferrer' : undefined}
className="underline font-joey-bold hover:opacity-70 transition-opacity"
>
{buttonLabel}
</a>
)}

View File

@ -1,97 +1,32 @@
import React from 'react'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
type SiteSettings = {
headerCodeInjection?: { enabled?: boolean; code?: string } | null
footerCodeInjection?: { enabled?: boolean; code?: string } | null
}
/**
* HeadInjection Server Component
*
* Fetches the SiteSettings global and renders the header code injection
* as raw HTML inside the <head> of your layout.
*
* Usage in app/layout.tsx:
*
* ```tsx
* import { HeadInjection } from '@/components/HeadInjection'
*
* export default async function RootLayout({ children }) {
* return (
* <html lang="sv">
* <head>
* <HeadInjection />
* </head>
* <body>{children}</body>
* </html>
* )
* }
* ```
* HeadInjection accepts already-fetched siteSettings as a prop.
* Data is fetched once in layout.tsx via getCachedGlobal and passed down,
* eliminating the uncached DB hit that was here previously.
*/
export async function HeadInjection() {
let headerCode = ''
try {
const payload = await getPayload({ config: configPromise })
const settings = await payload.findGlobal({ slug: 'site-settings' })
if (
settings?.headerCodeInjection?.enabled &&
settings?.headerCodeInjection?.code
) {
headerCode = settings.headerCodeInjection.code
}
} catch (error) {
// Silently fail — don't break the page if settings can't be fetched
console.error('HeadInjection: Failed to fetch site settings', error)
}
export function HeadInjection({ siteSettings }: { siteSettings: SiteSettings }) {
const headerCode = siteSettings?.headerCodeInjection?.enabled
? (siteSettings.headerCodeInjection.code ?? '')
: ''
if (!headerCode) return null
return <div dangerouslySetInnerHTML={{ __html: headerCode }} />
}
/**
* FooterInjection Server Component
*
* Same as HeadInjection but for the footer code injection.
* Place just before </body> in your layout.
*
* Usage in app/layout.tsx:
*
* ```tsx
* import { FooterInjection } from '@/components/HeadInjection'
*
* export default async function RootLayout({ children }) {
* return (
* <html lang="sv">
* <head>
* <HeadInjection />
* </head>
* <body>
* {children}
* <FooterInjection />
* </body>
* </html>
* )
* }
* ```
* FooterInjection same pattern as HeadInjection.
*/
export async function FooterInjection() {
let footerCode = ''
try {
const payload = await getPayload({ config: configPromise })
const settings = await payload.findGlobal({ slug: 'site-settings' })
if (
settings?.footerCodeInjection?.enabled &&
settings?.footerCodeInjection?.code
) {
footerCode = settings.footerCodeInjection.code
}
} catch (error) {
console.error('FooterInjection: Failed to fetch site settings', error)
}
export function FooterInjection({ siteSettings }: { siteSettings: SiteSettings }) {
const footerCode = siteSettings?.footerCodeInjection?.enabled
? (siteSettings.footerCodeInjection.code ?? '')
: ''
if (!footerCode) return null
return <div dangerouslySetInnerHTML={{ __html: footerCode }} />
}

View File

@ -1,5 +1,6 @@
import type { GlobalConfig } from 'payload'
import { adminOnly } from '../access/adminOnly'
import { revalidateAnnouncementBar } from './hooks/revalidateAnnouncementBar'
export const AnnouncementBar: GlobalConfig = {
slug: 'announcement-bar',
@ -11,6 +12,9 @@ export const AnnouncementBar: GlobalConfig = {
read: () => true,
update: adminOnly,
},
hooks: {
afterChange: [revalidateAnnouncementBar],
},
fields: [
{
name: 'enabled',
@ -35,12 +39,55 @@ export const AnnouncementBar: GlobalConfig = {
},
},
{
name: 'buttonUrl',
type: 'text',
label: 'Knapp-URL (valfri)',
name: 'buttonLink',
type: 'group',
label: 'Knapp-länk (valfri)',
admin: {
condition: (_, siblingData) => Boolean(siblingData?.enabled),
hideGutter: true,
},
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'radio',
label: 'Länktyp',
defaultValue: 'custom',
options: [
{ label: 'Intern sida', value: 'reference' },
{ label: 'Egen URL', value: 'custom' },
],
admin: { layout: 'horizontal', width: '50%' },
},
{
name: 'newTab',
type: 'checkbox',
label: 'Öppna i ny flik',
admin: { width: '50%', style: { alignSelf: 'flex-end' } },
},
],
},
{
name: 'reference',
type: 'relationship',
label: 'Sida',
relationTo: ['pages', 'posts'],
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
type: 'text',
label: 'URL',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
description: 'T.ex. /bredband eller https://example.com',
},
},
],
},
{
name: 'dismissible',

View File

@ -1,7 +1,17 @@
'use client'
import React, { useState, useEffect } from 'react'
import type { Media } from '@/payload-types'
import type { Media, Page } from '@/payload-types'
type LinkGroup = {
type?: 'reference' | 'custom' | null
reference?: Page | string | number | null
url?: string | null
newTab?: boolean | null
}
// specificPages is now a relationship array — each item is a populated Page or just an id
type SpecificPage = Page | string | number
interface PopupAnnouncementProps {
enabled?: boolean
@ -9,22 +19,43 @@ interface PopupAnnouncementProps {
subheading?: string
body?: string
ctaText?: string
ctaLink?: string
image?: Media | string
ctaLink?: LinkGroup | null
image?: Media | string | null
badgeText?: string
theme?: 'light' | 'dark'
showOnPages?: 'all' | 'home' | 'specific'
specificPages?: string
specificPages?: SpecificPage[] | null
dismissDays?: number
}
function resolveUrl(link?: LinkGroup | null): string | null {
if (!link) return null
if (link.type === 'reference' && link.reference) {
if (typeof link.reference === 'object' && 'slug' in link.reference) {
return link.reference.slug ? `/${link.reference.slug}` : null
}
return null
}
return link.url ?? null
}
function resolveSpecificPageSlugs(pages?: SpecificPage[] | null): string[] {
if (!pages) return []
return pages.flatMap((p) => {
if (typeof p === 'object' && p !== null && 'slug' in p && typeof p.slug === 'string') {
return [`/${p.slug}`]
}
return []
})
}
export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
enabled,
heading,
subheading,
body,
ctaText = 'Läs mer',
ctaLink = '/',
ctaLink,
image,
badgeText,
theme = 'light',
@ -50,9 +81,9 @@ export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
// Check page targeting
const path = window.location.pathname
if (showOnPages === 'home' && path !== '/') return
if (showOnPages === 'specific' && specificPages) {
const pages = specificPages.split(',').map(p => p.trim())
if (!pages.includes(path)) return
if (showOnPages === 'specific') {
const slugs = resolveSpecificPageSlugs(specificPages)
if (!slugs.includes(path)) return
}
// Show after short delay
@ -68,8 +99,10 @@ export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
if (!isVisible || !enabled) return null
const media = typeof image === 'object' ? image : null
const media = typeof image === 'object' && image !== null ? (image as Media) : null
const isDark = theme === 'dark'
const href = resolveUrl(ctaLink)
const newTab = ctaLink?.newTab ?? false
return (
<>
@ -80,7 +113,9 @@ export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
/>
{/* Popup */}
<div className={`fixed inset-0 z-[9999] flex items-center justify-center p-4 md:p-6 pointer-events-none transition-all duration-300 ${isClosing ? 'opacity-0 scale-95' : 'opacity-100 scale-100'}`}>
<div
className={`fixed inset-0 z-[9999] flex items-center justify-center p-4 md:p-6 pointer-events-none transition-all duration-300 ${isClosing ? 'opacity-0 scale-95' : 'opacity-100 scale-100'}`}
>
<div
className={`pointer-events-auto relative w-full max-w-[720px] rounded-2xl overflow-hidden shadow-2xl flex flex-col md:flex-row ${isDark ? 'bg-fd-navy' : 'bg-white'}`}
onClick={(e) => e.stopPropagation()}
@ -91,13 +126,17 @@ export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
className={`absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full transition-all duration-200 z-10 ${isDark ? 'text-white/60 hover:text-white hover:bg-white/10' : 'text-gray-400 hover:text-gray-800 hover:bg-gray-100'}`}
aria-label="Stäng"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/* Content */}
<div className="flex-1 p-6 md:p-8 flex flex-col justify-center">
{badgeText && (
<span className={`inline-block self-start px-3 py-1 rounded-full text-fd-xs font-joey-bold uppercase tracking-wider mb-4 ${isDark ? 'bg-fd-yellow/20 text-fd-yellow' : 'bg-fd-navy/10 text-fd-navy'}`}>
<span
className={`inline-block self-start px-3 py-1 rounded-full text-fd-xs font-joey-bold uppercase tracking-wider mb-4 ${isDark ? 'bg-fd-yellow/20 text-fd-yellow' : 'bg-fd-navy/10 text-fd-navy'}`}
>
{badgeText}
</span>
)}
@ -116,8 +155,13 @@ export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
{body}
</p>
)}
{ctaText && (
<a href={ctaLink || '#'} className="fd-btn-primary self-start">
{ctaText && href && (
<a
href={href}
target={newTab ? '_blank' : undefined}
rel={newTab ? 'noopener noreferrer' : undefined}
className="fd-btn-primary self-start"
>
{ctaText}
</a>
)}

View File

@ -1,12 +1,21 @@
import type { GlobalConfig } from 'payload'
import { adminOnly } from '../../access/adminOnly'
import { revalidatePopup } from './hooks/revalidatePopup'
export const PopupAnnouncement: GlobalConfig = {
slug: 'popup-announcement',
label: 'Popup-meddelande',
admin: {
group: 'Globala inställningar',
description: 'Ett popup-fönster som visas för besökare. Kan begränsas till specifika sidor.',
},
access: {
read: () => true,
update: adminOnly,
},
hooks: {
afterChange: [revalidatePopup],
},
fields: [
{
name: 'enabled',
@ -49,19 +58,62 @@ export const PopupAnnouncement: GlobalConfig = {
condition: (data) => Boolean(data?.enabled),
},
},
// ── CTA Link — internal page picker or custom URL ──────────────────────
{
name: 'ctaLink',
type: 'text',
type: 'group',
label: 'CTA-länk',
defaultValue: '/',
admin: {
condition: (data) => Boolean(data?.enabled),
hideGutter: true,
},
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'radio',
label: 'Länktyp',
defaultValue: 'custom',
options: [
{ label: 'Intern sida', value: 'reference' },
{ label: 'Egen URL', value: 'custom' },
],
admin: { layout: 'horizontal', width: '50%' },
},
{
name: 'newTab',
type: 'checkbox',
label: 'Öppna i ny flik',
admin: { width: '50%', style: { alignSelf: 'flex-end' } },
},
],
},
{
name: 'reference',
type: 'relationship',
label: 'Sida',
relationTo: ['pages', 'posts'] as const,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
type: 'text',
label: 'URL',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
description: 'T.ex. /bredband eller https://example.com',
},
},
],
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
relationTo: 'media' as const,
label: 'Bild (valfri, visas till höger)',
admin: {
condition: (data) => Boolean(data?.enabled),
@ -104,12 +156,15 @@ export const PopupAnnouncement: GlobalConfig = {
condition: (data) => Boolean(data?.enabled),
},
},
// ── Specific pages — relationship instead of fragile comma-separated text ──
{
name: 'specificPages',
type: 'text',
label: 'Sökvägar (komma-separerade)',
type: 'relationship',
label: 'Välj sidor',
relationTo: 'pages' as const,
hasMany: true,
admin: {
description: 'T.ex. /bredband, /cloud, /kontakt',
description: 'Välj exakt vilka sidor popupen ska visas på.',
condition: (data) => data?.showOnPages === 'specific',
},
},
@ -119,7 +174,7 @@ export const PopupAnnouncement: GlobalConfig = {
label: 'Dölj i antal dagar efter stängning',
defaultValue: 7,
admin: {
description: 'Hur många dagar popupen ska döljas efter att besökaren stänger den',
description: 'Hur många dagar popupen ska döljas efter att besökaren stänger den.',
condition: (data) => Boolean(data?.enabled),
},
},

View File

@ -1,13 +1,21 @@
import type { GlobalConfig } from 'payload'
import { adminOnly } from '../access/adminOnly'
import { revalidateSiteSettings } from './hooks/revalidateSiteSettings'
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
label: 'Webbplatsinställningar',
admin: {
group: 'Globala inställningar',
description: 'Kodinjektion, cookies, analytics och globala webbplatsinställningar.',
},
access: {
update: adminOnly,
read: () => true,
},
hooks: {
afterChange: [revalidateSiteSettings],
},
fields: [
{
name: 'headerCodeInjection',

View File

@ -0,0 +1,7 @@
import type { GlobalAfterChangeHook } from 'payload'
import { revalidateTag } from 'next/cache'
export const revalidateAnnouncementBar: GlobalAfterChangeHook = ({ req: { payload } }) => {
payload.logger.info({ msg: 'Revalidating announcement bar' })
;(revalidateTag as any)('global_announcement-bar')
}

View File

@ -0,0 +1,7 @@
import type { GlobalAfterChangeHook } from 'payload'
import { revalidateTag } from 'next/cache'
export const revalidateSiteSettings: GlobalAfterChangeHook = ({ req: { payload } }) => {
payload.logger.info({ msg: 'Revalidating site settings' })
;(revalidateTag as any)('global_site-settings')
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_header_nav_items_children_type" AS ENUM('reference', 'custom');
CREATE TYPE "public"."enum_header_nav_items_type" AS ENUM('reference', 'custom');
CREATE TYPE "public"."enum_header_logo_link_type" AS ENUM('reference', 'custom');
CREATE TYPE "public"."enum_footer_logo_link_type" AS ENUM('reference', 'custom');
CREATE TABLE "header_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"pages_id" integer
);
ALTER TABLE "header_nav_items_children" ALTER COLUMN "url" DROP NOT NULL;
ALTER TABLE "pages" ADD COLUMN "generate_slug" boolean DEFAULT true;
ALTER TABLE "_pages_v" ADD COLUMN "version_generate_slug" boolean DEFAULT true;
ALTER TABLE "header_nav_items_children" ADD COLUMN "type" "enum_header_nav_items_children_type" DEFAULT 'custom';
ALTER TABLE "header_nav_items" ADD COLUMN "type" "enum_header_nav_items_type" DEFAULT 'custom';
ALTER TABLE "header" ADD COLUMN "logo_link_type" "enum_header_logo_link_type" DEFAULT 'custom';
ALTER TABLE "header" ADD COLUMN "logo_link_url" varchar DEFAULT '/';
ALTER TABLE "footer" ADD COLUMN "logo_link_type" "enum_footer_logo_link_type" DEFAULT 'custom';
ALTER TABLE "footer" ADD COLUMN "logo_link_url" varchar DEFAULT '/';
ALTER TABLE "header_rels" ADD CONSTRAINT "header_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."header"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "header_rels" ADD CONSTRAINT "header_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "header_rels_order_idx" ON "header_rels" USING btree ("order");
CREATE INDEX "header_rels_parent_idx" ON "header_rels" USING btree ("parent_id");
CREATE INDEX "header_rels_path_idx" ON "header_rels" USING btree ("path");
CREATE INDEX "header_rels_pages_id_idx" ON "header_rels" USING btree ("pages_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "header_rels" DISABLE ROW LEVEL SECURITY;
DROP TABLE "header_rels" CASCADE;
ALTER TABLE "header_nav_items_children" ALTER COLUMN "url" SET NOT NULL;
ALTER TABLE "pages" DROP COLUMN "generate_slug";
ALTER TABLE "_pages_v" DROP COLUMN "version_generate_slug";
ALTER TABLE "header_nav_items_children" DROP COLUMN "type";
ALTER TABLE "header_nav_items" DROP COLUMN "type";
ALTER TABLE "header" DROP COLUMN "logo_link_type";
ALTER TABLE "header" DROP COLUMN "logo_link_url";
ALTER TABLE "footer" DROP COLUMN "logo_link_type";
ALTER TABLE "footer" DROP COLUMN "logo_link_url";
DROP TYPE "public"."enum_header_nav_items_children_type";
DROP TYPE "public"."enum_header_nav_items_type";
DROP TYPE "public"."enum_header_logo_link_type";
DROP TYPE "public"."enum_footer_logo_link_type";`)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,8 @@ import * as migration_20260216_184110 from './20260216_184110';
import * as migration_20260216_184342 from './20260216_184342';
import * as migration_20260218_130902 from './20260218_130902';
import * as migration_20260218_145924 from './20260218_145924';
import * as migration_20260219_175400 from './20260219_175400';
import * as migration_20260219_184333 from './20260219_184333';
export const migrations = [
{
@ -46,6 +48,16 @@ export const migrations = [
{
up: migration_20260218_145924.up,
down: migration_20260218_145924.down,
name: '20260218_145924'
name: '20260218_145924',
},
{
up: migration_20260219_175400.up,
down: migration_20260219_175400.down,
name: '20260219_175400',
},
{
up: migration_20260219_184333.up,
down: migration_20260219_184333.down,
name: '20260219_184333'
},
];

View File

@ -100,7 +100,7 @@ export interface Config {
db: {
defaultIDType: number;
};
fallbackLocale: null;
fallbackLocale: ('false' | 'none' | 'null') | false | null | ('sv' | 'en') | ('sv' | 'en')[];
globals: {
header: Header;
footer: Footer;
@ -115,7 +115,7 @@ export interface Config {
'popup-announcement': PopupAnnouncementSelect<false> | PopupAnnouncementSelect<true>;
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
};
locale: null;
locale: 'sv' | 'en';
user: User;
jobs: {
tasks: {
@ -196,9 +196,13 @@ export interface Page {
};
publishedAt?: string | null;
/**
* Genereras automatiskt från titeln.
* 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.
*/
slug: string;
/**
* När ikryssad skrivs slugen om från titeln vid varje sparning. Avbocka för att låsa slugen och redigera den manuellt.
*/
generateSlug?: boolean | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@ -1364,9 +1368,13 @@ export interface Post {
}[]
| null;
/**
* Genereras automatiskt från titeln.
* Inläggets URL-slug, t.ex. "mitt-inlagg" fiberdirekt.se/posts/mitt-inlagg. Kan alltid redigeras manuellt. Bocka i "Generera slug" nedan för att skriva om automatiskt från titeln.
*/
slug: string;
/**
* När ikryssad skrivs slugen om från titeln vid varje sparning. Avbocka för att låsa slugen och redigera den manuellt.
*/
generateSlug?: boolean | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@ -1704,6 +1712,7 @@ export interface PagesSelect<T extends boolean = true> {
};
publishedAt?: T;
slug?: T;
generateSlug?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
@ -2361,6 +2370,7 @@ export interface PostsSelect<T extends boolean = true> {
name?: T;
};
slug?: T;
generateSlug?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
@ -2712,23 +2722,53 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
*/
export interface Header {
id: number;
/**
* Vart man hamnar när man klickar logotypen. Standard är startsidan (/).
*/
logoLink?: {
type?: ('reference' | 'custom') | null;
reference?: {
relationTo: 'pages';
value: number | Page;
} | null;
/**
* T.ex. / eller /startsida
*/
url?: string | null;
};
navItems?:
| {
label: string;
/**
* Lämna tomt / ignoreras om detta objekt har en undermeny.
*/
type?: ('reference' | 'custom') | null;
reference?: {
relationTo: 'pages';
value: number | Page;
} | null;
/**
* T.ex. /bredband eller https://extern-sida.se
*/
url?: string | null;
/**
* 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.
*/
megaMenu?: boolean | null;
/**
* 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.
*/
children?:
| {
label: string;
url: string;
type?: ('reference' | 'custom') | null;
reference?: {
relationTo: 'pages';
value: number | Page;
} | null;
url?: string | null;
/**
* 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.
*/
group?: string | null;
id?: string | null;
@ -2746,6 +2786,20 @@ export interface Header {
*/
export interface Footer {
id: number;
/**
* Vart man hamnar när man klickar logotypen i footern. Standard är startsidan (/).
*/
logoLink?: {
type?: ('reference' | 'custom') | null;
reference?: {
relationTo: 'pages';
value: number | Page;
} | null;
/**
* T.ex. / eller /startsida
*/
url?: string | null;
};
/**
* Footer-kolumner med rubriker och länkar (sitemap-stil)
*/
@ -2818,7 +2872,23 @@ export interface AnnouncementBar {
enabled?: boolean | null;
text?: string | null;
buttonLabel?: string | null;
buttonUrl?: string | null;
buttonLink?: {
type?: ('reference' | 'custom') | null;
newTab?: boolean | null;
reference?:
| ({
relationTo: 'pages';
value: number | Page;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
} | null);
/**
* T.ex. /bredband eller https://example.com
*/
url?: string | null;
};
dismissible?: boolean | null;
backgroundColor?: ('yellow' | 'navy' | 'mint') | null;
updatedAt?: string | null;
@ -2837,7 +2907,23 @@ export interface PopupAnnouncement {
subheading?: string | null;
body?: string | null;
ctaText?: string | null;
ctaLink?: string | null;
ctaLink?: {
type?: ('reference' | 'custom') | null;
newTab?: boolean | null;
reference?:
| ({
relationTo: 'pages';
value: number | Page;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
} | null);
/**
* T.ex. /bredband eller https://example.com
*/
url?: string | null;
};
image?: (number | null) | Media;
/**
* T.ex. "NYHET", "ERBJUDANDE", "VIKTIG INFO"
@ -2846,17 +2932,19 @@ export interface PopupAnnouncement {
theme?: ('light' | 'dark') | null;
showOnPages?: ('all' | 'home' | 'specific') | null;
/**
* T.ex. /bredband, /cloud, /kontakt
* Välj exakt vilka sidor popupen ska visas .
*/
specificPages?: string | null;
specificPages?: (number | Page)[] | null;
/**
* Hur många dagar popupen ska döljas efter att besökaren stänger den
* Hur många dagar popupen ska döljas efter att besökaren stänger den.
*/
dismissDays?: number | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* Kodinjektion, cookies, analytics och globala webbplatsinställningar.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings".
*/
@ -2903,16 +2991,27 @@ export interface SiteSetting {
* via the `definition` "header_select".
*/
export interface HeaderSelect<T extends boolean = true> {
logoLink?:
| T
| {
type?: T;
reference?: T;
url?: T;
};
navItems?:
| T
| {
label?: T;
type?: T;
reference?: T;
url?: T;
megaMenu?: T;
children?:
| T
| {
label?: T;
type?: T;
reference?: T;
url?: T;
group?: T;
id?: T;
@ -2928,6 +3027,13 @@ export interface HeaderSelect<T extends boolean = true> {
* via the `definition` "footer_select".
*/
export interface FooterSelect<T extends boolean = true> {
logoLink?:
| T
| {
type?: T;
reference?: T;
url?: T;
};
columns?:
| T
| {
@ -2976,7 +3082,14 @@ export interface AnnouncementBarSelect<T extends boolean = true> {
enabled?: T;
text?: T;
buttonLabel?: T;
buttonUrl?: T;
buttonLink?:
| T
| {
type?: T;
newTab?: T;
reference?: T;
url?: T;
};
dismissible?: T;
backgroundColor?: T;
updatedAt?: T;
@ -2993,7 +3106,14 @@ export interface PopupAnnouncementSelect<T extends boolean = true> {
subheading?: T;
body?: T;
ctaText?: T;
ctaLink?: T;
ctaLink?:
| T
| {
type?: T;
newTab?: T;
reference?: T;
url?: T;
};
image?: T;
badgeText?: T;
theme?: T;

View File

@ -63,6 +63,14 @@ export default buildConfig({
},
},
editor: defaultLexical,
localization: {
locales: [
{ label: 'Svenska', code: 'sv' },
{ label: 'English', code: 'en' },
],
defaultLocale: 'sv',
fallback: true,
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL,

View File

@ -1,13 +1,12 @@
// @ts-nocheck
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
import { FixedToolbarFeature, HeadingFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import { nestedDocsPlugin } from '@payloadcms/plugin-nested-docs'
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
import { seoPlugin } from '@payloadcms/plugin-seo'
import { Plugin } from 'payload'
import type { Plugin, Field } from 'payload'
import { revalidateRedirects } from '@/hooks/revalidateRedirects'
import { GenerateTitle, GenerateURL } from '@payloadcms/plugin-seo/types'
import { Page, Post } from '@/payload-types'
import type { GenerateTitle, GenerateURL } from '@payloadcms/plugin-seo/types'
import type { Page, Post } from '@/payload-types'
import { getServerSideURL } from '@/utilities/getURL'
const generateTitle: GenerateTitle<Post | Page> = ({ doc }) => {
@ -23,8 +22,8 @@ export const plugins: Plugin[] = [
redirectsPlugin({
collections: ['pages', 'posts'],
overrides: {
// @ts-expect-error - This is a valid override, mapped fields don't resolve to the same type
fields: ({ defaultFields }) => {
// @ts-expect-error — mapped fields don't resolve to the same type as the plugin expects
fields: ({ defaultFields }: { defaultFields: Field[] }) => {
return defaultFields.map((field) => {
if ('name' in field && field.name === 'from') {
return {
@ -50,13 +49,15 @@ export const plugins: Plugin[] = [
seoPlugin({
generateTitle,
generateURL,
// Keep localized: true as the foundation for future i18n support.
// The localization config in payload.config.ts defines sv (default) + en.
fields: ({ defaultFields }) => {
return defaultFields.map((field) => ({
...field,
localized: true,
}))
})) as Field[]
},
}),
}),
formBuilderPlugin({
fields: {
payment: false,

File diff suppressed because one or more lines are too long