Iterated with translations

This commit is contained in:
Henrik Larsson 2023-05-03 09:58:35 +02:00
parent 86dca04eec
commit cca3250557
54 changed files with 5406 additions and 728 deletions

View File

@ -1,5 +1,23 @@
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce"
# Created by Vercel CLI
VERCEL="1"
VERCEL_ENV="development"
TURBO_REMOTE_ONLY="true"
NX_DAEMON="false"
VERCEL_URL=""
VERCEL_GIT_PROVIDER=""
VERCEL_GIT_PREVIOUS_SHA=""
VERCEL_GIT_REPO_SLUG=""
VERCEL_GIT_REPO_OWNER=""
VERCEL_GIT_REPO_ID=""
VERCEL_GIT_COMMIT_REF=""
VERCEL_GIT_COMMIT_SHA=""
VERCEL_GIT_COMMIT_MESSAGE=""
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
VERCEL_GIT_PULL_REQUEST_ID=""
TWITTER_CREATOR="@kodamera"
TWITTER_SITE="https://kodamera.se"
SITE_NAME="KM Storefront"
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
SHOPIFY_STORE_DOMAIN=

1
.npmrc Normal file
View File

@ -0,0 +1 @@
auto-install-peers=true

View File

@ -0,0 +1,15 @@
'use client';
import LocaleSwitcher from 'components/ui/locale-switcher';
import { useTranslations } from 'next-intl';
export default function Index() {
const t = useTranslations('Index');
return (
<div>
<h1>{t('title')}</h1>
<LocaleSwitcher />
</div>
)
}

68
app/[locale]/layout.tsx Normal file
View File

@ -0,0 +1,68 @@
import Footer from 'components/layout/footer';
import Header from 'components/layout/header';
import { NextIntlClientProvider } from 'next-intl';
import { Inter } from 'next/font/google';
import { notFound } from 'next/navigation';
import { ReactNode } from 'react';
import './globals.css';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
export const metadata = {
title: {
default: SITE_NAME,
template: `%s | ${SITE_NAME}`
},
robots: {
follow: true,
index: true
},
...(TWITTER_CREATOR &&
TWITTER_SITE && {
twitter: {
card: 'summary_large_image',
creator: TWITTER_CREATOR,
site: TWITTER_SITE
}
})
};
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
});
export function generateStaticParams() {
return [{locale: 'sv'}, {locale: 'en'}, {locale: 'nn'}];
}
interface LocaleLayoutProps {
children: ReactNode
params: {
locale: string
}
}
export default async function LocaleLayout({children, params: {locale}}: LocaleLayoutProps) {
let messages;
try {
messages = (await import(`../../messages/${locale}.json`)).default;
} catch (error) {
notFound();
}
return (
<html lang={locale} className={inter.variable}>
<body className="flex flex-col min-h-screen">
<NextIntlClientProvider locale={locale} messages={messages}>
<Header />
<main className="flex-1">{children}</main>
<Footer />
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,8 @@
export default function NotFound() {
return (
<>
<h2>Not Found</h2>
<p>Could not find requested resource</p>
</>
);
}

View File

@ -1,16 +0,0 @@
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<div className="w-full bg-white dark:bg-black">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
<Suspense>{children}</Suspense>
</div>
</div>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
);
}

View File

@ -1,56 +0,0 @@
import type { Metadata } from 'next';
import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation';
export const runtime = 'edge';
export const revalidate = 43200; // 12 hours in seconds
export async function generateMetadata({
params
}: {
params: { page: string };
}): Promise<Metadata> {
const page = await getPage(params.page);
if (!page) return notFound();
return {
title: page.seo?.title || page.title,
description: page.seo?.description || page.bodySummary,
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(page.title)}`,
width: 1200,
height: 630
}
],
publishedTime: page.createdAt,
modifiedTime: page.updatedAt,
type: 'article'
}
};
}
export default async function Page({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
if (!page) return notFound();
return (
<>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body as string} />
<p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(page.updatedAt))}.`}
</p>
</>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -1,45 +0,0 @@
import Navbar from 'components/layout/navbar';
import { Inter } from 'next/font/google';
import { ReactNode, Suspense } from 'react';
import './globals.css';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
export const metadata = {
title: {
default: SITE_NAME,
template: `%s | ${SITE_NAME}`
},
robots: {
follow: true,
index: true
},
...(TWITTER_CREATOR &&
TWITTER_SITE && {
twitter: {
card: 'summary_large_image',
creator: TWITTER_CREATOR,
site: TWITTER_SITE
}
})
};
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
});
export default async function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="bg-white text-black selection:bg-teal-300 dark:bg-black dark:text-white dark:selection:bg-fuchsia-600 dark:selection:text-white">
{/* @ts-expect-error Server Component */}
<Navbar />
<Suspense>
<main>{children}</main>
</Suspense>
</body>
</html>
);
}

View File

@ -1,37 +0,0 @@
import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export const runtime = 'edge';
export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(process.env.SITE_NAME || '')}`,
width: 1200,
height: 630
}
],
type: 'website'
}
};
export default async function HomePage() {
return (
<>
{/* @ts-expect-error Server Component */}
<ThreeItemGrid />
<Suspense>
{/* @ts-expect-error Server Component */}
<Carousel />
<Suspense>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
</Suspense>
</>
);
}

View File

@ -1,49 +0,0 @@
import { getCollection, getCollectionProducts } from 'lib/shopify';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
export const runtime = 'edge';
export async function generateMetadata({
params
}: {
params: { collection: string };
}): Promise<Metadata> {
const collection = await getCollection(params.collection);
if (!collection) return notFound();
return {
title: collection.seo?.title || collection.title,
description:
collection.seo?.description || collection.description || `${collection.title} products`,
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(collection.title)}`,
width: 1200,
height: 630
}
]
}
};
}
export default async function CategoryPage({ params }: { params: { collection: string } }) {
const products = await getCollectionProducts(params.collection);
return (
<section>
{products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p>
) : (
<Grid className="grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
)}
</section>
);
}

View File

