feat(payload): add pt-BR admin/frontend copy and site settings social links

- add  global and cached global fetch utility
- add , , and  components driven by global settings
- wire global registration in Payload config
- enable Payload admin i18n with Portuguese () language pack
- translate frontend metadata/homepage/custom route copy to pt-BR
- translate collection/global labels, descriptions, and validation messages
- improve social-link a11y with Portuguese fallback labels and external-link icon
- update francisco.solla and  admin labels/groups for Portuguese UX
- regenerate  for new global/schema changes
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-02-15 01:17:26 -03:00
parent c3d1e5c717
commit 56f2d48d8d
12 changed files with 329 additions and 12 deletions

View file

@ -1,18 +1,22 @@
import { Footer } from '@/components/Footer'
import { Header } from '@/components/Header'
import React from 'react'
import './styles.css'
export const metadata = {
description: 'A blank template using Payload in a Next.js app.',
title: 'Payload Blank Template',
description: 'Um template base usando Payload em uma aplicação Next.js.',
title: 'Template Base do Payload',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
return (
<html lang="en">
<html lang="pt-BR">
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
)

View file

@ -26,8 +26,8 @@ export default async function HomePage() {
width={65}
/>
</picture>
{!user && <h1>Welcome to your new project.</h1>}
{user && <h1>Welcome back, {user.email}</h1>}
{!user && <h1>Boas-vindas ao seu novo projeto.</h1>}
{user && <h1>Que bom ver você de volta, {user.email}.</h1>}
<div className="links">
<a
className="admin"
@ -35,7 +35,7 @@ export default async function HomePage() {
rel="noopener noreferrer"
target="_blank"
>
Go to admin panel
Ir para o painel administrativo
</a>
<a
className="docs"
@ -43,18 +43,18 @@ export default async function HomePage() {
rel="noopener noreferrer"
target="_blank"
>
Documentation
Documentação
</a>
</div>
</div>
<div className="footer">
<p>Update this page by editing</p>
<p>Atualize esta página editando</p>
<a className="codeLink" href={fileURL}>
<code>app/(frontend)/page.tsx</code>
</a>
</div>
<div className="mt-4 rounded-md border border-white bg-white px-3 py-1 text-sm text-black">
Tailwind is active: utility classes are being applied.
Tailwind está ativo: as classes utilitárias estão sendo aplicadas.
</div>
</div>
)

View file

@ -7,6 +7,6 @@ export const GET = async (request: Request) => {
})
return Response.json({
message: 'This is an example of a custom route.',
message: 'Este é um exemplo de rota personalizada.',
})
}

View file

@ -2,6 +2,13 @@ import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
labels: {
singular: 'Mídia',
plural: 'Mídias',
},
admin: {
group: 'Coleções',
},
access: {
read: () => true,
},
@ -9,6 +16,10 @@ export const Media: CollectionConfig = {
{
name: 'alt',
type: 'text',
label: 'Texto alternativo',
admin: {
description: 'Descrição da imagem para acessibilidade.',
},
required: true,
},
],

View file

@ -2,7 +2,12 @@ import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
labels: {
singular: 'Usuário',
plural: 'Usuários',
},
admin: {
group: 'Coleções',
useAsTitle: 'email',
},
auth: true,

View file

@ -0,0 +1,7 @@
import { SocialLinks } from './socialLinks'
export const Footer = () => (
<footer className="flex items-center justify-center p-4">
<SocialLinks ariaLabel="Redes sociais do rodapé" />
</footer>
)

15
src/components/Header.tsx Normal file
View file

@ -0,0 +1,15 @@
import { getCachedGlobal } from '@/utilities/getGlobals'
import { SocialLinks } from './socialLinks'
export const Header = async () => {
const settings = await getCachedGlobal('site-settings', 2)()
console.log(settings)
return (
<header className="flex items-center justify-between p-4">
{settings.headerTitle ? <h2>{settings.headerTitle}</h2> : null}
<SocialLinks ariaLabel="Redes sociais" />
</header>
)
}

View file

@ -0,0 +1,114 @@
import { getCachedGlobal } from '@/utilities/getGlobals'
import Link from 'next/link'
type IconProps = {
className?: string
}
const baseIconClass = 'h-5 w-5'
const InstagramIcon = ({ className }: IconProps) => (
<svg
viewBox="0 0 24 24"
fill="white"
xmlns="http://www.w3.org/2000/svg"
className={className || baseIconClass}
aria-hidden
>
<title>Instagram</title>
<path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077" />
</svg>
)
const FacebookIcon = ({ className }: IconProps) => (
<svg
viewBox="0 0 24 24"
fill="white"
xmlns="http://www.w3.org/2000/svg"
className={className || baseIconClass}
aria-hidden
>
<title>Facebook</title>
<path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" />
</svg>
)
const YoutubeIcon = ({ className }: IconProps) => (
<svg
role="img"
viewBox="0 0 24 24"
fill="white"
xmlns="http://www.w3.org/2000/svg"
className={className || baseIconClass}
aria-hidden
>
<title>YouTube</title>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
)
const GlobeIcon = ({ className }: IconProps) => (
<svg
viewBox="0 0 24 24"
fill="white"
xmlns="http://www.w3.org/2000/svg"
className={className || baseIconClass}
aria-hidden
>
<title>Link externo</title>
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm7.9 9h-3.02a15.7 15.7 0 0 0-1.18-4.2A8.03 8.03 0 0 1 19.9 11zM12 4.04c.9 1.13 1.65 2.95 1.94 4.96h-3.88c.29-2.01 1.04-3.83 1.94-4.96zM8.3 6.8A15.7 15.7 0 0 0 7.12 11H4.1a8.03 8.03 0 0 1 4.2-4.2zM4.1 13h3.02c.15 1.47.56 2.92 1.18 4.2A8.03 8.03 0 0 1 4.1 13zM12 19.96c-.9-1.13-1.65-2.95-1.94-4.96h3.88c-.29 2.01-1.04 3.83-1.94 4.96zm2.88-2.76c.62-1.28 1.03-2.73 1.18-4.2h3.02a8.03 8.03 0 0 1-4.2 4.2z" />
</svg>
)
const icons = {
instagram: InstagramIcon,
facebook: FacebookIcon,
youtube: YoutubeIcon,
custom: GlobeIcon,
} as const
const defaultA11yLabels: Record<string, string> = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
custom: 'Link externo',
}
type SocialLinksProps = {
ariaLabel: string
}
export const SocialLinks = async ({ ariaLabel }: SocialLinksProps) => {
const { socialLinks: links } = await getCachedGlobal('site-settings', 2)()
if (!links?.length) return null
return (
<nav aria-label={ariaLabel}>
<ul className="flex items-center gap-3">
{links.map((link) => {
if (!link.enabled || !link.url) return null
const platform = link.platform ?? 'custom'
const Icon = icons[platform] ?? GlobeIcon
const label =
link.label?.trim() ?? defaultA11yLabels[platform] ?? defaultA11yLabels.custom
return (
<li key={`${platform}-${link.url}`}>
<Link
href={link.url}
aria-label={label}
target="_blank"
rel="noopener noreferrer"
className="inline-flex rounded-md p-1 transition-opacity hover:opacity-80"
>
<Icon />
</Link>
</li>
)
})}
</ul>
</nav>
)
}

