The OM Lounge — web đặt lịch spa song ngữ
Web giới thiệu và đặt lịch dịch vụ spa chạy song ngữ Việt - Anh: hai bộ URL, hai bộ metadata, giỏ hàng gắn với khung giờ hẹn.
- Next.js 16
- TypeScript
- Tailwind CSS
- next-intl
- Docker
Một web giới thiệu và đặt lịch cho tiệm spa - nail: khách xem thực đơn dịch vụ, thêm vào giỏ, chọn ngày giờ rồi xác nhận. Toàn bộ chạy song ngữ Việt - Anh trên Next.js App Router.
Song ngữ bắt đầu từ URL, không phải từ string#
Việc đầu tiên tôi chốt là mọi route nằm dưới src/app/[locale]/, còn danh sách locale
khai báo đúng một lần trong src/types/locale.ts bằng defineRouting. File proxy chỉ
có ba dòng: bọc createMiddleware(routing) và loại api, _next cùng các file tĩnh
ra khỏi matcher.
Nhờ vậy /vi/services và /en/services là hai URL thật, generateStaticParams
prerender cả hai, và nút đổi ngôn ngữ trên header chỉ cần
<Link href={pathname} locale={otherLocale}> — khách đang đứng ở trang nào thì đổi
ngôn ngữ vẫn ở nguyên trang đó, không bị đá về trang chủ.
Metadata cũng phải có hai bản#
Đây là chỗ dễ làm dối nhất: dịch giao diện xong là coi như xong đa ngôn ngữ, còn thẻ
title, description thì để chung một bản tiếng Anh. Tôi đẩy nguyên khối SEO vào
namespace seo trong hai file messages và đọc nó ở server:
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}): Promise<Metadata> {
const t = await getTranslations({ locale, namespace: 'seo.services' });
const canonicalUrl = `${siteUrl}/${locale}/services`;
return {
title: t('title'),
description: t('description'),
keywords: t('keywords'),
alternates: {
/* canonical trỏ về đúng locale, languages khai báo cặp vi/en
để search engine không coi hai bản là nội dung trùng lặp. */
canonical: canonicalUrl,
languages: {
vi: `${siteUrl}/vi/services`,
en: `${siteUrl}/en/services`,
},
},
openGraph: { url: canonicalUrl, locale: locale === 'vi' ? 'vi_VN' : 'en_US' },
};
}Bộ từ khoá của hai bản cũng không phải bản dịch của nhau: bản tiếng Việt nhắm "nail quận 7", "sơn gel", còn bản tiếng Anh nhắm cụm khác hẳn.
Ranh giới giữa string giao diện và dữ liệu dịch vụ#
Tôi tách làm hai loại. Chữ thuộc về giao diện — điều hướng, nhãn nút, thông báo trong giỏ — nằm trong file messages. Còn thực đơn dịch vụ, gồm tên, giá, thời lượng và các hiệu ứng kèm theo, nằm trong một module dữ liệu riêng.
Bản hiện tại thực đơn mới chỉ có tiếng Việt, và tôi cố tình không nhét nó vào messages. Thực đơn spa đổi theo tháng, sớm muộn cũng phải lấy từ backend; khi đó mỗi dịch vụ sẽ mang theo bản dịch của chính nó, chứ không phải một key i18n do frontend tự đặt.
Giỏ hàng spa không giống giỏ hàng bán lẻ#
Giỏ nằm trong một store Zustand, và có hai điểm lệch so với giỏ thương mại điện tử
thông thường. Thứ nhất, một dịch vụ có thể được thêm nhiều lần với hiệu ứng khác nhau,
nên key của mỗi dòng là id-variant chứ không phải id. Thứ hai, giỏ chỉ có nghĩa
khi gắn được vào một khung giờ còn trống:
export function getUpcomingDates(count: number) {
const dates: { label: string; day: string }[] = [];
const today = new Date();
const nowMinutes = today.getHours() * 60 + today.getMinutes();
const lastSlot = TIME_SLOTS[TIME_SLOTS.length - 1];
/* Qua khung giờ cuối trong ngày thì bỏ hôm nay, bắt đầu đếm từ mai. */
const startOffset =
nowMinutes >= slotToMinutes(lastSlot.time, lastSlot.period) ? 1 : 0;
for (let i = startOffset; i < count + startOffset; i++) {
const d = new Date(today);
d.setDate(today.getDate() + i);
dates.push({ label: DAY_NAMES[d.getDay()], day: d.toISOString().split('T')[0] });
}
return dates;
}Khung giờ cố định mỗi nửa tiếng. Nếu khách chọn hôm nay, mọi slot đã trôi qua bị
disabled; và khi khách đổi ngày, slot đang chọn tự bị xoá nếu nó vừa trở thành
quá khứ.
Đóng gói để deploy#
Dockerfile chia ba stage — cài dependency, build, rồi chạy — và dùng output: 'standalone'
để image cuối chỉ chứa đúng thứ cần thiết. Container chạy bằng user không phải root,
và tôi set TZ=Asia/Ho_Chi_Minh ngay trong stage runner vì toàn bộ logic khung giờ
đều so với giờ hiện tại. Makefile bọc docker buildx để build multi-arch cho cả
linux/arm64 lẫn linux/amd64, và bắt buộc truyền TAG trước khi push.
Kết quả#
- Hai locale là hai URL prerender sẵn, kèm canonical và
alternates.languagesriêng - Đổi ngôn ngữ giữ nguyên trang đang xem thay vì quay về trang chủ
- Giỏ hàng phân biệt được hiệu ứng, và không cho chọn khung giờ đã trôi qua
- Một image Docker multi-arch, chạy được trên cả máy x86 lẫn board ARM