@ -1,23 +0,0 @@
import Footer from 'components/layout/footer';
import Collections from 'components/layout/search/collections';
import FilterList from 'components/layout/search/filter';
import { sorting } from 'lib/constants';
import { Suspense } from 'react';
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<div className="mx-auto flex max-w-7xl flex-col bg-white py-6 text-black dark:bg-black dark:text-white md:flex-row">
<div className="order-first flex-none md:w-1/6">
<Collections />
</div>
<div className="order-last min-h-screen w-full md:order-none">{children}</div>
<div className="order-none md:order-last md:w-1/6 md:flex-none">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
{/* @ts-expect-error Server Component */}
<Footer />
</Suspense>
);
}

View File

@ -1,13 +0,0 @@
import Grid from 'components/grid';
export default function Loading() {
return (
<Grid className="grid-cols-2 lg:grid-cols-3">
{Array(12)
.fill(0)
.map((_, index) => {
return <Grid.Item key={index} className="animate-pulse bg-gray-100 dark:bg-gray-900" />;
})}
</Grid>
);
}

View File

@ -1,41 +0,0 @@
import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify';
export const runtime = 'edge';
export const metadata = {
title: 'Search',
description: 'Search for products in the store.'
};
export default async function SearchPage({
searchParams
}: {
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const { sort, q: searchValue } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getProducts({ sortKey, reverse, query: searchValue });
const resultsText = products.length > 1 ? 'results' : 'result';
return (
<>
{searchValue ? (
<p>
{products.length === 0
? 'There are no products that match '
: `Showing ${products.length} ${resultsText} for `}
<span className="font-bold">&quot;{searchValue}&quot;</span>
</p>
) : null}
{products.length > 0 ? (
<Grid className="grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
) : null}
</>
);
}

View File

@ -1,19 +1,18 @@
export default function LogoIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label={`${process.env.SITE_NAME} logo`}
viewBox="0 0 32 32"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 109 80"
className={className}
>
<rect width="100%" height="100%" rx="16" className="fill-black dark:fill-white" />
<path
className=" fill-white dark:fill-black"
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
fill="currentColor"
d="M54.6,0C32.8,0,15.1,17.9,15.1,40c0,10.6,4.3,18.1,4.6,18.8h20.6c-0.7-0.5-9.1-6.9-9.1-18.8 c0-13.1,10.5-23.7,23.4-23.7S78,26.9,78,40S67.5,63.7,54.6,63.7H0V80h54.6c21.8,0,39.5-17.9,39.5-40S76.5,0,54.6,0z"
/>
<path
fill="currentColor"
d="M109,63.7V80H75.3c7.2-3.7,13.2-9.4,17.4-16.3H109z"
/>
</svg>
);

View File

@ -1,20 +0,0 @@
export default function VercelIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label="Vercel.com Logo"
viewBox="0 0 89 20"
fill="currentColor"
shapeRendering="geometricPrecision"
className={className}
>
<path d="M11.5625 0L23.125 20H0L11.5625 0Z" />
<path d="M49.875 10.625C49.875 7.40625 47.5 5.15625 44.0937 5.15625C40.6875 5.15625 38.3125 7.40625 38.3125 10.625C38.3125 13.7812 40.875 16.0937 44.4062 16.0937C46.3438 16.0937 48.0938 15.375 49.2188 14.0625L47.0938 12.8437C46.4375 13.5 45.4688 13.9062 44.4062 13.9062C42.8438 13.9062 41.5 13.0625 41.0312 11.7812L40.9375 11.5625H49.7812C49.8438 11.25 49.875 10.9375 49.875 10.625ZM40.9062 9.6875L40.9688 9.5C41.375 8.15625 42.5625 7.34375 44.0625 7.34375C45.5938 7.34375 46.75 8.15625 47.1562 9.5L47.2188 9.6875H40.9062Z" />
<path d="M83.5313 10.625C83.5313 7.40625 81.1563 5.15625 77.75 5.15625C74.3438 5.15625 71.9688 7.40625 71.9688 10.625C71.9688 13.7812 74.5313 16.0937 78.0625 16.0937C80 16.0937 81.75 15.375 82.875 14.0625L80.75 12.8437C80.0938 13.5 79.125 13.9062 78.0625 13.9062C76.5 13.9062 75.1563 13.0625 74.6875 11.7812L74.5938 11.5625H83.4375C83.5 11.25 83.5313 10.9375 83.5313 10.625ZM74.5625 9.6875L74.625 9.5C75.0313 8.15625 76.2188 7.34375 77.7188 7.34375C79.25 7.34375 80.4063 8.15625 80.8125 9.5L80.875 9.6875H74.5625Z" />
<path d="M68.5313 8.84374L70.6563 7.62499C69.6563 6.06249 67.875 5.18749 65.7188 5.18749C62.3125 5.18749 59.9375 7.43749 59.9375 10.6562C59.9375 13.875 62.3125 16.125 65.7188 16.125C67.875 16.125 69.6563 15.25 70.6563 13.6875L68.5313 12.4687C67.9688 13.4062 66.9688 13.9375 65.7188 13.9375C63.75 13.9375 62.4375 12.625 62.4375 10.6562C62.4375 8.68749 63.75 7.37499 65.7188 7.37499C66.9375 7.37499 67.9688 7.90624 68.5313 8.84374Z" />
<path d="M88.2188 1.75H85.7188V15.8125H88.2188V1.75Z" />
<path d="M40.1563 1.75H37.2813L31.7813 11.25L26.2813 1.75H23.375L31.7813 16.25L40.1563 1.75Z" />
<path d="M57.8438 8.0625C58.125 8.0625 58.4062 8.09375 58.6875 8.15625V5.5C56.5625 5.5625 54.5625 6.75 54.5625 8.21875V5.5H52.0625V15.8125H54.5625V11.3437C54.5625 9.40625 55.9062 8.0625 57.8438 8.0625Z" />
</svg>
);
}

View File

