diff --git a/components/CustomHead.tsx b/components/CustomHead.tsx index 31b9ba1..0a217f9 100644 --- a/components/CustomHead.tsx +++ b/components/CustomHead.tsx @@ -1,21 +1,53 @@ /** biome-ignore-all lint/security/noDangerouslySetInnerHtml: Used as expected */ import Head from 'next/head'; import { useRouter } from 'next/router'; +import { type Messages, useTranslations } from 'next-intl'; import type { ReactElement } from 'react'; +import { NON_LOCALIZED_STRING } from '@/constants/localization'; import METADATA, { type IMetadata } from '@/constants/metadata'; import { isLocal } from '@/utils/links'; +export type MetadataLocaleKey = keyof Messages['metadata']; interface Props { title?: string; + localeKey?: MetadataLocaleKey; metadata?: IMetadata; structuredData?: Array; } export default function CustomHead(props: Props): ReactElement { const router = useRouter(); - const { title, metadata, structuredData } = props; - const pageTitle = - title && title.length > 0 ? `${title} - Session Private Messenger` : METADATA.TITLE; + const t = useTranslations('metadata'); + const tFeature = useTranslations('feature'); + + const { localeKey, metadata, structuredData } = props; + + // TODO: we can probably use the locale defaults as the initialized vars + let title = ''; + let description = ''; + + const localeArgs = { + appName: NON_LOCALIZED_STRING.appName, + appNamePossessive: NON_LOCALIZED_STRING.appNamePossessive, + featureCommunity: tFeature('community'), + }; + + if (localeKey) { + const titleKey = `${localeKey}.title` as const; + const pageTitle = t.has(titleKey) ? t(titleKey, localeArgs) : undefined; + + title = + pageTitle && localeKey !== 'default' + ? t('default.titleLayout', { ...localeArgs, title: pageTitle }) + : t('default.title', localeArgs); + + const descriptionKey = `${localeKey}.description` as const; + description = t(t.has(descriptionKey) ? descriptionKey : 'default.description', localeArgs); + } else { + title = props.title || t('default.title', localeArgs); + description = metadata?.DESCRIPTION || t('default.description', localeArgs); + } + const pageUrl = `${METADATA.HOST_URL}${router.asPath}`; const imageALT = metadata?.OG_IMAGE?.ALT ?? METADATA.OG_IMAGE.ALT; let imageWidth = metadata?.OG_IMAGE?.WIDTH ?? METADATA.OG_IMAGE.WIDTH; @@ -37,6 +69,7 @@ export default function CustomHead(props: Props): ReactElement { } const tags = metadata?.TAGS ? metadata?.TAGS : METADATA.TAGS; + const renderTags = (() => { const keywords = ; if (metadata?.TYPE !== 'article') return keywords; @@ -63,6 +96,7 @@ export default function CustomHead(props: Props): ReactElement { ); })(); + const renderLdJSON = (() => { const ldjson = `{ "@context": "https://schema.org", @@ -72,27 +106,27 @@ export default function CustomHead(props: Props): ReactElement { "@id": "${METADATA.HOST_URL}/#website", "url": "${pageUrl}", "name": "${METADATA.SITE_NAME}", - "description": "${METADATA.DESCRIPTION}" + "description": "${t('default.description', localeArgs)}" }, { "@type": "ImageObject", "@id": "${pageUrl}#primaryimage", "url": "${imageUrl}", "width": "${imageWidth}", - "height": "${imageHeight}", + "height": "${imageHeight}" }, { "@type": "WebPage", "@id": "${pageUrl}#webpage", "url": "${pageUrl}", "inLanguage": "${METADATA.LOCALE}", - "name": "${pageTitle}", + "name": "${title}", "isPartOf": { "@id": "${METADATA.HOST_URL}/#website" }, "primaryImageOfPage": { "@id": "${pageUrl}#primaryimage" }, "datePublished": "${metadata?.PUBLISHED_TIME ?? ''}", - "description": "${METADATA.DESCRIPTION}" + "description": "${description}" } ] }`; @@ -104,20 +138,47 @@ export default function CustomHead(props: Props): ReactElement { /> ); })(); + + // Generate localized page variants if localeKey is defined + const renderLocalizedVariants = (() => { + if (!localeKey || !router.locales) return null; + + const currentPathWithoutLocale = router.asPath.replace(`/${router.locale}`, '') || '/'; + + return router.locales.map((locale) => { + const localizedPath = + locale === router.defaultLocale + ? currentPathWithoutLocale + : `/${locale}${currentPathWithoutLocale}`; + + const href = `${METADATA.HOST_URL}${localizedPath}`; + + return ; + }); + })(); + + // Add x-default hreflang for the default locale + const renderDefaultHreflang = (() => { + if (!localeKey || !router.defaultLocale) return null; + + const currentPathWithoutLocale = router.asPath.replace(`/${router.locale}`, '') || '/'; + const defaultHref = `${METADATA.HOST_URL}${currentPathWithoutLocale}`; + + return ( + + ); + })(); + return ( - {pageTitle} + {title} - + - + - + @@ -144,12 +201,8 @@ export default function CustomHead(props: Props): ReactElement { - - + + @@ -159,6 +212,8 @@ export default function CustomHead(props: Props): ReactElement { name="msapplication-TileColor" content={METADATA.MSAPPLICATION_TILECOLOR} /> + {renderLocalizedVariants} + {renderDefaultHreflang} {renderTags} @@ -218,6 +273,15 @@ export default function CustomHead(props: Props): ReactElement { }} /> ))} + {process.env.NEXT_PUBLIC_TRANSLATION_MODE === 'true' ? ( + <> + + + + ) : null} ); } diff --git a/components/RedirectPage.tsx b/components/RedirectPage.tsx index 4b1bb41..aa4f88c 100644 --- a/components/RedirectPage.tsx +++ b/components/RedirectPage.tsx @@ -1,37 +1,60 @@ import classNames from 'classnames'; import { useRouter } from 'next/router'; +import { useTranslations } from 'next-intl'; +import { useEffect } from 'react'; import Container from '@/components/Container'; +import type { MetadataLocaleKey } from '@/components/CustomHead'; +import Layout from '@/components/ui/Layout'; +import type { IMetadata } from '@/constants/metadata'; -export default function RedirectPage() { +type RedirectPageProps = { + redirectUrl: string; + localeKey?: MetadataLocaleKey; + metadata?: IMetadata; +}; + +export default function RedirectPage({ redirectUrl, localeKey, metadata }: RedirectPageProps) { const router = useRouter(); + const t = useTranslations('redirect'); + + useEffect(() => { + router.push(redirectUrl); + }, [router, redirectUrl]); + return ( -
- -

Redirecting...

-

- Click{' '} - {' '} - to return to the previous page. -

-
-
+ +
+ +

+ {t('heading')} +

+

+ {t.rich('content', { + button: (chunk) => ( + + ), + })} +

+
+
+
); } diff --git a/components/cards/BenefitsCard.tsx b/components/cards/BenefitsCard.tsx index 39b97eb..ad09077 100644 --- a/components/cards/BenefitsCard.tsx +++ b/components/cards/BenefitsCard.tsx @@ -1,12 +1,14 @@ import classNames from 'classnames'; import Image from 'next/legacy/image'; -import type { ReactElement } from 'react'; +import { useTranslations } from 'next-intl'; +import type { ReactNode } from 'react'; +import { NON_LOCALIZED_STRING } from '@/constants/localization'; import { useScreen } from '@/contexts/screen'; +import capitalize from '@/utils/capitalize'; import redact from '@/utils/redact'; interface Props { - title: string; - description?: string[]; + itemNumber: '1' | '2' | '3' | '4' | '5' | '6'; images: string[]; // toggle images on hover [original, redacted] imageAlt: string; imageWidth: number; @@ -14,9 +16,26 @@ interface Props { classes?: string; } -export default function BenefitsCard(props: Props): ReactElement { +export default function BenefitsCard(props: Props): ReactNode { + const t = useTranslations('landing.benefits'); + const tFeature = useTranslations('feature'); const { isSmall } = useScreen(); - const { title, description, images, imageAlt, imageWidth, imageHeight, classes } = props; + + const { images, itemNumber, imageAlt, imageWidth, imageHeight, classes } = props; + + if (!t.has(`${itemNumber}.heading` as const) || !t.has(`${itemNumber}.content` as const)) { + return null; + } + + const title = t(`${itemNumber}.heading` as const); + const rawDescription = t.rich(`${itemNumber}.content` as const, { + br: () => null, + appName: NON_LOCALIZED_STRING.appName, + appNamePossessive: NON_LOCALIZED_STRING.appNamePossessive, + featureAccountIds: tFeature('accountIds'), + featureNodes: tFeature('nodes'), + }); + const redactedClasses = redact({ redactColor: 'gray-dark', textColor: 'gray-dark', @@ -27,6 +46,7 @@ export default function BenefitsCard(props: Props): ReactElement { {imageAlt} { - return description?.map((line) => { + const nodes = Array.isArray(rawDescription) ? rawDescription : [rawDescription]; + return nodes.map((line) => { return (

{line}

@@ -71,7 +93,7 @@ export default function BenefitsCard(props: Props): ReactElement { return (
{renderImages}
-

{title}

+

{title}

{renderDescription}
); diff --git a/components/cards/PostCard.tsx b/components/cards/PostCard.tsx index 3502595..2afed5d 100644 --- a/components/cards/PostCard.tsx +++ b/components/cards/PostCard.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import Image from 'next/legacy/image'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import type { ReactElement } from 'react'; import type { IPost } from '@/types/cms'; @@ -13,6 +14,7 @@ interface Props extends IPost { } export default function PostCard(props: Props): ReactElement { + const t = useTranslations('blog'); const { title, description, @@ -84,7 +86,7 @@ export default function PostCard(props: Props): ReactElement { )} {featured && ( - Read More » + {t('readMore')} » )} diff --git a/components/navigation/Footer.tsx b/components/navigation/Footer.tsx index 520246c..960b89a 100644 --- a/components/navigation/Footer.tsx +++ b/components/navigation/Footer.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import Image from 'next/legacy/image'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import type { FunctionComponent, ReactElement, SVGProps } from 'react'; import { ReactComponent as GithubSVG } from '@/assets/svgs/github.svg'; import { ReactComponent as InstagramSVG } from '@/assets/svgs/instagram.svg'; @@ -8,12 +9,12 @@ import { ReactComponent as MastodonSVG } from '@/assets/svgs/mastodon.svg'; import { ReactComponent as RssSVG } from '@/assets/svgs/rss.svg'; import { ReactComponent as TwitterSVG } from '@/assets/svgs/twitter.svg'; import { ReactComponent as YouTubeSVG } from '@/assets/svgs/youtube.svg'; +import { NON_LOCALIZED_STRING } from '@/constants/localization'; import redact from '@/utils/redact'; // Type definitions interface SocialLink { href: string; - label: string; icon: FunctionComponent>; external: boolean; platform: string; @@ -34,14 +35,12 @@ const svgClasses = classNames('fill-current w-7 h-7 m-1', 'lg:my-0 lg:ml-0', 'ho const socialLinks: SocialLink[] = [ { href: 'https://twitter.com/session_app', - label: 'Follow Session on Twitter', icon: TwitterSVG, external: true, platform: 'Twitter', }, { href: 'https://mastodon.social/@session', - label: 'Follow Session on Mastodon', icon: MastodonSVG, external: true, platform: 'Mastodon', @@ -50,28 +49,24 @@ const socialLinks: SocialLink[] = [ }, { href: 'https://www.instagram.com/getsession', - label: 'Follow Session on Instagram', icon: InstagramSVG, external: true, platform: 'Instagram', }, { href: 'https://www.youtube.com/@SessionTV', - label: 'Subscribe to Session on YouTube', icon: YouTubeSVG, external: true, platform: 'YouTube', }, { href: 'https://github.com/session-foundation', - label: 'View Session on GitHub', icon: GithubSVG, external: true, platform: 'GitHub', }, { href: '/feed', - label: 'Subscribe to Session RSS feed', icon: RssSVG, external: false, platform: 'RSS', @@ -79,38 +74,45 @@ const socialLinks: SocialLink[] = [ ]; function SocialLinks() { + const t = useTranslations('footer.aria'); return ( -