add basic internationalization support

This commit is contained in:
Sol Irvine 2023-08-13 17:14:30 +09:00
parent b786cc2228
commit 2cb348259a
32 changed files with 198 additions and 61 deletions

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,4 +1,5 @@
import Navbar from 'components/layout/navbar';
import { i18n } from 'i18n-config';
import { Inter } from 'next/font/google';
import { ReactNode, Suspense } from 'react';
import './globals.css';
@ -34,12 +35,22 @@ const inter = Inter({
variable: '--font-inter'
});
export default async function RootLayout({ children }: { children: ReactNode }) {
export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ lang: locale }));
}
export default async function RootLayout({
children,
params
}: {
children: ReactNode;
params: { lang: string };
}) {
return (
<html lang="en" className={inter.variable}>
<html lang={params.lang} className={inter.variable}>
<body className="bg-dark text-white selection:bg-green-800 selection:text-green-400">
<div className="mx-auto max-w-screen-2xl">
<Navbar />
<Navbar lang={params.lang} />
<Suspense>
<main>{children}</main>
</Suspense>

View File

@ -2,6 +2,8 @@ import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer';
import { LanguageControl } from 'components/layout/navbar/language-control';
import type { Locale } from '../../i18n-config';
import Image from 'next/image';
import Namemark from 'public/assets/images/namemark.png';
import { Suspense } from 'react';
@ -15,11 +17,13 @@ export const metadata = {
}
};
export default async function HomePage() {
export default async function HomePage({ params: { lang } }: { params: { lang: Locale } }) {
// const dictionary = await getDictionary(lang);
return (
<>
<div className="invisible absolute right-40 top-12 md:visible">
<LanguageControl />
<LanguageControl lang={lang} />
</div>
<div className="px-6 pb-12 pt-6 md:py-12 md:pl-6">
<Image

View File

@ -1,32 +1,21 @@
import clsx from 'clsx';
import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify';
import type { Product } from 'lib/shopify/types';
import Link from 'next/link';
function ThreeItemGridItem({
item,
size,
priority
}: {
item: Product;
size: 'full' | 'half';
priority?: boolean;
}) {
function ThreeItemGridItem({ item, priority }: { item: Product; priority?: boolean }) {
return (
<div
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
>
<div className={clsx('md:col-span-2 md:row-span-1')}>
<Link className="relative block aspect-square h-full w-full" href={`/product/${item.handle}`}>
<GridTileImage
src={item.featuredImage.url}
fill
sizes={
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
}
sizes={'(min-width: 768px) 33vw, 100vw'}
priority={priority}
alt={item.title}
label={{
position: size === 'full' ? 'center' : 'bottom',
position: 'bottom',
title: item.title as string,
amount: item.priceRange.maxVariantPrice.amount,
currencyCode: item.priceRange.maxVariantPrice.currencyCode
@ -48,10 +37,15 @@ export async function ThreeItemGrid() {
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
return (
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2">
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
<ThreeItemGridItem size="half" item={thirdProduct} />
<section
className={clsx(
'mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6',
'md:grid-rows-3'
)}
>
<ThreeItemGridItem item={firstProduct} priority={true} />
<ThreeItemGridItem item={secondProduct} priority={true} />
<ThreeItemGridItem item={thirdProduct} />
</section>
);
}

View File

@ -14,36 +14,21 @@ export function GridTileImage({
title: string;
amount: string;
currencyCode: string;
position?: 'bottom' | 'center';
};
} & React.ComponentProps<typeof Image>) {
return (
<div
className={clsx(
'flex h-full w-full items-center justify-center overflow-hidden rounded-lg border bg-white hover:border-blue-600 dark:bg-black',
{
relative: label,
'border-2 border-blue-600': active,
'border-neutral-200 dark:border-neutral-800': !active
}
)}
>
<div className="flex flex-col space-y-2">
{props.src ? (
// eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript
<Image
className={clsx('relative h-full w-full object-contain', {
className={clsx('h-full w-full object-contain', {
'transition duration-300 ease-in-out hover:scale-105': isInteractive
})}
{...props}
/>
) : null}
{label ? (
<Label
title={label.title}
amount={label.amount}
currencyCode={label.currencyCode}
position={label.position}
/>
<Label title={label.title} amount={label.amount} currencyCode={label.currencyCode} />
) : null}
</div>
);

View File

@ -4,24 +4,18 @@ import Price from './price';
const Label = ({
title,
amount,
currencyCode,
position = 'bottom'
currencyCode
}: {
title: string;
amount: string;
currencyCode: string;
position?: 'bottom' | 'center';
}) => {
return (
<div
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
'lg:px-20 lg:pb-[35%]': position === 'center'
})}
>
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white">
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
<div className={clsx('@container/label')}>
<div className="flex flex-col space-y-2">
<h3 className="mr-4 line-clamp-2 flex-grow text-3xl">{title}</h3>
<Price
className="flex-none rounded-full bg-blue-600 p-2 text-white"
className="flex-none"
amount={amount}
currencyCode={currencyCode}
currencyCodeClassName="hidden @[275px]/label:inline"

View File

@ -3,11 +3,12 @@
import { Dialog, Transition } from '@headlessui/react';
import CloseIcon from 'components/icons/close';
import MenuIcon from 'components/icons/menu';
import type { Locale } from 'i18n-config';
import Link from 'next/link';
import { Fragment, useRef, useState } from 'react';
import { LanguageControl } from '../navbar/language-control';
export function MenuModal() {
export function MenuModal({ lang }: { lang: Locale }) {
let [isOpen, setIsOpen] = useState(false);
let closeButtonRef = useRef(null);
@ -44,7 +45,7 @@ export function MenuModal() {
<Transition.Child as={Fragment}>
<div className="fixed right-5 top-6 z-40 px-2 py-1 md:top-11">
<div className="flex flex-row space-x-4">
<LanguageControl />
<LanguageControl lang={lang} />
<button ref={closeButtonRef} onClick={close} className="">
<CloseIcon className="h-10 w-10 stroke-current transition-opacity duration-150 hover:opacity-50" />

View File

@ -1,16 +1,17 @@
import Cart from 'components/cart';
import OpenCart from 'components/cart/open-cart';
import type { Locale } from 'i18n-config';
import { Suspense } from 'react';
import { MenuModal } from '../menu/modal';
export default async function Navbar() {
export default async function Navbar({ lang }: { lang: Locale }) {
return (
<nav className="fixed right-0 top-6 z-10 md:top-12">
<div className="flex justify-end pr-5">
<Suspense fallback={<OpenCart />}>
<div className="flex flex-col-reverse items-center justify-center space-y-2 rounded bg-dark/40 px-2 backdrop-blur-sm md:flex-row md:space-x-6">
<Cart />
<MenuModal />
<MenuModal lang={lang} />
</div>
</Suspense>
</div>

View File

@ -1,9 +1,45 @@
export const LanguageControl = () => {
'use client';
import clsx from 'clsx';
import type { Locale } from 'i18n-config';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export const LanguageControl = ({ lang }: { lang?: Locale }) => {
const pathName = usePathname();
console.debug({ lang });
const redirectedPathName = (locale: string) => {
if (!pathName) return '/';
const segments = pathName.split('/');
segments[1] = locale;
return segments.join('/');
};
return (
<div className="flex flex-row space-x-0">
<span className="px-2 py-4">JP</span>
<span className="px-2 py-4">
<Link
href={redirectedPathName('ja')}
className={clsx(
lang === 'ja' && 'opacity-70',
'transition-opacity duration-150 hover:opacity-50'
)}
>
JP
</Link>
</span>
<span className="py-4">/</span>
<span className="px-2 py-4">EN</span>
<span className="px-2 py-4">
<Link
href={redirectedPathName('en')}
className={clsx(
lang === 'en' && 'opacity-70',
'transition-opacity duration-150 hover:opacity-50'
)}
>
EN
</Link>
</span>
</div>
);
};

6
dictionaries/en.json Normal file
View File

@ -0,0 +1,6 @@
{
"hello": {
"title": "Hello World",
"description": "This is a description"
}
}

12
dictionaries/index.ts Normal file
View File

@ -0,0 +1,12 @@
import 'server-only';
import type { Locale } from '../i18n-config';
// We enumerate all dictionaries here for better linting and typescript support
// We also get the default import for cleaner types
const dictionaries = {
en: () => import('./en.json').then((module) => module.default),
ja: () => import('./ja.json').then((module) => module.default)
};
export const getDictionary = async (locale: Locale) =>
dictionaries[locale]?.() ?? dictionaries.en();

6
dictionaries/ja.json Normal file
View File

@ -0,0 +1,6 @@
{
"hello": {
"title": "こんにちは",
"description": "これはせつめいですよ"
}
}

6
i18n-config.ts Normal file
View File

@ -0,0 +1,6 @@
export const i18n = {
defaultLocale: 'en',
locales: ['en', 'ja']
} as const;
export type Locale = (typeof i18n)['locales'][number];

59
middleware.ts Normal file
View File

@ -0,0 +1,59 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { match as matchLocale } from '@formatjs/intl-localematcher';
import { i18n } from 'i18n-config';
import Negotiator from 'negotiator';
function getLocale(request: NextRequest): string | undefined {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
// @ts-ignore locales are readonly
const locales: string[] = i18n.locales;
// Use negotiator and intl-localematcher to get best locale
let languages = new Negotiator({ headers: negotiatorHeaders }).languages(locales);
const locale = matchLocale(languages, locales, i18n.defaultLocale);
return locale;
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// `/_next/` and `/api/` are ignored by the watcher, but we need to ignore files in `public` manually.
// If you have one
if (
[
'/public/addets/images/logo.png',
'/public/addets/images/logo+namemark.png',
'/public/addets/images/namemark.png'
// Your other files in `public`
].includes(pathname)
)
return;
// Check if there is any supported locale in the pathname
const pathnameIsMissingLocale = i18n.locales.every(
(locale: any) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// Redirect if there is no locale
if (pathnameIsMissingLocale) {
const locale = getLocale(request);
// e.g. incoming request is /products
// The new URL is now /en-US/products
return NextResponse.redirect(
new URL(`/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`, request.url)
);
}
}
export const config = {
// Matcher ignoring `/_next/` and `/api/`
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
};

View File

@ -23,11 +23,13 @@
"*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.4.0",
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"clsx": "^2.0.0",
"eslint-plugin-tailwindcss": "^3.13.0",
"eslint-plugin-unused-imports": "^3.0.0",
"negotiator": "^0.6.3",
"next": "latest",
"prettier-plugin-organize-imports": "^3.2.3",
"react": "latest",
@ -36,6 +38,7 @@
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.9",
"@types/negotiator": "^0.6.1",
"@types/node": "20.4.4",
"@types/react": "18.2.16",
"@types/react-dom": "18.2.7",

View File

@ -97,6 +97,15 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:^0.4.0":
version: 0.4.0
resolution: "@formatjs/intl-localematcher@npm:0.4.0"
dependencies:
tslib: ^2.4.0
checksum: c65108e9a81c3733d2b6240ceedc846d0ae59c3606041cb5cc71c13453cdabe295b0dc8559dc4a8acaafdc45876807bd5e9ef37a3ec1cb864e78db655d434b66
languageName: node
linkType: hard
"@headlessui/react@npm:^1.7.15":
version: 1.7.16
resolution: "@headlessui/react@npm:1.7.16"
@ -395,6 +404,13 @@ __metadata:
languageName: node
linkType: hard
"@types/negotiator@npm:^0.6.1":
version: 0.6.1
resolution: "@types/negotiator@npm:0.6.1"
checksum: e39f985874a30bd13186249eaaede0c5eec107178f55f2bd719c2c9d1397de329a5e0869a25dd5e7c702cdd311bd5e4153e25963900b4353545842354a2d1bc4
languageName: node
linkType: hard
"@types/node@npm:20.4.4":
version: 20.4.4
resolution: "@types/node@npm:20.4.4"
@ -1172,10 +1188,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "commerce@workspace:."
dependencies:
"@formatjs/intl-localematcher": ^0.4.0
"@headlessui/react": ^1.7.15
"@heroicons/react": ^2.0.18
"@tailwindcss/container-queries": ^0.1.1
"@tailwindcss/typography": ^0.5.9
"@types/negotiator": ^0.6.1
"@types/node": 20.4.4
"@types/react": 18.2.16
"@types/react-dom": 18.2.7
@ -1188,6 +1206,7 @@ __metadata:
eslint-plugin-unicorn: ^48.0.0
eslint-plugin-unused-imports: ^3.0.0
lint-staged: ^13.2.3
negotiator: ^0.6.3
next: latest
postcss: ^8.4.27
prettier: 3.0.1