@ -1,70 +0,0 @@
import Link from 'next/link';
import GitHubIcon from 'components/icons/github';
import LogoIcon from 'components/icons/logo';
import VercelIcon from 'components/icons/vercel';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
const { SITE_NAME } = process.env;
export default async function Footer() {
const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
const menu = await getMenu('next-js-frontend-footer-menu');
return (
<footer className="border-t border-gray-700 bg-white text-black dark:bg-black dark:text-white">
<div className="mx-auto w-full max-w-7xl px-6">
<div className="grid grid-cols-1 gap-8 border-b border-gray-700 py-12 transition-colors duration-150 lg:grid-cols-12">
<div className="col-span-1 lg:col-span-3">
<a className="flex flex-initial items-center font-bold md:mr-24" href="/">
<span className="mr-2">
<LogoIcon className="h-8" />
</span>
<span>{SITE_NAME}</span>
</a>
</div>
{menu.length ? (
<nav className="col-span-1 lg:col-span-7">
<ul className="grid md:grid-flow-col md:grid-cols-3 md:grid-rows-4">
{menu.map((item: Menu) => (
<li key={item.title} className="py-3 md:py-0 md:pb-4">
<Link
href={item.path}
className="text-gray-800 transition duration-150 ease-in-out hover:text-gray-300 dark:text-gray-100"
>
{item.title}
</Link>
</li>
))}
</ul>
</nav>
) : null}
<div className="col-span-1 text-black dark:text-white lg:col-span-2">
<a aria-label="Github Repository" href="https://github.com/vercel/commerce">
<GitHubIcon className="h-6" />
</a>
</div>
</div>
<div className="flex flex-col items-center justify-between space-y-4 pb-10 pt-6 text-sm md:flex-row">
<p>
&copy; {copyrightDate} {SITE_NAME}. All rights reserved.
</p>
<div className="flex items-center text-sm text-white dark:text-black">
<span className="text-black dark:text-white">Created by</span>
<a
rel="noopener noreferrer"
href="https://vercel.com"
aria-label="Vercel.com Link"
target="_blank"
className="text-black dark:text-white"
>
<VercelIcon className="ml-3 inline-block h-6" />
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,31 @@
'use client'
import Logo from 'components/ui/logo/logo';
import { useTranslations } from 'next-intl';
interface FooterProps {}
const Footer = () => {
const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
const t = useTranslations('ui');
return (
<footer className="border-t border-ui-border bg-app">
<div className="mx-auto w-full py-2 px-4 lg:py-3 lg:px-8 2xl:px-16">
<div className="flex w-full justify-between items-baseline my-12 transition-colors duration-150">
<div className="">
<a className="flex flex-initial items-center font-bold md:mr-24" href="/">
<Logo />
</a>
</div>
<p>
&copy; {copyrightDate} - {t('copyright')}
</p>
</div>
</div>
</footer>
);
}
export default Footer;

View File

@ -0,0 +1,2 @@
export { default } from './footer';

View File

@ -0,0 +1,8 @@
import { cn } from 'lib/utils'
import { FC, ReactNode } from 'react'
const HeaderRoot: FC<{ children?: ReactNode }> = ({ children }) => {
return <header className={cn('w-full bg-app')}>{children}</header>
}
export default HeaderRoot

View File

@ -0,0 +1,73 @@
'use client'
import Logo from 'components/ui/logo/logo'
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from 'components/ui/navigation-menu'
import Link from 'next/link'
import { FC } from 'react'
import HeaderRoot from './header-root'
interface HeaderProps {}
const Header: FC<HeaderProps> = () => {
return (
<HeaderRoot>
<div className="relative flex flex-col">
<div className="relative flex items-center w-full justify-between py-2 px-4 h-14 lg:h-16 lg:py-3 lg:px-8 2xl:px-16">
<div className="flex items-center">
<Link
href="/"
className="cursor-pointer duration-100 ease-in-out absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 lg:relative lg:left-0 lg:top-0 lg:translate-x-0 lg:translate-y-0"
aria-label="Logo"
>
<Logo />
</Link>
</div>
<div className="absolute transform left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<NavigationMenu delayDuration={0} className="hidden lg:block">
<NavigationMenuList>
<NavigationMenuItem>
<Link href={'/kategori/junior'} legacyBehavior passHref>
<NavigationMenuLink
className={navigationMenuTriggerStyle()}
>
Junior
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href={'/kategori/trojor'} legacyBehavior passHref>
<NavigationMenuLink
className={navigationMenuTriggerStyle()}
>
Tröjor
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href={'/kategori/byxor'} legacyBehavior passHref>
<NavigationMenuLink
className={navigationMenuTriggerStyle()}
>
Byxor
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</div>
</div>
</HeaderRoot>
)
}
export default Header

View File

@ -0,0 +1,2 @@
export { default } from './header';

View File

@ -1,21 +1,18 @@
import Link from 'next/link';
import { Suspense } from 'react';
import Cart from 'components/cart';
import CartIcon from 'components/icons/cart';
import LogoIcon from 'components/icons/logo';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
import MobileMenu from './mobile-menu';
import Search from './search';
// import { getMenu } from 'lib/shopify';
// import { Menu } from 'lib/shopify/types';
// import MobileMenu from './mobile-menu';
// import Search from './search';
export default async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu');
// const menu = await getMenu('next-js-frontend-header-menu');
return (
<nav className="relative flex items-center justify-between bg-white p-4 dark:bg-black lg:px-6">
<div className="block w-1/3 md:hidden">
<MobileMenu menu={menu} />
{/* <MobileMenu menu={menu} /> */}
</div>
<div className="flex justify-self-center md:w-1/3 md:justify-self-start">
<div className="md:mr-4">
@ -23,7 +20,7 @@ export default async function Navbar() {
<LogoIcon className="h-8 transition-transform hover:scale-110" />
</Link>
</div>
{menu.length ? (
{/* {menu.length ? (
<ul className="hidden md:flex">
{menu.map((item: Menu) => (
<li key={item.title}>
@ -36,17 +33,17 @@ export default async function Navbar() {
</li>
))}
</ul>
) : null}
) : null} */}
</div>
<div className="hidden w-1/3 md:block">
<Search />
{/* <Search /> */}
</div>
<div className="flex w-1/3 justify-end">
<Suspense fallback={<CartIcon className="h-6" />}>
{/* <Suspense fallback={<CartIcon className="h-6" />}> */}
{/* @ts-expect-error Server Component */}
<Cart />
</Suspense>
{/* <Cart /> */}
{/* </Suspense> */}
</div>
</nav>
);

View File

@ -0,0 +1,30 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { i18n } from '../../i18n-config'
export default function LocaleSwitcher() {
const pathName = usePathname()
const redirectedPathName = (locale: string) => {
if (!pathName) return '/'
const segments = pathName.split('/')
segments[1] = locale
return segments.join('/')
}
return (
<div>
<p>Locale switcher:</p>
<ul>
{i18n.locales.map((locale) => {
return (
<li key={locale}>
<Link href={redirectedPathName(locale)}>{locale}</Link>
</li>
)
})}
</ul>
</div>
)
}

View File

@ -0,0 +1,2 @@
export { default } from './logo';

View File

@ -0,0 +1,21 @@
const Logo = ({ className = 'w-auto h-8', ...props }) => (
<div className="flex items-center space-x-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 109 80"
className={className}
{...props}
>
<path
fill="currentColor"
d="M54.6,0C32.8,0,15.1,17.9,15.1,40c0,10.6,4.3,18.1,4.6,18.8h20.6c-0.7-0.5-9.1-6.9-9.1-18.8 c0-13.1,10.5-23.7,23.4-23.7S78,26.9,78,40S67.5,63.7,54.6,63.7H0V80h54.6c21.8,0,39.5-17.9,39.5-40S76.5,0,54.6,0z"
/>
<path
fill="currentColor"
d="M109,63.7V80H75.3c7.2-3.7,13.2-9.4,17.4-16.3H109z"
/>
</svg>
</div>
)
export default Logo

View File

@ -0,0 +1,2 @@
export * from './navigation-menu';

View File

@ -0,0 +1,123 @@
'use client'
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
import { cva } from 'class-variance-authority'
import { ChevronDown } from 'lucide-react'
import * as React from 'react'
import { cn } from 'lib/utils'
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
'relative flex flex-1 items-center justify-center',
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
'group flex flex-1 list-none items-center justify-center',
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
'inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:bg-subtle disabled:opacity-50 disabled:pointer-events-none hover:bg-ui-hover data-[state=open]:bg-ui-hover data-[active]:bg-ui-active h-10 py-1 px-3 group w-max'
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDown
className="relative top-[1px] ml-2 h-5 w-5 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=to-start]:slide-out-to-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=from-end]:slide-in-from-right-52 top-0 left-0 w-full md:absolute md:w-auto',
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn('absolute left-0 top-full flex justify-center z-50')}>
<NavigationMenuPrimitive.Viewport
className={cn(
'origin-top-center data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:zoom-in-90 data-[state=closed]:zoom-out-95 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden drop-shadow bg-white md:w-[var(--radix-navigation-menu-viewport-width)]',
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=visible]:fade-in data-[state=hidden]:fade-out top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 bg-ui" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
NavigationMenu, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, NavigationMenuViewport, navigationMenuTriggerStyle
}

View File

@ -0,0 +1,45 @@
import { groq } from 'next-sanity'
import {
homePageQuery,
pageQuery,
} from '../lib/sanity/queries'
const getQueryFromSlug = (slugArray = []) => {
const docQuery = {
homePage: groq`${homePageQuery}`,
page: groq`${pageQuery}`,
}
let docType = ''
if (slugArray.length === 0) {
return {
docType: 'home',
queryParams: {},
query: docQuery.homePage,
}
}
const [slugStart] = slugArray
// We now have to re-combine the slug array to match our slug in Sanity.
let queryParams = {
slug: `/${slugArray.join('/')}`,
}
if (slugStart === 'articles' && slugArray.length === 2) {
docType = `article`
} else if (slugStart === 'work' && slugArray.length === 2) {
docType = `work`
} else {
docType = `page`
}
return {
docType,
queryParams,
query: docQuery[docType],
}
}
export default getQueryFromSlug

6
i18n-config.ts Normal file
View File

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

10
languages.json Normal file
View File

@ -0,0 +1,10 @@
{
"i18n": {
"languages": [
{ "id": "sv", "title": "Swedish", "isDefault": true },
{ "id": "nn", "title": "Norwegian" },
{ "id": "en", "title": "English" }
],
"base": "sv"
}
}

273
lib/sanity/queries.tsx Normal file
View File

@ -0,0 +1,273 @@
export const docQuery = `*[_type in ["home", "page", "category", "product"] && defined(slug.current)] {
_type,
"slug": slug.current,
"locale": language
}`
export const imageFields = `
alt,
crop,
hotspot,
asset-> {
...,
_type,
_ref,
}
`
export const seoFields = `
title,
description,
image {
${imageFields}
}
`
// Construct our "Modules" GROQ
export const modules = `
_type == 'hero' => {
_type,
_key,
label,
title,
variant,
headingLevel,
text,
link {
title,
reference->{title, slug, "locale": language}
},
image {
${imageFields}
}
},
_type == 'filteredProductList' => {
_type,
_key,
title,
itemsToShow,
productCategories[]->,
"products": *[_type == "product" && count(categories[@._ref in ^.^.productCategories[]._ref]) > 0] | order(publishedAt desc, _createdAt desc) [0...12] {
title,
"slug": slug.current,
price,
images[] {
${imageFields}
},
price {
value,
currencyCode,
retailPrice
},
}
},
_type == 'slider' => {
_type,
_key,
title,
sliderType,
categories[]-> {
title,
"slug": slug.current,
"locale": language,
image {
${imageFields}
},
},
products[]-> {
id,
title,
"slug": slug.current,
"locale": language,
images[] {
${imageFields}
},
price {
value,
currencyCode,
retailPrice
},
},
},
_type == 'banner' => {
_type,
_key,
title,
text,
image {
${imageFields}
}
},
_type == 'blurbSection' => {
disabled,
_type,
_key,
title,
mobileLayout,
desktopLayout,
imageFormat,
blurbs[]->{
title,
text,
link {
linkType,
title,
internalLink {
reference->
},
externalLink {
title,
url,
newWindow
}
},
"locale": language,
image {
${imageFields}
},
},
},
`
// Homepage query
export const homePageQuery = `*[_type == "home" && slug.current == "/" && language == $locale][0] {
_type,
title,
"slug": slug.current,
"locale": language,
"translations": *[_type == "translation.metadata" && references(^._id)].translations[].value->{
title,
slug,
"locale": language
},
content[] {
${modules}
},
seo {
${seoFields}
}
}`
// Page query
export const pageQuery = `*[_type == "page" && slug.current == $slug && language == $locale][0] {
_type,
title,
"slug": slug.current,
"locale": language,
"translations": *[_type == "translation.metadata" && references(^._id)].translations[].value->{
title,
slug,
"locale": language
},
content[] {
${modules}
},
seo {
${seoFields}
}
}`
// Product query
export const productQuery = `*[_type == "product" && slug.current == $slug && language == $locale][0] {
_type,
title,
"slug": slug.current,
"locale": language,
"translations": *[_type == "translation.metadata" && references(^._id)].translations[].value->{
title,
slug,
"locale": language
},
"product": {
id,
"name": title,
description,
"descriptionHtml": "",
images[] {
${imageFields}
},
price {
value,
currencyCode,
retailPrice
},
options[] {
id,
displayName,
values[] {
label,
"hexColors": hexColors.hex
}
},
"variants": []
},
seo {
${seoFields}
}
}`
// Category query
export const categoryQuery = `*[_type == "category" && slug.current == $slug && language == $locale][0] {
_type,
title,
"slug": slug.current,
"locale": language,
showBanner,
banner {
_type,
_key,
title,
text,
image {
${imageFields}
}
},
"translations": *[_type == "translation.metadata" && references(^._id)].translations[].value->{
title,
slug,
"locale": language
},
seo {
${seoFields}
}
}`
// Site settings query
export const siteSettingsQuery = `*[_type == "settings" && language == $locale][0] {
menuMain {
links[] {
title,
"link": reference->
}
},
seo,
socialMedia,
"locale": language,
notFoundPage {
title,
body,
category->{title},
"products": *[_type == "product" && ^.category->title in categories[]->title] | order(publishedAt desc, _createdAt desc) [0...4] {
id,
title,
"slug": slug.current,
price,
images[] {
${imageFields}
},
price {
value,
currencyCode,
retailPrice
},
}
},
cookieConsent {
title,
description,
consentText,
link {
reference->
}
}
}`

View File

@ -0,0 +1,12 @@
import { createClient } from "next-sanity";
export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!;
export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET!;
const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION!;
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
});

View File

@ -0,0 +1,7 @@
import { sanityClient } from './sanity.client'
import createImageUrlBuilder from '@sanity/image-url'
export const imageBuilder = createImageUrlBuilder(sanityClient)
export const urlForImage = (source: any) =>
imageBuilder.image(source).auto('format').fit('crop')

View File

@ -0,0 +1,20 @@
"use client";
import { definePreview } from "next-sanity/preview";
import { dataset, projectId } from "./sanity.client";
function onPublicAccessOnly() {
throw new Error("Unable to load preview as you're not logged in");
}
if (!projectId || !dataset) {
throw new Error(
"Missing projectId or dataset. Check your sanity.json or .env"
);
}
export const usePreview = definePreview({
projectId,
dataset,
onPublicAccessOnly,
});

View File

@ -1,6 +1,13 @@
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const createUrl = (pathname: string, params: URLSearchParams) => {
const paramsString = params.toString();
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
return `${pathname}${queryString}`;
};
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

178
messages/en.json Normal file
View File

@ -0,0 +1,178 @@
{
"Index": {
"title": "Welcome"
},
"ui": {
"button": {
"close": "Close",
"back": "Back",
"wishList": "Add to wishlist"
},
"previewBanner": {
"titlePart": "Preview of:",
"exitPreviewLabel": "Exit Preview"
},
"copyright": "All rights reserved."
},
"search": {
"search": "Search",
"placeholder": "Search for products...",
"globalPlaceholder": "Search products and categories...",
"categories": "Categories",
"priceRange": "Price range",
"clearAllFilters": "Clear all filters",
"submitTitle": "Submit your search query",
"clearTitle": "Clear your search query",
"resetTitle": "Reset your search query",
"seo": {
"title": "Search",
"description": "Search for product or category"
}
},
"product": {
"description": "Description",
"specification": "Specification",
"shippingAndReturns": "Shipping & returns",
"reviews": "reviews",
"noReviews": "No reviews",
"addToCart": "Add to cart",
"notAvailable": "Not available",
"related": "Related products",
"selectSize": "Select size"
},
"cart": {
"myCarttitle": "My cart",
"subTotal": "Subtotal",
"taxes": "Taxes",
"calculatedAtCheckout": "Calculated at checkout",
"free": "Free",
"shipping": "Shipping",
"estimatedShipping": "Estimated Shipping",
"total": "Total",
"proceedToCheckout": "Proceed to checkout",
"empty": {
"title": "Your cart is empty",
"text": "Add an item to begin shopping."
},
"error": "We couldnt process the purchase. Please check your card information and try again.",
"success": "Thank you for your order.",
"upsell": "Before you leave, take a look at these items. We picked them just for you.",
"addShippingAddress": "+ Add Shipping Address",
"addShippingMethod": "+ Add shipping method",
"addPaymentMethod": "+ Add Payment Method",
"continueShopping": "Continue Shopping",
"seo": {
"title": "My cart",
"description": "All products in my shopping cart are displayed here"
}
},
"checkout": {
"checkoutTitle": "Checkout",
"subTotal": "Subtotal",
"taxes": "Taxes",
"calculatedAtCheckout": "Calculated at checkout",
"free": "Free",
"shipping": "Shipping",
"total": "Total",
"confirmPurchase": "Confirm purchase"
},
"paymentMethod": {
"paymentMethodTitle": "Payment Method",
"addPaymentMethod": "Add Payment Method",
"cardHolderName": "Cardholder Name",
"cardNumber": "Card Number",
"expires": "Expires",
"cvc": "CVC",
"firstName": "First name",
"lastName": "Last name",
"company": "Company (optional)",
"streetAndHouseNumber": "Street and house number",
"apartment": "Apartment, Suite, Etc. (Optional)",
"postalCode": "Postal Code",
"city": "City",
"countryAndRegion": "Country/Region",
"continue": "Continue"
},
"shipping": {
"shippingTitle": "Shipping",
"shippingAdress": {
"same": "Same as billing address",
"different": "Use a different shipping address"
},
"addShippingAddress": "Add Shipping Address",
"firstName": "First name",
"lastName": "Last name",
"company": "Company (optional)",
"streetAndHouseNumber": "Street and house number",
"apartment": "Apartment, Suite, Etc. (Optional)",
"postalCode": "Postal Code",
"city": "City",
"countryAndRegion": "Country/Region",
"continue": "Continue"
},
"orders": {
"title": "My Orders",
"notFound": "No orders found",
"seo": {
"title": "My orders",
"description": "My orders are displayed here"
}
},
"profile": {
"title": "My Profile",
"fullName": "Full name",
"email": "Email",
"seo": {
"title": "My profile",
"description": "View and configure my account"
}
},
"wishlist": {
"title": "My wishlist",
"empty": "Your wishlist is empty",
"seo": {
"title": "My wishlist",
"description": "Here is my wish list"
}
},
"auth": {
"signUp": {
"firstName": "First name",
"lastName": "Last name",
"email": "Email",
"password": "Password",
"informationPre": "Info",
"information": "Passwords must be longer than 7 chars and include numbers.",
"register": "Sign up",
"existingAccount": "Do you have an account?",
"loginLabel": "Log In"
},
"login": {
"email": "Email",
"password": "Password",
"logIn": "Log In",
"noExistingAccount": "Don't have an account?",
"noExistingAccountLabel": "Sign Up",
"forgotPasswordText": "An email and password are required to login. Did you",
"forgotPasswordLinkLabel": "forgot your password?"
},
"forgotPassword": {
"email": "Email",
"recover": "Recover password",
"existingAccount": "Do you have an account?",
"loginLabel": "Log In"
}
},
"customerMenu": {
"myOrders": "My Orders",
"myProfile": "My Profile",
"myCart": "My Cart",
"logOut": "Log Out"
},
"notFound": {
"seo": {
"title": "Page not found",
"description": "Page not found"
}
}
}

176
messages/nn.json Normal file
View File

@ -0,0 +1,176 @@
{
"Index": {
"title": "Velkommen"
},
"ui": {
"button": {
"close": "Lukk",
"back": "Tilbake",
"wishList": "Legg til i favorittlisten"
},
"previewBanner": {
"titlePart": "Forhåndsvisning av:",
"exitPreviewLabel": "Avslutt forhåndsvisning"
},
"copyright": "Alle rettigheter forbeholdt."
},
"search": {
"search": "Søk",
"placeholder": "Søk etter produkter...",
"globalPlaceholder": "Søk produkter og kategorier...",
"categories": "Kategorier",
"priceRange": "Prisklasse",
"clearAllFilters": "Fjern alle filtre",
"submitTitle": "Send inn søket ditt",
"clearTitle": "Fjern søket ditt",
"resetTitle": "Tilbakestill søket ditt",
"seo": {
"title": "Søk",
"description": "Søk etter produkt eller kategori"
}
},
"product": {
"description": "Beskrivelse",
"specification": "Spesifikasjon",
"shippingAndReturns": "Frakt og retur",
"reviews": "anmeldelser",
"noReviews": "Ingen recensioner",
"addToCart": "Legg i handlekurv",
"notAvailable": "Ikke tilgjengelig",
"related": "Relaterte produkter",
"selectSize": "Velg størrelse"
},
"cart": {
"myCarttitle": "Handlekurven min",
"subTotal": "Delsum",
"taxes": "Skatter",
"calculatedAtCheckout": "Beregned i kassen",
"free": "Gratis",
"shipping": "Shipping",
"estimatedShipping": "Estimert shipping",
"total": "Totalt",
"proceedToCheckout": "Fortsett til utsjekking",
"empty": {
"title": "Handlekurven din er tom",
"text": "Legg til et produkt for å begynne å handle."
},
"error": "Vi kunne ikke fullføre kjøpet ditt. Sjekk kortinformasjonen din og prøv igjen.",
"success": "Takk for din bestilling.",
"addShippingAddress": "+ Legg til leveringsadresse",
"addShippingMethod": "+ Legg til leveringsmetode",
"addPaymentMethod": "+ Legg til betalingsmåte",
"continueShopping": "Fortsette å handle",
"seo": {
"title": "Min handlekurv",
"description": "Alle produktene i handlekurven din vises her"
}
},
"checkout": {
"checkoutTitle": "Kasse",
"subTotal": "Delsum",
"taxes": "Skatter",
"calculatedAtCheckout": "Beregned i kassen",
"free": "Gratis",
"shipping": "Shipping",
"total": "Totalt",
"confirmPurchase": "Bekreft kjøp"
},
"paymentMethod": {
"paymentMethodTitle": "Betalingsmåte",
"cardHolderName": "Kortholder",
"cardNumber": "Kortnummer",
"expires": "Utløpsdato",
"cvc": "CVC",
"firstName": "Fornavn",
"lastName": "Etternavn",
"company": "Firma (valgfritt)",
"streetAndHouseNumber": "Gate og husnummer",
"apartment": "Leilighet, suite osv. (valgfritt)",
"postalCode": "Post kode",
"city": "By",
"countryAndRegion": "Land/Region",
"continue": "Fortsette"
},
"shipping": {
"shippingTitle": "Shipping",
"shippingAdress": {
"same": "Samme som betalingsadresse",
"different": "Bruk en annen leveringsadresse"
},
"addShippingAddress": "Legg til leveringsadresse",
"firstName": "Fornavn",
"lastName": "Etternavn",
"company": "Firma (valgfritt)",
"streetAndHouseNumber": "Gate og husnummer",
"apartment": "Leilighet, suite osv. (valgfritt)",
"postalCode": "Post kode",
"city": "By",
"countryAndRegion": "Land/Region",
"continue": "Fortsette"
},
"orders": {
"title": "Mine bestillinger",
"notFound": "Ingen bestillinger funnet",
"seo": {
"title": "Mine bestillinger",
"description": "Mine bestillinger vises her"
}
},
"profile": {
"title": "Min profil",
"fullName": "Navn",
"email": "E-post",
"seo": {
"title": "Min profil",
"description": "Se og konfigurer kontoen min"
}
},
"wishlist": {
"title": "Min ønskeliste",
"empty": "Din ønskeliste er tom",
"seo": {
"title": "Min ønskeliste",
"description": "Ønskelisten min vises her"
}
},
"auth": {
"signUp": {
"firstName": "Fornavn",
"lastName": "Etternavn",
"email": "E-post",
"password": "Passord",
"informationPre": "Info",
"information": "Passord må være lengre enn 7 bokstaver og inneholde tall.",
"register": "Registrere",
"existingAccount": "Har du en konto?",
"loginLabel": "Logg inn"
},
"login": {
"email": "E-post",
"password": "Passord",
"logIn": "Logg inn",
"noExistingAccount": "Har du ikke en konto?",
"noExistingAccountLabel": "Registrere",
"forgotPasswordText": "E-post og passord er obligatorisk for å logge inn, har du",
"forgotPasswordLinkLabel": "glemt passordet?"
},
"forgotPassword": {
"email": "E-post",
"recover": "Tilbakestille passord",
"existingAccount": "Har du en konto?",
"loginLabel": "Logg inn"
}
},
"customerMenu": {
"myOrders": "Mine bestillinger",
"myProfile": "Min profil",
"myCart": "Min handlekurv",
"logOut": "Logg ut"
},
"notFound": {
"seo": {
"title": "Siden kan ikke bli funnet",
"description": "Siden kan ikke bli funnet"
}
}
}

176
messages/sv.json Normal file
View File

@ -0,0 +1,176 @@
{
"Index": {
"title": "Välkommen"
},
"ui": {
"button": {
"close": "Stäng",
"back": "Tillbaka",
"wishList": "Lägg till i favoritlista"
},
"previewBanner": {
"titlePart": "Förhandsvisning av:",
"exitPreviewLabel": "Avsluta förhandsvisning"
},
"copyright": "Alla rättigheter förbehållna."
},
"search": {
"search": "Sök",
"placeholder": "Sök efter produkter...",
"globalPlaceholder": "Sök produkter och kategorier...",
"categories": "Kategorier",
"priceRange": "Prisklass",
"clearAllFilters": "Rensa alla filter",
"submitTitle": "Skicka din sökfråga",
"clearTitle": "Rensa din sökfråga",
"resetTitle": "Återställ din sökfråga",
"seo": {
"title": "Sök",
"description": "Sök efter produkt eller kategori"
}
},
"product": {
"description": "Beskrivning",
"specification": "Specifikation",
"shippingAndReturns": "Frakt & retur",
"reviews": "recensioner",
"noReviews": "Inga recensioner",
"addToCart": "Lägg i varukorg",
"notAvailable": "Ej tillgänglig",
"related": "Relaterade produkter",
"selectSize": "Välj storlek"
},
"cart": {
"myCarttitle": "Min varukorg",
"subTotal": "Delsumma",
"taxes": "Moms",
"calculatedAtCheckout": "Beräknas i kassan",
"free": "Gratis",
"shipping": "Frakt",
"estimatedShipping": "Beräknad frakt",
"total": "Totalt",
"proceedToCheckout": "Fortsätt till kassan",
"empty": {
"title": "Din varukorg är tom",
"text": "Lägg till en produkt för att börja handla."
},
"error": "Vi kunde inte genomföra ditt köp. Var vänlig kontrollera din kort-information och försök igen.",
"success": "Tack för din order.",
"addShippingAddress": "+ Lägg till leveransadress",
"addShippingMethod": "+ Lägg till leveransmetod",
"addPaymentMethod": "+ Lägg till betalmetod",
"continueShopping": "Fortsätt handla",
"seo": {
"title": "Min varukorg",
"description": "Här visas alla produkter i min varukorg"
}
},
"checkout": {
"checkoutTitle": "Kassa",
"subTotal": "Delsumma",
"taxes": "Moms",
"calculatedAtCheckout": "Beräknas i kassan",
"free": "Gratis",
"shipping": "Frakt",
"total": "Totalt",
"confirmPurchase": "Bekräfta köp"
},
"paymentMethod": {
"paymentMethodTitle": "Betalmetod",
"cardHolderName": "Kortinnehavare",
"cardNumber": "Kortnummer",
"expires": "Utgångsdatum",
"cvc": "CVC",
"firstName": "Förnamn",
"lastName": "Efternamn",
"company": "Företag (valfritt)",
"streetAndHouseNumber": "Gata- och husnummer",
"apartment": "Lägenhet, svit, Etc. (valfritt)",
"postalCode": "Postnummer",
"city": "Stad",
"countryAndRegion": "Land/Region",
"continue": "Fortsätt"
},
"shipping": {
"shippingTitle": "Frakt",
"shippingAdress": {
"same": "Samma som faktureringsadress",
"different": "Använd en annan leveransadress"
},
"addShippingAddress": "Lägg till leveransadress",
"firstName": "Förnamn",
"lastName": "Efternamn",
"company": "Företag (valfritt)",
"streetAndHouseNumber": "Gata- och husnummer",
"apartment": "Lägenhet, svit, Etc. (valfritt)",
"postalCode": "Postnummer",
"city": "Stad",
"countryAndRegion": "Land/Region",
"continue": "Fortsätt"
},
"orders": {
"title": "Mina ordrar",
"notFound": "Inga ordrar funna",
"seo": {
"title": "Mina ordrar",
"description": "Här visas mina ordrar"
}
},
"profile": {
"title": "Min profil",
"fullName": "Namn",
"email": "Epost",
"seo": {
"title": "Min profil",
"description": "Se och konfigurera mitt konto"
}
},
"wishlist": {
"title": "Min önskelista",
"empty": "Din önskelista är tom",
"seo": {
"title": "Min önskelista",
"description": "Här visas min önskelista"
}
},
"auth": {
"signUp": {
"firstName": "Förnamn",
"lastName": "Efternamn",
"email": "Epost",
"password": "Lösenord",
"informationPre": "Info",
"information": "Lösenord måste vara längre än 7 bokstäver och innehålla nummer.",
"register": "Registrera dig",
"existingAccount": "Har du ett konto?",
"loginLabel": "Logga in"
},
"login": {
"email": "Epost",
"password": "Lösenord",
"logIn": "Logga in",
"noExistingAccount": "Har du inget konto?",
"noExistingAccountLabel": "Registrera dig",
"forgotPasswordText": "Epost och lösenord är obligatoriskt för att logga in, har du",
"forgotPasswordLinkLabel": "glömt ditt lösenord?"
},
"forgotPassword": {
"email": "Epost",
"recover": "Återställ lösenord",
"existingAccount": "Har du ett konto?",
"loginLabel": "Logga in"
}
},
"customerMenu": {
"myOrders": "Mina ordrar",
"myProfile": "Min profil",
"myCart": "Min varukorg",
"logOut": "Logga ut"
},
"notFound": {
"seo": {
"title": "Sidan kan inte hittas",
"description": "Sidan kan inte hittas"
}
}
}

14
middleware.ts Normal file
View File

@ -0,0 +1,14 @@
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
// A list of all locales that are supported
locales: ['sv', 'en', 'nn'],
// If this locale is matched, pathnames work without a prefix (e.g. `/about`)
defaultLocale: 'sv'
});
export const config = {
// Skip all paths that should not be internationalized
matcher: ['/((?!api|_next|.*\\..*).*)']
};

