From 10131e08bff6638dbb49bd5ad50517dcd768c318 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 1 Jul 2026 19:31:43 -0400 Subject: [PATCH] x --- apps/admin/package.json | 2 +- apps/admin/src/pages/ProductEditPage.tsx | 53 +--- apps/spa/src/pages/ProductDetailPage.tsx | 2 +- apps/website/package.json | 2 + .../[lang]/products/{[id] => [slug]}/page.tsx | 74 ++++- apps/website/src/app/sitemap.ts | 32 +- apps/website/src/components/ProductCard.tsx | 8 +- apps/website/src/lib/seo.ts | 12 +- apps/website/src/lib/slugify.ts | 22 ++ bun.lock | 290 ++++++++++++++++-- package.json | 6 +- 11 files changed, 415 insertions(+), 88 deletions(-) rename apps/website/src/app/[lang]/products/{[id] => [slug]}/page.tsx (74%) create mode 100644 apps/website/src/lib/slugify.ts diff --git a/apps/admin/package.json b/apps/admin/package.json index b401637..ff2950a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -13,11 +13,11 @@ "@homaroma/types": "workspace:*", "@tailwindcss/vite": "^4.2.2", "@types/papaparse": "^5.5.2", + "@uiw/react-md-editor": "^4.1.1", "lucide-react": "^1.7.0", "papaparse": "^5.5.3", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-quill-new": "^3.8.3", "react-router-dom": "^7.14.0", "recharts": "^3.8.1", "tailwindcss": "^4.2.2", diff --git a/apps/admin/src/pages/ProductEditPage.tsx b/apps/admin/src/pages/ProductEditPage.tsx index 32e5ea1..776d59e 100644 --- a/apps/admin/src/pages/ProductEditPage.tsx +++ b/apps/admin/src/pages/ProductEditPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef } from 'react' +import { useState, useEffect } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import { ArrowLeft, @@ -17,8 +17,7 @@ import { X } from 'lucide-react' import { API_BASE, type AttributeDefinition, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types' -import ReactQuill from 'react-quill-new' -import 'react-quill-new/dist/quill.snow.css' +import MDEditor from '@uiw/react-md-editor' import MediaLibraryModal from '../components/MediaLibraryModal' type EditableAttribute = { @@ -62,7 +61,7 @@ export default function ProductEditPage() { const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null) - const quillRef = useRef(null) + // removed quillRef const fixUrl = (url: string) => { if (!url) return '' @@ -97,30 +96,7 @@ export default function ProductEditPage() { setAvailableAttributes(data) } - const imageHandler = () => { - openMediaModal((url) => { - const quill = quillRef.current?.getEditor() - if (quill) { - const range = quill.getSelection(true) - quill.insertEmbed(range?.index || 0, 'image', url) - } - }) - } - - const modules = useMemo(() => ({ - toolbar: { - container: [ - [{ 'header': [1, 2, 3, false] }], - ['bold', 'italic', 'underline', 'strike', 'blockquote'], - [{'list': 'ordered'}, {'list': 'bullet'}], - ['link', 'image'], - ['clean'] - ], - handlers: { - image: imageHandler - } - } - }), []) + // removed imageHandler and modules useEffect(() => { const fetchData = async () => { @@ -489,21 +465,18 @@ export default function ProductEditPage() { /> -
+
Full Details ({activeLang.toUpperCase()})
- setDetails({ ...details, [activeLang]: val })} - modules={modules} - className="min-h-[300px]" - /> +
+ setDetails({ ...details, [activeLang]: val || '' })} + height={300} + preview="live" + /> +
-
diff --git a/apps/spa/src/pages/ProductDetailPage.tsx b/apps/spa/src/pages/ProductDetailPage.tsx index 70459fa..e33db1a 100644 --- a/apps/spa/src/pages/ProductDetailPage.tsx +++ b/apps/spa/src/pages/ProductDetailPage.tsx @@ -325,7 +325,7 @@ export default function ProductDetailPage() {

{t('product.details')}

product.id !== excludeId).slice(0, 6) } -export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise { - const { id, lang } = await params +export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise { + const { slug, lang } = await params + const id = extractIdFromSlug(slug) const product = await getProduct(id, lang) if (!product) return { title: 'Product Not Found' } @@ -40,12 +44,22 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin const categoryName = resolveCategoryName(product.category, lang) const description = resolveLocalized(product.description, lang) + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com' + const customLanguages: Record = {} + const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'] + for (const l of languages) { + const locName = resolveLocalized(product.name, l) || product.brand.name + customLanguages[l] = `${baseUrl}/${l}/products/${generateProductSlug(locName, product.id)}` + } + return generateSeoMetadata({ title: `${productName} ${categoryName ? `| ${categoryName}` : ''}`, description: description || `Premium ${categoryName} from .`, - path: `/products/${id}`, + path: `/products/${slug}`, lang, + customLanguages, openGraph: { + type: 'website', title: productName, description, images: product.images?.[0]?.url ? [product.images[0].url] : [], @@ -53,8 +67,9 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin }) } -export default async function ProductDetailPage({ params }: { params: Promise<{ id: string, lang: string }> }) { - const { id, lang } = await params +export default async function ProductDetailPage({ params }: { params: Promise<{ slug: string, lang: string }> }) { + const { slug, lang } = await params + const id = extractIdFromSlug(slug) const [productResult, categoriesResult, cmsLayoutResult] = await Promise.all([ getProductResult(id, lang), getCategoriesResult(lang), @@ -91,12 +106,50 @@ export default async function ProductDetailPage({ params }: { params: Promise<{ const related = await getRelated(product.category.id, product.id, lang) const productName = resolveLocalized(product.name, lang) || product.brand.name const categoryName = resolveCategoryName(product.category, lang) + const description = resolveLocalized(product.description, lang) const details = resolveLocalized(product.details, lang) const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName) const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute)) + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com' + const canonicalUrl = `${baseUrl}/${lang}/products/${slug}` + + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'Product', + name: productName, + description: details || description || undefined, + image: product.images?.map(img => img.url) || [imageUrl], + sku: product.variants?.[0]?.sku || undefined, + brand: { + '@type': 'Brand', + name: product.brand.name, + }, + offers: { + '@type': 'AggregateOffer', + offerCount: product.variants?.length || 1, + lowPrice: Math.min(...(product.variants?.map(v => v.price || 0) || [0])), + highPrice: Math.max(...(product.variants?.map(v => v.price || 0) || [0])), + priceCurrency: 'THB', + availability: (product.variants?.some(v => (v.stock ?? 0) > 0)) ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', + } + } + + const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: `${baseUrl}/${lang}` }, + { '@type': 'ListItem', position: 2, name: 'Shop', item: `${baseUrl}/${lang}/shop` }, + { '@type': 'ListItem', position: 3, name: categoryName, item: `${baseUrl}/${lang}/category/${product.category.id}` }, + { '@type': 'ListItem', position: 4, name: productName, item: canonicalUrl } + ] + } + return (
+