Stage M7
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import type { ContentDetail, Locale } from '@sport/types';
|
||||
|
||||
import { Markdown } from '@/components/commerce/markdown';
|
||||
import { Link, redirect } from '@/i18n/navigation';
|
||||
import { getServerApi } from '@/lib/api';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string; slug: string }> };
|
||||
|
||||
async function fetchPost(locale: Locale, slug: string): Promise<ContentDetail | null> {
|
||||
try {
|
||||
return await getServerApi().content.getPost(locale, slug, { next: { revalidate: 300 } });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const post = await fetchPost(locale as Locale, slug);
|
||||
if (!post) return {};
|
||||
|
||||
const languages = Object.fromEntries(
|
||||
Object.entries(post.alternateSlugs ?? {}).map(([alt, altSlug]) => [
|
||||
alt,
|
||||
alt === 'vi' ? `/blog/${altSlug}` : `/${alt}/blog/${altSlug}`,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
title: post.seo.metaTitle ?? post.title,
|
||||
description: post.seo.metaDescription ?? undefined,
|
||||
alternates: {
|
||||
canonical: locale === 'vi' ? `/blog/${post.slug}` : `/${locale}/blog/${post.slug}`,
|
||||
languages,
|
||||
},
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.seo.metaDescription ?? undefined,
|
||||
images: post.coverImageUrl ? [{ url: post.coverImageUrl }] : undefined,
|
||||
type: 'article',
|
||||
publishedTime: post.publishedAt ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: PageProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const post = await fetchPost(locale as Locale, slug);
|
||||
if (!post) notFound();
|
||||
|
||||
// The API resolves a slug from any locale, so `/en/blog/<vi-slug>` finds the
|
||||
// post. Send it to this locale's canonical URL rather than serving one entry
|
||||
// at two addresses. The locale-aware redirect, never next/navigation's.
|
||||
if (post.slug !== slug) {
|
||||
redirect({ href: routes.post(post.slug), locale });
|
||||
}
|
||||
|
||||
const t = await getTranslations('blog');
|
||||
const format = await getFormatter();
|
||||
|
||||
return (
|
||||
<article className="max-w-page px-gutter mx-auto py-12">
|
||||
<Link href={routes.blog()} className="text-ink-500 hover:text-ink-950 text-xs">
|
||||
← {t('backToJournal')}
|
||||
</Link>
|
||||
|
||||
<header className="mt-6 max-w-prose">
|
||||
{post.publishedAt ? (
|
||||
<time
|
||||
dateTime={post.publishedAt}
|
||||
className="text-ink-400 text-xs uppercase tracking-widest"
|
||||
>
|
||||
{format.dateTime(new Date(post.publishedAt), { dateStyle: 'long' })}
|
||||
</time>
|
||||
) : null}
|
||||
|
||||
<h1 className="mt-3 text-3xl font-black uppercase leading-tight tracking-tight sm:text-4xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{post.authorName ? (
|
||||
<p className="text-ink-500 mt-3 text-sm">{t('by', { name: post.authorName })}</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{post.coverImageUrl ? (
|
||||
<div className="bg-ink-100 relative mt-8 aspect-[16/9] w-full overflow-hidden">
|
||||
<Image
|
||||
src={post.coverImageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1280px) 1200px, 100vw"
|
||||
priority
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-10">
|
||||
<Markdown>{post.body}</Markdown>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,87 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import Image from 'next/image';
|
||||
import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import type { ContentSummary, Locale } from '@sport/types';
|
||||
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { getServerApi } from '@/lib/api';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.blog' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'blog' });
|
||||
return { title: t('title'), description: t('intro') };
|
||||
}
|
||||
|
||||
export default async function BlogPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.blog');
|
||||
const t = await getTranslations('blog');
|
||||
const format = await getFormatter();
|
||||
|
||||
return <PageScaffold title={t('title')} description={t('body')} milestone="M7 — content" />;
|
||||
let posts: readonly ContentSummary[] = [];
|
||||
try {
|
||||
const result = await getServerApi().content.listPosts(
|
||||
locale as Locale,
|
||||
{ perPage: 24 },
|
||||
{ next: { revalidate: 300 } },
|
||||
);
|
||||
posts = result.items;
|
||||
} catch {
|
||||
// An empty journal beats an error page; the rest of the store still works.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-page px-gutter mx-auto py-12">
|
||||
<header className="max-w-2xl">
|
||||
<h1 className="text-3xl font-black uppercase tracking-tight sm:text-4xl">{t('title')}</h1>
|
||||
<p className="text-ink-500 mt-3 text-sm">{t('intro')}</p>
|
||||
</header>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-ink-500 mt-12 text-sm">{t('empty')}</p>
|
||||
) : (
|
||||
<ul className="mt-12 grid gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((post) => (
|
||||
<li key={post.id}>
|
||||
<Link href={routes.post(post.slug)} className="group block">
|
||||
<div className="bg-ink-100 relative aspect-[4/3] overflow-hidden">
|
||||
{post.coverImageUrl ? (
|
||||
<Image
|
||||
src={post.coverImageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{post.publishedAt ? (
|
||||
<time
|
||||
dateTime={post.publishedAt}
|
||||
className="text-ink-400 mt-4 block text-xs uppercase tracking-widest"
|
||||
>
|
||||
{format.dateTime(new Date(post.publishedAt), { dateStyle: 'medium' })}
|
||||
</time>
|
||||
) : null}
|
||||
|
||||
<h2 className="mt-2 text-base font-bold leading-snug group-hover:underline">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{post.excerpt ? (
|
||||
<p className="text-ink-500 mt-2 line-clamp-3 text-sm">{post.excerpt}</p>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import type { ContentDetail, Locale } from '@sport/types';
|
||||
|
||||
import { Markdown } from '@/components/commerce/markdown';
|
||||
import { redirect } from '@/i18n/navigation';
|
||||
import { getServerApi } from '@/lib/api';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string; slug: string }> };
|
||||
|
||||
async function fetchPage(locale: Locale, slug: string): Promise<ContentDetail | null> {
|
||||
try {
|
||||
return await getServerApi().content.getPage(locale, slug, { next: { revalidate: 300 } });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const page = await fetchPage(locale as Locale, slug);
|
||||
if (!page) return {};
|
||||
|
||||
const languages = Object.fromEntries(
|
||||
Object.entries(page.alternateSlugs ?? {}).map(([alt, altSlug]) => [
|
||||
alt,
|
||||
alt === 'vi' ? `/pages/${altSlug}` : `/${alt}/pages/${altSlug}`,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
title: page.seo.metaTitle ?? page.title,
|
||||
description: page.seo.metaDescription ?? undefined,
|
||||
alternates: {
|
||||
canonical: locale === 'vi' ? `/pages/${page.slug}` : `/${locale}/pages/${page.slug}`,
|
||||
languages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ContentPage({ params }: PageProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const page = await fetchPage(locale as Locale, slug);
|
||||
if (!page) notFound();
|
||||
|
||||
if (page.slug !== slug) {
|
||||
redirect({ href: routes.page(page.slug), locale });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-page px-gutter mx-auto py-12">
|
||||
<h1 className="text-3xl font-black uppercase tracking-tight sm:text-4xl">{page.title}</h1>
|
||||
<div className="mt-8">
|
||||
<Markdown>{page.body}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { LOCALES, type Locale } from '@sport/types';
|
||||
|
||||
import { ProductDetail } from '@/components/commerce/product-detail';
|
||||
import { ProductReviews } from '@/components/commerce/product-reviews';
|
||||
import { Link, redirect } from '@/i18n/navigation';
|
||||
import { getServerApi } from '@/lib/api';
|
||||
import { fetchProduct } from '@/lib/catalog';
|
||||
@@ -130,6 +131,8 @@ export default async function ProductPage({ params }: PageProps) {
|
||||
</nav>
|
||||
|
||||
<ProductDetail product={product} />
|
||||
|
||||
<ProductReviews productId={product.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Link } from '@/i18n/navigation';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
import { DiscountForm } from './discount-form';
|
||||
|
||||
/**
|
||||
* The bag.
|
||||
*
|
||||
@@ -151,8 +153,18 @@ export function CartView() {
|
||||
<aside className="border-ink-200 h-fit border p-6 lg:sticky lg:top-24">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('summary')}</h2>
|
||||
|
||||
<div className="border-ink-200 mt-6 border-b pb-6">
|
||||
<DiscountForm applied={cart.discounts} rejected={cart.rejectedDiscounts} />
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 space-y-3 text-sm">
|
||||
<Row label={t('subtotal')} value={formatMoney(cart.totals.subtotal, format)} />
|
||||
{cart.totals.discount.amount > 0 ? (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<dt className="text-ink-500">{t('discount')}</dt>
|
||||
<dd className="text-success">−{formatMoney(cart.totals.discount, format)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Named zeroes rather than hidden rows: the total is always the sum
|
||||
of parts a shopper can see, even before shipping exists (M9). */}
|
||||
<Row label={t('shipping')} value={t('shippingAtCheckout')} muted />
|
||||
|
||||
@@ -237,6 +237,12 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
|
||||
<dt className="text-ink-500">{t('subtotal')}</dt>
|
||||
<dd>{formatMoney(cart.totals.subtotal, format)}</dd>
|
||||
</div>
|
||||
{cart.totals.discount.amount > 0 ? (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink-500">{t('discount')}</dt>
|
||||
<dd className="text-success">−{formatMoney(cart.totals.discount, format)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink-500">{t('shipping')}</dt>
|
||||
<dd className="text-ink-400 text-xs">{t('shippingLater')}</dd>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Tag, X } from 'lucide-react';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { DISCOUNT_REJECTIONS, type AppliedDiscount, type RejectedDiscount } from '@sport/types';
|
||||
import { Button, Input } from '@sport/ui';
|
||||
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* Discount codes on the bag.
|
||||
*
|
||||
* Applied discounts are listed with what each one took off, because "you saved
|
||||
* 258.000 ₫" is the thing a shopper is actually checking. Automatic promotions
|
||||
* appear in the same list — from the customer's side there is no difference
|
||||
* between an offer they earned and one they typed for.
|
||||
*/
|
||||
export function DiscountForm({
|
||||
applied,
|
||||
rejected,
|
||||
}: {
|
||||
applied: readonly AppliedDiscount[];
|
||||
rejected: readonly RejectedDiscount[];
|
||||
}) {
|
||||
const t = useTranslations('cart');
|
||||
const format = useFormatter();
|
||||
const { applyCode, removeCode, pending } = useCart();
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const updated = await applyCode(trimmed);
|
||||
|
||||
/**
|
||||
* Cleared only when the code actually applied.
|
||||
*
|
||||
* The request succeeding is not the same as the discount applying — an
|
||||
* expired or fully-claimed code returns 200 with a rejection. Clearing on
|
||||
* the HTTP result wiped the box while the error explaining it was still on
|
||||
* screen, leaving nothing to correct.
|
||||
*/
|
||||
const accepted = updated?.discounts.some(
|
||||
(discount) => discount.code?.toUpperCase() === trimmed.toUpperCase(),
|
||||
);
|
||||
|
||||
if (accepted) setCode('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{applied.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{applied.map((discount) => (
|
||||
<li key={discount.id} className="flex items-center gap-2 text-sm">
|
||||
<Tag className="text-success size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{discount.name}</span>
|
||||
<span className="text-success font-medium">
|
||||
−{formatMoney(discount.amount, format)}
|
||||
</span>
|
||||
{discount.code ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeCode(discount.code as string)}
|
||||
aria-label={t('removeCode', { code: discount.code })}
|
||||
className="text-ink-400 hover:text-danger focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px] disabled:opacity-50"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{rejected.map((rejection) => (
|
||||
<p key={rejection.code} role="alert" className="text-danger text-xs">
|
||||
{rejection.reason === DISCOUNT_REJECTIONS.MINIMUM_NOT_MET && rejection.minimumSubtotal
|
||||
? t('discountError.minimum', {
|
||||
code: rejection.code,
|
||||
amount: formatMoney(rejection.minimumSubtotal, format),
|
||||
})
|
||||
: t(`discountError.${rejection.reason}`, { code: rejection.code })}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<form onSubmit={(event) => void submit(event)} className="flex gap-2">
|
||||
<Input
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder={t('discountPlaceholder')}
|
||||
aria-label={t('discountLabel')}
|
||||
className="h-10 uppercase"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="sm" disabled={pending || !code.trim()}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : null}
|
||||
{t('applyCode')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { Link } from '@/i18n/navigation';
|
||||
|
||||
/**
|
||||
* Renders editorial Markdown.
|
||||
*
|
||||
* `react-markdown` parses to React elements rather than to an HTML string, so
|
||||
* there is no `dangerouslySetInnerHTML` anywhere in this path. That makes
|
||||
* injection structurally impossible instead of sanitised-away: a `<script>` in
|
||||
* a post body is escaped text, not a tag, because it never becomes markup.
|
||||
*
|
||||
* Elements are mapped explicitly rather than inheriting browser defaults. The
|
||||
* storefront has a deliberate type scale, and unstyled `<h2>`/`<p>` from a CMS
|
||||
* body is exactly how editorial pages end up looking like a different website.
|
||||
*/
|
||||
export function Markdown({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="max-w-prose">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children: content }) => (
|
||||
<h2 className="mt-10 text-2xl font-black uppercase tracking-tight first:mt-0">
|
||||
{content}
|
||||
</h2>
|
||||
),
|
||||
// Demoted one level: the page already renders the title as <h1>, and
|
||||
// a second <h1> in the body breaks the document outline.
|
||||
h2: ({ children: content }) => (
|
||||
<h3 className="mt-8 text-lg font-bold first:mt-0">{content}</h3>
|
||||
),
|
||||
h3: ({ children: content }) => (
|
||||
<h4 className="mt-6 text-base font-semibold first:mt-0">{content}</h4>
|
||||
),
|
||||
p: ({ children: content }) => (
|
||||
<p className="text-ink-700 mt-4 text-sm leading-relaxed first:mt-0">{content}</p>
|
||||
),
|
||||
ul: ({ children: content }) => (
|
||||
<ul className="text-ink-700 mt-4 list-disc space-y-1.5 pl-5 text-sm">{content}</ul>
|
||||
),
|
||||
ol: ({ children: content }) => (
|
||||
<ol className="text-ink-700 mt-4 list-decimal space-y-1.5 pl-5 text-sm">{content}</ol>
|
||||
),
|
||||
blockquote: ({ children: content }) => (
|
||||
<blockquote className="border-ink-950 text-ink-600 mt-6 border-l-2 pl-4 italic">
|
||||
{content}
|
||||
</blockquote>
|
||||
),
|
||||
strong: ({ children: content }) => (
|
||||
<strong className="text-ink-950 font-semibold">{content}</strong>
|
||||
),
|
||||
hr: () => <hr className="border-ink-200 mt-8" />,
|
||||
code: ({ children: content }) => (
|
||||
<code className="bg-ink-100 rounded px-1.5 py-0.5 font-mono text-xs">{content}</code>
|
||||
),
|
||||
table: ({ children: content }) => (
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">{content}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children: content }) => (
|
||||
<th className="border-ink-200 border-b px-3 py-2 text-xs font-semibold uppercase tracking-widest">
|
||||
{content}
|
||||
</th>
|
||||
),
|
||||
td: ({ children: content }) => (
|
||||
<td className="border-ink-100 text-ink-700 border-b px-3 py-2">{content}</td>
|
||||
),
|
||||
a: ({ href, children: content }) => {
|
||||
const target = href ?? '';
|
||||
|
||||
/**
|
||||
* Internal links go through the locale-aware `Link`.
|
||||
*
|
||||
* A raw `<a href="/men">` inside a Vietnamese post drops the locale
|
||||
* prefix and bounces the reader to the default language — the same
|
||||
* bug already fixed three times elsewhere in this storefront.
|
||||
*/
|
||||
if (target.startsWith('/')) {
|
||||
return (
|
||||
<Link href={target} className="underline underline-offset-2">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={target}
|
||||
// Editorial copy is operator-authored, but `noopener` costs
|
||||
// nothing and `nofollow` keeps a compromised account from
|
||||
// handing out the store's link equity.
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Images are deliberately unsupported in bodies: they would bypass
|
||||
// the media library, hotlink to arbitrary hosts, and arrive without
|
||||
// dimensions, which is a layout shift on every page they appear on.
|
||||
img: () => null,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { browserApi } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
import { ReviewForm } from './review-form';
|
||||
|
||||
/**
|
||||
* Order confirmation, fetched by number + email.
|
||||
*
|
||||
@@ -131,6 +133,8 @@ export function OrderConfirmation({ orderId }: { orderId: string }) {
|
||||
</address>
|
||||
</section>
|
||||
|
||||
<ReviewForm orderId={orderId} email={order.email} />
|
||||
|
||||
<div className="text-center">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={routes.men()}>{t('keepShopping')}</Link>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Star } from 'lucide-react';
|
||||
import { getFormatter, getTranslations } from 'next-intl/server';
|
||||
|
||||
import type { ProductReviews as ProductReviewsPayload } from '@sport/api-client';
|
||||
import { cn } from '@sport/ui';
|
||||
|
||||
import { getServerApi } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Reviews for one product.
|
||||
*
|
||||
* A server component on purpose: reviews are the text a product page is
|
||||
* *about* as far as a search engine is concerned, and fetching them on the
|
||||
* client would leave them out of the HTML entirely. It also means no loading
|
||||
* spinner in the middle of the page.
|
||||
*
|
||||
* Degrades to nothing if the call fails — a product page missing its reviews
|
||||
* is a smaller failure than a product page that 500s.
|
||||
*/
|
||||
export async function ProductReviews({ productId }: { productId: string }) {
|
||||
const t = await getTranslations('reviews');
|
||||
const format = await getFormatter();
|
||||
|
||||
let payload: ProductReviewsPayload;
|
||||
try {
|
||||
payload = await getServerApi().reviews.listForProduct(
|
||||
productId,
|
||||
{ perPage: 10 },
|
||||
// 60s, matching the product listing. Reviews are moderated, so a new one
|
||||
// appearing a minute late is not a problem worth a cache miss per view.
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { summary, items } = payload;
|
||||
|
||||
return (
|
||||
<section id="reviews" className="border-ink-200 mt-16 border-t pt-10">
|
||||
<h2 className="text-xl font-black uppercase tracking-tight">{t('heading')}</h2>
|
||||
|
||||
{summary.count === 0 ? (
|
||||
<p className="text-ink-500 mt-4 text-sm">{t('empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-6 flex flex-col gap-8 sm:flex-row sm:items-start">
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-5xl font-black tabular-nums">
|
||||
{format.number(summary.average, { minimumFractionDigits: 1 })}
|
||||
</span>
|
||||
<span className="text-ink-400 text-sm">/ 5</span>
|
||||
</div>
|
||||
<Stars rating={Math.round(summary.average)} className="mt-2" />
|
||||
<p className="text-ink-500 mt-2 text-xs">{t('count', { count: summary.count })}</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The distribution, not just the average. A 3.0 built from straight
|
||||
3s and a 3.0 built from 5s and 1s are different products, and the
|
||||
bars are the only thing that says which one this is.
|
||||
*/}
|
||||
<ul className="w-full max-w-sm space-y-1.5">
|
||||
{summary.distribution.map((bucket) => {
|
||||
const share = summary.count === 0 ? 0 : (bucket.count / summary.count) * 100;
|
||||
|
||||
return (
|
||||
<li key={bucket.rating} className="flex items-center gap-3 text-xs">
|
||||
<span className="text-ink-500 w-8 shrink-0 tabular-nums">{bucket.rating}★</span>
|
||||
<span
|
||||
className="bg-ink-100 h-1.5 flex-1 overflow-hidden rounded-full"
|
||||
// The bar is decoration; the number beside it is the data.
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
className="bg-ink-950 block h-full rounded-full"
|
||||
style={{ width: `${share}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-ink-500 w-6 shrink-0 text-right tabular-nums">
|
||||
{bucket.count}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul className="divide-ink-100 mt-10 divide-y">
|
||||
{items.map((review) => (
|
||||
<li key={review.id} className="py-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Stars rating={review.rating} />
|
||||
{review.title ? <h3 className="text-sm font-semibold">{review.title}</h3> : null}
|
||||
</div>
|
||||
|
||||
<p className="text-ink-500 mt-1.5 text-xs">
|
||||
{review.authorName}
|
||||
{' · '}
|
||||
{format.dateTime(new Date(review.createdAt), { dateStyle: 'medium' })}
|
||||
{review.isVerifiedPurchase ? (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="text-ink-950 font-medium">{t('verified')}</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{review.body ? (
|
||||
<p className="text-ink-700 mt-3 whitespace-pre-line text-sm leading-relaxed">
|
||||
{review.body}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{payload.pageInfo.totalItems > items.length ? (
|
||||
<p className="text-ink-500 mt-4 text-xs">
|
||||
{t('showing', { shown: items.length, total: payload.pageInfo.totalItems })}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stars({ rating, className }: { rating: number; className?: string }) {
|
||||
return (
|
||||
<span className={cn('flex items-center gap-0.5', className)} aria-label={`${rating}/5`}>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
aria-hidden
|
||||
className={cn('size-4', star <= rating ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { Star } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Locale, ReviewableItem } from '@sport/types';
|
||||
import { Button, Input, cn } from '@sport/ui';
|
||||
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Rate the items on one order.
|
||||
*
|
||||
* Lives on the confirmation page because that URL is the only order-scoped page
|
||||
* a guest can reach — it is what the shopper bookmarks and what the receipt
|
||||
* email links to. The email is taken from the order rather than typed: holding
|
||||
* the link already proves ownership (see OrderConfirmation), so asking for it
|
||||
* again would be theatre that also puts the address back on screen.
|
||||
*/
|
||||
export function ReviewForm({ orderId, email }: { orderId: string; email: string }) {
|
||||
const t = useTranslations('reviewForm');
|
||||
const locale = useLocale() as Locale;
|
||||
|
||||
const [items, setItems] = useState<readonly ReviewableItem[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
browserApi.reviews
|
||||
.listReviewable({ orderId, email }, locale)
|
||||
.then((result) => {
|
||||
if (!cancelled) setItems(result);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a shopper who cannot review is shown nothing, not an error.
|
||||
// The order itself rendering is what matters on this page.
|
||||
if (!cancelled) setItems([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orderId, email, locale]);
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="border-ink-200 border p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('heading')}</h2>
|
||||
<p className="text-ink-500 mt-2 text-sm">{t('intro')}</p>
|
||||
|
||||
<ul className="divide-ink-100 mt-4 divide-y">
|
||||
{items.map((item) => (
|
||||
<ItemReview
|
||||
key={item.orderLineId}
|
||||
item={item}
|
||||
orderId={orderId}
|
||||
email={email}
|
||||
locale={locale}
|
||||
onSubmitted={setItems}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemReview({
|
||||
item,
|
||||
orderId,
|
||||
email,
|
||||
locale,
|
||||
onSubmitted,
|
||||
}: {
|
||||
item: ReviewableItem;
|
||||
orderId: string;
|
||||
email: string;
|
||||
locale: Locale;
|
||||
onSubmitted: (items: readonly ReviewableItem[]) => void;
|
||||
}) {
|
||||
const t = useTranslations('reviewForm');
|
||||
|
||||
const [rating, setRating] = useState(0);
|
||||
const [title, setTitle] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [authorName, setAuthorName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const updated = await browserApi.reviews.submit(
|
||||
{
|
||||
orderId,
|
||||
email,
|
||||
orderLineId: item.orderLineId,
|
||||
rating,
|
||||
title: title.trim() || null,
|
||||
body: body.trim() || null,
|
||||
authorName: authorName.trim(),
|
||||
},
|
||||
locale,
|
||||
);
|
||||
onSubmitted(updated);
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('failed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="py-4">
|
||||
<p className="text-sm font-medium">{item.productName}</p>
|
||||
<p className="text-ink-500 text-xs">{item.variantTitle}</p>
|
||||
|
||||
{item.reviewId ? (
|
||||
/*
|
||||
Already reviewed: shown as a receipt rather than removed from the
|
||||
list. A form that silently vanishes after submitting looks like it
|
||||
failed — and "waiting for approval" is information the shopper needs,
|
||||
otherwise they will wonder why their review is not on the page.
|
||||
*/
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<StarRating value={item.rating ?? 0} readOnly />
|
||||
<span className="text-ink-500 text-xs">
|
||||
{item.reviewStatus === 'APPROVED' ? t('published') : t('pending')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
<StarRating value={rating} onChange={setRating} />
|
||||
|
||||
{/* The rest of the form appears only once a rating is chosen —
|
||||
a star click is the whole commitment for most people, and four
|
||||
empty fields up front is what stops them making it. */}
|
||||
{rating > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={authorName}
|
||||
onChange={(event) => setAuthorName(event.target.value)}
|
||||
placeholder={t('namePlaceholder')}
|
||||
aria-label={t('namePlaceholder')}
|
||||
maxLength={120}
|
||||
/>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder={t('titlePlaceholder')}
|
||||
aria-label={t('titlePlaceholder')}
|
||||
maxLength={140}
|
||||
/>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={t('bodyPlaceholder')}
|
||||
aria-label={t('bodyPlaceholder')}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 text-sm outline-none"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-xs">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button size="sm" disabled={saving || !authorName.trim()} onClick={submit}>
|
||||
{saving ? t('sending') : t('submit')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function StarRating({
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: {
|
||||
value: number;
|
||||
onChange?: (value: number) => void;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const t = useTranslations('reviewForm');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1" role={readOnly ? 'img' : 'radiogroup'}>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = star <= value;
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Star
|
||||
key={star}
|
||||
aria-hidden
|
||||
className={cn('size-5', filled ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === star}
|
||||
aria-label={t('stars', { count: star })}
|
||||
onClick={() => onChange?.(star)}
|
||||
className="p-0.5"
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'size-6 transition-colors',
|
||||
filled ? 'fill-ink-950 text-ink-950' : 'text-ink-300 hover:text-ink-500',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getLocale, getTranslations } from 'next-intl/server';
|
||||
|
||||
import type { NavigationMenu } from '@sport/types';
|
||||
import type { Locale, NavigationMenu } from '@sport/types';
|
||||
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { getServerApi } from '@/lib/api';
|
||||
import { SPORT_NAV, routes } from '@/lib/routes';
|
||||
|
||||
export async function SiteFooter({ navigation }: { navigation: NavigationMenu | null }) {
|
||||
const t = await getTranslations('footer');
|
||||
const tSports = await getTranslations('sports');
|
||||
const locale = (await getLocale()) as Locale;
|
||||
|
||||
/**
|
||||
* Published pages, listed here because this is the only thing that makes
|
||||
* them reachable — a policy page nobody can navigate to is a page that does
|
||||
* not exist. Degrades to nothing rather than taking the footer down with it.
|
||||
*/
|
||||
let pages: { slug: string; title: string }[] = [];
|
||||
try {
|
||||
pages = [
|
||||
...(await getServerApi().content.listPageSlugs(locale, { next: { revalidate: 300 } })),
|
||||
];
|
||||
} catch {
|
||||
// The footer renders on every page; a content outage must not break it.
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="border-ink-200 bg-ink-950 text-ink-100 mt-24 border-t">
|
||||
@@ -80,8 +96,24 @@ export async function SiteFooter({ navigation }: { navigation: NavigationMenu |
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="border-ink-800 text-ink-500 border-t py-6 text-center text-xs">
|
||||
{t('rights', { year: new Date().getFullYear() })}
|
||||
<div className="border-ink-800 border-t py-6">
|
||||
{pages.length > 0 ? (
|
||||
<nav aria-label={t('policies')} className="mb-4">
|
||||
<ul className="text-ink-400 flex flex-wrap justify-center gap-x-6 gap-y-2 text-xs">
|
||||
{pages.map((page) => (
|
||||
<li key={page.slug}>
|
||||
<Link href={routes.page(page.slug)} className="hover:text-white">
|
||||
{page.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<p className="text-ink-500 text-center text-xs">
|
||||
{t('rights', { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,9 @@ interface CartState {
|
||||
addLine: (variantId: string, quantity?: number) => Promise<boolean>;
|
||||
setQuantity: (variantId: string, quantity: number) => Promise<void>;
|
||||
removeLine: (variantId: string) => Promise<void>;
|
||||
/** Resolves to the updated cart, or null if the request itself failed. */
|
||||
applyCode: (code: string) => Promise<Cart | null>;
|
||||
removeCode: (code: string) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -55,6 +58,28 @@ export function CartProvider({ locale, children }: { locale: Locale; children: R
|
||||
}, []);
|
||||
|
||||
/** Re-reads the bag after something outside this provider changed it. */
|
||||
/**
|
||||
* Like `run`, but hands the caller the cart it produced.
|
||||
*
|
||||
* A discount code needs this: the request succeeding and the code *applying*
|
||||
* are different outcomes, and only the returned cart can tell them apart.
|
||||
*/
|
||||
const runReturning = useCallback(async (operation: () => Promise<Cart>): Promise<Cart | null> => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
setCart(result);
|
||||
return result;
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : 'Something went wrong.');
|
||||
return null;
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setCart(await browserApi.commerce.getCart(locale));
|
||||
@@ -103,9 +128,13 @@ export function CartProvider({ locale, children }: { locale: Locale; children: R
|
||||
removeLine: async (variantId) => {
|
||||
await run(() => browserApi.commerce.removeCartLine(locale, variantId));
|
||||
},
|
||||
applyCode: (code) => runReturning(() => browserApi.commerce.applyDiscountCode(locale, code)),
|
||||
removeCode: async (code) => {
|
||||
await run(() => browserApi.commerce.removeDiscountCode(locale, code));
|
||||
},
|
||||
refresh,
|
||||
}),
|
||||
[cart, loading, pending, error, locale, run, refresh],
|
||||
[cart, loading, pending, error, locale, run, runReturning, refresh],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
|
||||
@@ -28,6 +28,8 @@ export const routes = {
|
||||
accountWishlist: () => '/account/wishlist',
|
||||
|
||||
blog: () => '/blog',
|
||||
post: (slug: string) => `/blog/${slug}`,
|
||||
page: (slug: string) => `/pages/${slug}`,
|
||||
} as const;
|
||||
|
||||
/** The sport facets that back `/sports/[sport]`. */
|
||||
|
||||
@@ -106,7 +106,8 @@
|
||||
"orders": "Orders",
|
||||
"wishlist": "Wishlist",
|
||||
"journal": "Journal",
|
||||
"rights": "© {year} Sport Store."
|
||||
"rights": "© {year} Sport Store.",
|
||||
"policies": "Information"
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
@@ -173,6 +174,21 @@
|
||||
"reduced": "{name} was reduced to {quantity} — that is all we have left.",
|
||||
"soldOut": "{name} sold out and was removed from your bag.",
|
||||
"unavailable": "An item is no longer available and was removed from your bag."
|
||||
},
|
||||
"discountPlaceholder": "Discount code",
|
||||
"discountLabel": "Discount code",
|
||||
"applyCode": "Apply",
|
||||
"discount": "Discount",
|
||||
"removeCode": "Remove code {code}",
|
||||
"discountError": {
|
||||
"NOT_FOUND": "We don't recognise {code}.",
|
||||
"EXPIRED": "{code} has expired.",
|
||||
"NOT_STARTED": "{code} is not active yet.",
|
||||
"USAGE_LIMIT_REACHED": "{code} has been fully claimed.",
|
||||
"MINIMUM_NOT_MET": "{code} needs a larger order.",
|
||||
"NOTHING_ELIGIBLE": "{code} does not apply to anything in your bag.",
|
||||
"NOT_COMBINABLE": "{code} cannot be combined with your current offer.",
|
||||
"minimum": "{code} needs a subtotal of at least {amount}."
|
||||
}
|
||||
},
|
||||
"checkout": {
|
||||
@@ -198,7 +214,8 @@
|
||||
"failed": "We could not place your order.",
|
||||
"loading": "Loading your bag…",
|
||||
"emptyBag": "There is nothing in your bag.",
|
||||
"backToShop": "Back to shopping"
|
||||
"backToShop": "Back to shopping",
|
||||
"discount": "Discount"
|
||||
},
|
||||
"confirmation": {
|
||||
"title": "Order placed",
|
||||
@@ -210,5 +227,32 @@
|
||||
"keepShopping": "Keep shopping",
|
||||
"notFound": "We could not find that order.",
|
||||
"backHome": "Back to home"
|
||||
},
|
||||
"reviews": {
|
||||
"heading": "Reviews",
|
||||
"empty": "No reviews yet. Buy it and be the first.",
|
||||
"count": "{count, plural, one {# review} other {# reviews}}",
|
||||
"verified": "Verified purchase",
|
||||
"showing": "Showing {shown} of {total}."
|
||||
},
|
||||
"reviewForm": {
|
||||
"heading": "Rate what you bought",
|
||||
"intro": "Reviews are checked before they appear on the product page.",
|
||||
"namePlaceholder": "The name to show on your review",
|
||||
"titlePlaceholder": "Sum it up (optional)",
|
||||
"bodyPlaceholder": "How did it fit, wear, wash? (optional)",
|
||||
"submit": "Submit review",
|
||||
"sending": "Sending…",
|
||||
"failed": "Could not send your review. Try again.",
|
||||
"pending": "Waiting to be published",
|
||||
"published": "Published",
|
||||
"stars": "{count, plural, one {# star} other {# stars}}"
|
||||
},
|
||||
"blog": {
|
||||
"title": "Journal",
|
||||
"intro": "Training notes, kit guides and what we are working on.",
|
||||
"empty": "Nothing published yet.",
|
||||
"backToJournal": "Back to the journal",
|
||||
"by": "By {name}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@
|
||||
"orders": "Đơn hàng",
|
||||
"wishlist": "Yêu thích",
|
||||
"journal": "Bài viết",
|
||||
"rights": "© {year} Sport Store."
|
||||
"rights": "© {year} Sport Store.",
|
||||
"policies": "Thông tin"
|
||||
},
|
||||
"account": {
|
||||
"title": "Tài khoản",
|
||||
@@ -173,6 +174,21 @@
|
||||
"reduced": "{name} đã giảm còn {quantity} — đó là số hàng còn lại.",
|
||||
"soldOut": "{name} đã hết hàng và được bỏ khỏi giỏ.",
|
||||
"unavailable": "Một sản phẩm không còn bán và đã được bỏ khỏi giỏ."
|
||||
},
|
||||
"discountPlaceholder": "Mã giảm giá",
|
||||
"discountLabel": "Mã giảm giá",
|
||||
"applyCode": "Áp dụng",
|
||||
"discount": "Giảm giá",
|
||||
"removeCode": "Bỏ mã {code}",
|
||||
"discountError": {
|
||||
"NOT_FOUND": "Không tìm thấy mã {code}.",
|
||||
"EXPIRED": "Mã {code} đã hết hạn.",
|
||||
"NOT_STARTED": "Mã {code} chưa có hiệu lực.",
|
||||
"USAGE_LIMIT_REACHED": "Mã {code} đã hết lượt sử dụng.",
|
||||
"MINIMUM_NOT_MET": "Mã {code} cần đơn hàng lớn hơn.",
|
||||
"NOTHING_ELIGIBLE": "Mã {code} không áp dụng cho sản phẩm trong giỏ.",
|
||||
"NOT_COMBINABLE": "Mã {code} không dùng chung với ưu đãi hiện tại.",
|
||||
"minimum": "Mã {code} cần đơn tối thiểu {amount}."
|
||||
}
|
||||
},
|
||||
"checkout": {
|
||||
@@ -198,7 +214,8 @@
|
||||
"failed": "Không đặt được đơn hàng.",
|
||||
"loading": "Đang tải giỏ hàng…",
|
||||
"emptyBag": "Giỏ hàng của bạn đang trống.",
|
||||
"backToShop": "Quay lại mua sắm"
|
||||
"backToShop": "Quay lại mua sắm",
|
||||
"discount": "Giảm giá"
|
||||
},
|
||||
"confirmation": {
|
||||
"title": "Đặt hàng thành công",
|
||||
@@ -210,5 +227,32 @@
|
||||
"keepShopping": "Tiếp tục mua sắm",
|
||||
"notFound": "Không tìm thấy đơn hàng.",
|
||||
"backHome": "Về trang chủ"
|
||||
},
|
||||
"reviews": {
|
||||
"heading": "Đánh giá",
|
||||
"empty": "Chưa có đánh giá nào. Hãy là người đầu tiên.",
|
||||
"count": "{count, plural, other {# đánh giá}}",
|
||||
"verified": "Đã mua hàng",
|
||||
"showing": "Đang hiển thị {shown} trên {total}."
|
||||
},
|
||||
"reviewForm": {
|
||||
"heading": "Đánh giá sản phẩm bạn đã mua",
|
||||
"intro": "Đánh giá được kiểm duyệt trước khi hiển thị trên trang sản phẩm.",
|
||||
"namePlaceholder": "Tên hiển thị trên đánh giá",
|
||||
"titlePlaceholder": "Tiêu đề ngắn (không bắt buộc)",
|
||||
"bodyPlaceholder": "Form dáng, chất vải, độ bền thế nào? (không bắt buộc)",
|
||||
"submit": "Gửi đánh giá",
|
||||
"sending": "Đang gửi…",
|
||||
"failed": "Không gửi được đánh giá. Vui lòng thử lại.",
|
||||
"pending": "Đang chờ duyệt",
|
||||
"published": "Đã hiển thị",
|
||||
"stars": "{count, plural, other {# sao}}"
|
||||
},
|
||||
"blog": {
|
||||
"title": "Bài viết",
|
||||
"intro": "Ghi chép tập luyện, hướng dẫn chọn đồ và những gì chúng tôi đang làm.",
|
||||
"empty": "Chưa có bài viết nào.",
|
||||
"backToJournal": "Quay lại danh sách bài viết",
|
||||
"by": "Bởi {name}"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user