View File

@ -1,5 +1,12 @@
/** @type {import('next').NextConfig} */
module.exports = {
const { i18n } = require('./languages.json')
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.BUNDLE_ANALYZE === 'true',
})
module.exports = withBundleAnalyzer(
{
eslint: {
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
ignoreDuringBuilds: true
@ -9,12 +16,7 @@ module.exports = {
},
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
domains: ['cdn.sanity.io'],
},
}
]
}
};
);

View File

@ -13,7 +13,8 @@
"prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .",
"test": "pnpm lint && pnpm prettier:check",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"analyze": "BUNDLE_ANALYZE=true next build"
},
"git": {
"pre-commit": "lint-staged"
@ -23,25 +24,42 @@
},
"dependencies": {
"@headlessui/react": "^1.7.10",
"@next/bundle-analyzer": "^13.3.4",
"@portabletext/react": "^3.0.0",
"@radix-ui/react-navigation-menu": "^1.1.2",
"@sanity/icons": "2",
"@sanity/image-url": "^1.0.2",
"@sanity/types": "3",
"@sanity/ui": "1",
"@types/styled-components": "^5.1",
"@vercel/og": "^0.1.0",
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"framer-motion": "^8.4.0",
"is-empty-iterable": "^3.0.0",
"next": "13.3.1",
"lucide-react": "^0.194.0",
"next": "13.3.4",
"next-intl": "^2.13.1",
"next-sanity": "^4.2.0",
"react": "18.2.0",
"react-cookie": "^4.1.1",
"react-dom": "18.2.0"
"react-dom": "18.2.0",
"sanity": "3",
"styled-components": "^5.2",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5"
},
"devDependencies": {
"@playwright/test": "^1.31.2",
"@tailwindcss/typography": "^0.5.9",
"@types/negotiator": "^0.6.1",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"@vercel/git-hooks": "^1.0.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.35.0",
"eslint-config-next": "^13.3.1",
"eslint-config-next": "^13.3.4",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-unicorn": "^45.0.2",
"lint-staged": "^13.1.1",

4245
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
const plugin = require('tailwindcss/plugin');
const colors = require('tailwindcss/colors');
/** @type {import('tailwindcss').Config} */
const { fontFamily } = require('tailwindcss/defaultTheme')
const plugin = require('tailwindcss/plugin');
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
@ -9,44 +9,54 @@ module.exports = {
'./icons/**/*.{js,ts,jsx,tsx}',
'./app/**/*.{js,ts,jsx,tsx}'
],
safelist: ['outline-none'],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)']
},
colors: {
gray: colors.neutral,
hotPink: '#FF1966',
dark: '#111111',
light: '#FAFAFA',
violetDark: '#4c2889'
app: '#ffffff',
subtle: '#f8f8f8',
ui: '#f3f3f3',
'ui-hover': '#ededed',
'ui-active': '#e8e8e8',
'ui-separator': '#e2e2e2',
'ui-border': '#dbdbdb',
'ui-border-hover': '#c7c7c7',
solid: '#8f8f8f',
'solid-hover': '#858585',
'low-contrast': '#585858',
'high-contrast': '#333333',
blue: '#369eff',
green: '#55b467',
red: '#ec5d40',
yellow: '#ffcb47',
},
textColor: {
base: '#333333',
'low-contrast': '#585858',
'high-contrast': '#333333',
},
fontFamily: {
sans: ['var(--font-inter)', ...fontFamily.sans],
display: ['var(--font-inter-tight)', ...fontFamily.sans],
},
keyframes: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 }
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
},
marquee: {
'0%': { transform: 'translateX(0%)' },
'100%': { transform: 'translateX(-100%)' }
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
blink: {
'0%': { opacity: 0.2 },
'20%': { opacity: 1 },
'100% ': { opacity: 0.2 }
}
},
animation: {
fadeIn: 'fadeIn .3s ease-in-out',
carousel: 'marquee 60s linear infinite',
blink: 'blink 1.4s both infinite'
}
}
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
future: {
hoverOnlyWhenSupported: true
},
plugins: [
require('tailwindcss-animate'),
require('@tailwindcss/typography'),
plugin(({ matchUtilities, theme }) => {
matchUtilities(
@ -62,5 +72,8 @@ module.exports = {
}
);
})
]
};
],
future: {
hoverOnlyWhenSupported: true
},
}