View file

@ -0,0 +1,80 @@
import { revalidateTag } from 'next/cache'
import type { GlobalConfig } from 'payload'
const revalidate = async () => revalidateTag('global_site-settings')
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
label: 'Configurações do site',
admin: {
group: 'Configurações',
},
access: {
read: () => true,
update: ({ req: { user } }) => Boolean(user),
},
fields: [
{
name: 'headerTitle',
type: 'text',
label: 'Título do cabeçalho',
},
{
name: 'socialLinks',
type: 'array',
label: 'Redes sociais',
labels: {
singular: 'Rede social',
plural: 'Redes sociais',
},
fields: [
{
name: 'platform',
type: 'select',
label: 'Plataforma',
required: true,
options: [
{ label: 'Instagram', value: 'instagram' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'YouTube', value: 'youtube' },
],
},
{
name: 'url',
type: 'text',
label: 'URL',
required: true,
validate: (value: string | null | undefined) => {
if (!value) return 'A URL é obrigatória'
try {
const parsed = new URL(value)
if (!['http:', 'https:'].includes(parsed.protocol)) {
return 'A URL deve começar com http:// ou https://'
}
return true
} catch {
return 'Informe uma URL válida'
}
},
},
{
name: 'label',
type: 'text',
label: 'Rótulo de acessibilidade',
admin: {
description:
'Rótulo opcional para acessibilidade. Se estiver vazio, usamos um rótulo padrão da plataforma.',
},
},
{
name: 'enabled',
type: 'checkbox',
label: 'Ativo',
},
],
},
],
hooks: {
afterChange: [revalidate],
},
}

View file

@ -87,8 +87,12 @@ export interface Config {
defaultIDType: number;
};
fallbackLocale: null;
globals: {};
globalsSelect: {};
globals: {
'site-settings': SiteSetting;
};
globalsSelect: {
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
};
locale: null;
user: User;
jobs: {
@ -145,6 +149,9 @@ export interface User {
*/
export interface Media {
id: number;
/**
* Descrição da imagem para acessibilidade.
*/
alt: string;
updatedAt: string;
createdAt: string;
@ -312,6 +319,47 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings".
*/
export interface SiteSetting {
id: number;
headerTitle?: string | null;
socialLinks?:
| {
platform: 'instagram' | 'facebook' | 'youtube';
url: string;
/**
* Rótulo opcional para acessibilidade. Se estiver vazio, usamos um rótulo padrão da plataforma.
*/
label?: string | null;
enabled?: boolean | null;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings_select".
*/
export interface SiteSettingsSelect<T extends boolean = true> {
headerTitle?: T;
socialLinks?:
| T
| {
platform?: T;
url?: T;
label?: T;
enabled?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

View file

@ -7,6 +7,8 @@ import sharp from 'sharp'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { SiteSettings } from './globals/SiteSettings'
import { pt } from 'payload/i18n/pt'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -19,6 +21,7 @@ export default buildConfig({
},
},
collections: [Users, Media],
globals: [SiteSettings],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
@ -31,4 +34,8 @@ export default buildConfig({
}),
sharp,
plugins: [],
i18n: {
fallbackLanguage: 'pt', // use 'pt' (not 'pt-BR') for Payload admin language code
supportedLanguages: { pt },
},
})

View file

@ -0,0 +1,26 @@
import type { Config } from 'src/payload-types'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
type Global = keyof Config['globals']
export async function getGlobal(slug: Global, depth = 0) {
const payload = await getPayload({ config: configPromise })
const global = await payload.findGlobal({
slug,
depth,
})
return global
}
/**
* Returns a unstable_cache function mapped with the cache tag for the slug
*/
export const getCachedGlobal = (slug: Global, depth = 0) =>
unstable_cache(async () => getGlobal(slug, depth), [slug], {
tags: [`global_${slug}`],
})