From d8cca4fcfee3852ae3725ffa33d4d1718fc66a80 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 13 Jul 2026 20:04:23 -0400 Subject: [PATCH] x --- CUSTOM_BLOCK_FLOW_GUIDE.md | 109 +++++ apps/mcp-server/section-toolkit.ts | 2 +- .../components/custom/StoryFeatureMosaic.tsx | 193 +++++++++ apps/spa/src/config/customRegistry.ts | 2 + .../components/custom/StoryFeatureMosaic.tsx | 193 +++++++++ apps/website/src/config/customRegistry.ts | 2 + docs/brand-icon-preview.html | 376 ++++++++++++++++++ 7 files changed, 876 insertions(+), 1 deletion(-) create mode 100644 apps/spa/src/components/custom/StoryFeatureMosaic.tsx create mode 100644 apps/website/src/components/custom/StoryFeatureMosaic.tsx create mode 100644 docs/brand-icon-preview.html diff --git a/CUSTOM_BLOCK_FLOW_GUIDE.md b/CUSTOM_BLOCK_FLOW_GUIDE.md index cbc8874..f7565f7 100644 --- a/CUSTOM_BLOCK_FLOW_GUIDE.md +++ b/CUSTOM_BLOCK_FLOW_GUIDE.md @@ -429,6 +429,115 @@ Acceptance criteria: - ถ้าต้องการให้ MCP ใช้งานได้ ให้เพิ่ม guidance ใน `section-toolkit.ts` - build ทั้ง website/spa/admin ผ่าน +## Example: StoryFeatureMosaic + +`StoryFeatureMosaic` เป็น custom block สำหรับ layout แบบ story section: + +```text +ข้อความซ้าย + รูปภาพ 3 ช่องกลาง + feature icons ด้านขวา +``` + +ใช้ component id: + +```text +StoryFeatureMosaic +``` + +ตัวอย่าง payload: + +```json +{ + "componentId": "StoryFeatureMosaic", + "props": { + "title": { + "th": "เรื่องราวของปั้นคำหอม", + "en": "The Story of Pun-Kham-Hom", + "ja": "Pun-Kham-Homの物語", + "zh": "Pun-Kham-Hom 的故事", + "vi": "Câu chuyện của Pun-Kham-Hom", + "ko": "Pun-Kham-Hom 이야기" + }, + "subtitle": { + "th": "จากความหอม...สู่ความสุขในทุกคำ", + "en": "From fragrance to happiness in every bite", + "ja": "香りから、一口ごとの幸せへ", + "zh": "从香气到每一口的幸福", + "vi": "Từ hương thơm đến niềm vui trong từng miếng bánh", + "ko": "향기로움에서 한 입의 행복까지" + }, + "description": { + "th": "เราเริ่มต้นจากความรักในขนมไทยและเบเกอรี่ คัดสรรวัตถุดิบคุณภาพดี ปรุงด้วยความพิถีพิถัน ผสานภูมิปัญญาไทยเข้ากับความร่วมสมัย เพื่อส่งต่อความหอมและความสุขในทุกช่วงเวลาที่ได้แบ่งปัน", + "en": "We began with a love for Thai desserts and bakery, carefully selecting quality ingredients and blending Thai craft with a modern touch.", + "ja": "私たちはタイ菓子とベーカリーへの愛から始まり、厳選した素材と丁寧な手仕事で、伝統と現代らしさを重ねています。", + "zh": "我们从对泰式甜点与烘焙的热爱出发,精选优质食材,以细致工艺融合泰式智慧与现代风格。", + "vi": "Chúng tôi bắt đầu từ tình yêu dành cho bánh Thái và bánh nướng, chọn lọc nguyên liệu chất lượng và kết hợp tinh hoa Thái với phong cách hiện đại.", + "ko": "태국 디저트와 베이커리에 대한 애정에서 시작해 좋은 재료와 정성스러운 손길로 전통과 현대적인 감각을 담았습니다." + }, + "buttonText": { + "th": "อ่านเพิ่มเติม", + "en": "Read More", + "ja": "もっと読む", + "zh": "了解更多", + "vi": "Đọc thêm", + "ko": "더 보기" + }, + "buttonHref": "/about", + "images": [ + { + "imageUrl": "https://example.com/story-dessert.jpg", + "alt": { + "th": "การทำขนมไทย", + "en": "Thai dessert preparation" + } + }, + { + "imageUrl": "https://example.com/story-bakery.jpg", + "alt": { + "th": "ขนมปังอบใหม่", + "en": "Fresh bakery" + } + }, + { + "imageUrl": "https://example.com/story-team.jpg", + "alt": { + "th": "ทีมงานปั้นคำหอม", + "en": "Pun-Kham-Hom team" + } + } + ], + "features": [ + { + "icon": "leaf", + "title": { + "th": "คัดสรรวัตถุดิบคุณภาพดี", + "en": "Quality ingredients" + } + }, + { + "icon": "cake", + "title": { + "th": "ทำสดใหม่ทุกวัน", + "en": "Fresh daily" + } + }, + { + "icon": "heart", + "title": { + "th": "ใส่ใจในทุกรายละเอียด", + "en": "Made with care" + } + } + ] + } +} +``` + +Icon ที่รองรับตอนนี้: + +```text +leaf, cake, fresh, heart, care, sparkles, premium, check +``` + ## Common Mistakes 1. `componentId` ไม่ตรงกับ registry key diff --git a/apps/mcp-server/section-toolkit.ts b/apps/mcp-server/section-toolkit.ts index 444f1bc..e75678d 100644 --- a/apps/mcp-server/section-toolkit.ts +++ b/apps/mcp-server/section-toolkit.ts @@ -93,7 +93,7 @@ const ADD_PAGE_SECTION_GUIDANCE: Record; + +type MosaicImage = { + imageUrl: string; + alt?: LocalizedValue; +}; + +type FeatureItem = { + icon?: string; + title: LocalizedValue; + subtitle?: LocalizedValue; +}; + +type ResolvedImage = { + imageUrl: string; + alt: string; +}; + +type ResolvedFeature = { + icon: string; + title: string; + subtitle: string; +}; + +const iconMap: Record = { + leaf: Leaf, + cake: CakeSlice, + fresh: CakeSlice, + heart: Heart, + care: Heart, + sparkles: Sparkles, + premium: Sparkles, + check: CheckCircle, +}; + +const defaultImages: MosaicImage[] = [ + { imageUrl: '', alt: 'Dessert preparation' }, + { imageUrl: '', alt: 'Fresh bakery' }, + { imageUrl: '', alt: 'Our team' }, +]; + +const defaultFeatures: FeatureItem[] = [ + { icon: 'leaf', title: 'Quality ingredients' }, + { icon: 'cake', title: 'Fresh daily' }, + { icon: 'heart', title: 'Made with care' }, +]; + +function readString(value: unknown, fallback: string) { + return typeof value === 'string' && value.trim() ? value : fallback; +} + +function readLocalized(value: unknown, lang: string, fallback: string) { + const text = getLocalizedField(value, lang); + return text.trim() ? text : fallback; +} + +function readImages(value: unknown, lang: string): ResolvedImage[] { + const sourceImages = Array.isArray(value) ? value : defaultImages; + + return sourceImages.reduce((acc, item, index) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return acc; + + const record = item as Record; + const imageUrl = readString(record.imageUrl, ''); + const alt = readLocalized(record.alt, lang, `Story image ${index + 1}`); + + acc.push({ imageUrl, alt }); + return acc; + }, []).slice(0, 3); +} + +function readFeatures(value: unknown, lang: string): ResolvedFeature[] { + const sourceFeatures = Array.isArray(value) ? value : defaultFeatures; + + return sourceFeatures.reduce((acc, item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return acc; + + const record = item as Record; + const title = readLocalized(record.title, lang, ''); + if (!title) return acc; + + acc.push({ + icon: readString(record.icon, 'check'), + title, + subtitle: readLocalized(record.subtitle, lang, ''), + }); + return acc; + }, []).slice(0, 4); +} + +function StoryImage({ image, index }: { image: ResolvedImage; index: number }) { + const className = index === 0 + ? 'lg:col-span-3 lg:aspect-[4/3]' + : 'lg:col-span-2 lg:aspect-[4/3]'; + + return ( +
+ {image.imageUrl ? ( + {image.alt} + ) : ( +
+ {image.alt} +
+ )} +
+ ); +} + +export default function StoryFeatureMosaic({ props }: CustomBlockComponentProps) { + const { i18n } = useTranslation(); + const lang = i18n.language; + const title = readLocalized(props?.title, lang, 'Our Story'); + const subtitle = readLocalized(props?.subtitle, lang, 'From craft to happiness in every bite'); + const description = readLocalized(props?.description, lang, ''); + const buttonText = readLocalized(props?.buttonText, lang, ''); + const buttonHref = readString(props?.buttonHref, ''); + const images = readImages(props?.images, lang); + const features = readFeatures(props?.features, lang); + + return ( +
+
+
+
+
+ {subtitle && ( +

+ {subtitle} +

+ )} +

+ {title} +

+
+ {description && ( +

+ {description} +

+ )} + {buttonText && buttonHref && ( + + {buttonText} + + + )} +
+ +
+ {images.map((image, index) => ( + + ))} +
+ +
+ {features.map((feature, index) => { + const Icon = iconMap[feature.icon] ?? CheckCircle; + return ( +
+
+ +
+
+

+ {feature.title} +

+ {feature.subtitle && ( +

+ {feature.subtitle} +

+ )} +
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/apps/spa/src/config/customRegistry.ts b/apps/spa/src/config/customRegistry.ts index ae22772..86b44a8 100644 --- a/apps/spa/src/config/customRegistry.ts +++ b/apps/spa/src/config/customRegistry.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react'; import type { CustomSectionPayload } from '@homaroma/types'; import BakeryCategories from '../components/custom/BakeryCategories'; +import StoryFeatureMosaic from '../components/custom/StoryFeatureMosaic'; export type CustomBlockComponentProps = { props: CustomSectionPayload['props']; @@ -10,4 +11,5 @@ export type CustomBlockComponent = ComponentType; export const customRegistry: Record = { BakeryCategories, + StoryFeatureMosaic, }; diff --git a/apps/website/src/components/custom/StoryFeatureMosaic.tsx b/apps/website/src/components/custom/StoryFeatureMosaic.tsx new file mode 100644 index 0000000..7dc177d --- /dev/null +++ b/apps/website/src/components/custom/StoryFeatureMosaic.tsx @@ -0,0 +1,193 @@ +import Image from 'next/image' +import { ArrowRight, CakeSlice, CheckCircle, Heart, Leaf, Sparkles, type LucideIcon } from 'lucide-react' +import Link from '../LocalizedLink' +import type { CustomBlockComponentProps } from '../../config/customRegistry' +import { resolveLocalized } from '../../lib/localization' + +type LocalizedValue = string | Record + +type MosaicImage = { + imageUrl: string + alt?: LocalizedValue +} + +type FeatureItem = { + icon?: string + title: LocalizedValue + subtitle?: LocalizedValue +} + +type ResolvedImage = { + imageUrl: string + alt: string +} + +type ResolvedFeature = { + icon: string + title: string + subtitle: string +} + +const iconMap: Record = { + leaf: Leaf, + cake: CakeSlice, + fresh: CakeSlice, + heart: Heart, + care: Heart, + sparkles: Sparkles, + premium: Sparkles, + check: CheckCircle, +} + +const defaultImages: MosaicImage[] = [ + { imageUrl: '', alt: 'Dessert preparation' }, + { imageUrl: '', alt: 'Fresh bakery' }, + { imageUrl: '', alt: 'Our team' }, +] + +const defaultFeatures: FeatureItem[] = [ + { icon: 'leaf', title: 'Quality ingredients' }, + { icon: 'cake', title: 'Fresh daily' }, + { icon: 'heart', title: 'Made with care' }, +] + +function readString(value: unknown, fallback: string) { + return typeof value === 'string' && value.trim() ? value : fallback +} + +function readLocalized(value: unknown, lang: string, fallback: string) { + const text = resolveLocalized(value as LocalizedValue | null | undefined, lang) + return text.trim() ? text : fallback +} + +function readImages(value: unknown, lang: string): ResolvedImage[] { + const sourceImages = Array.isArray(value) ? value : defaultImages + + return sourceImages.reduce((acc, item, index) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return acc + + const record = item as Record + const imageUrl = readString(record.imageUrl, '') + const alt = readLocalized(record.alt, lang, `Story image ${index + 1}`) + + acc.push({ imageUrl, alt }) + return acc + }, []).slice(0, 3) +} + +function readFeatures(value: unknown, lang: string): ResolvedFeature[] { + const sourceFeatures = Array.isArray(value) ? value : defaultFeatures + + return sourceFeatures.reduce((acc, item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return acc + + const record = item as Record + const title = readLocalized(record.title, lang, '') + if (!title) return acc + + acc.push({ + icon: readString(record.icon, 'check'), + title, + subtitle: readLocalized(record.subtitle, lang, ''), + }) + return acc + }, []).slice(0, 4) +} + +function StoryImage({ image, index }: { image: ResolvedImage, index: number }) { + const className = index === 0 + ? 'lg:col-span-3 lg:aspect-[4/3]' + : 'lg:col-span-2 lg:aspect-[4/3]' + + return ( +
+ {image.imageUrl ? ( + {image.alt} + ) : ( +
+ {image.alt} +
+ )} +
+ ) +} + +export default function StoryFeatureMosaic({ props, lang }: CustomBlockComponentProps) { + const title = readLocalized(props?.title, lang, 'Our Story') + const subtitle = readLocalized(props?.subtitle, lang, 'From craft to happiness in every bite') + const description = readLocalized(props?.description, lang, '') + const buttonText = readLocalized(props?.buttonText, lang, '') + const buttonHref = readString(props?.buttonHref, '') + const images = readImages(props?.images, lang) + const features = readFeatures(props?.features, lang) + + return ( +
+
+
+
+
+ {subtitle && ( +

+ {subtitle} +

+ )} +

+ {title} +

+
+ {description && ( +

+ {description} +

+ )} + {buttonText && buttonHref && ( + + {buttonText} + + + )} +
+ +
+ {images.map((image, index) => ( + + ))} +
+ +
+ {features.map((feature, index) => { + const Icon = iconMap[feature.icon] ?? CheckCircle + return ( +
+
+ +
+
+

+ {feature.title} +

+ {feature.subtitle && ( +

+ {feature.subtitle} +

+ )} +
+
+ ) + })} +
+
+
+
+ ) +} diff --git a/apps/website/src/config/customRegistry.ts b/apps/website/src/config/customRegistry.ts index b652880..ea20e45 100644 --- a/apps/website/src/config/customRegistry.ts +++ b/apps/website/src/config/customRegistry.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react' import type { CustomSectionPayload } from '@homaroma/types' import BakeryCategories from '../components/custom/BakeryCategories' +import StoryFeatureMosaic from '../components/custom/StoryFeatureMosaic' export type CustomBlockComponentProps = { props: CustomSectionPayload['props'] @@ -11,4 +12,5 @@ export type CustomBlockComponent = ComponentType export const customRegistry: Record = { BakeryCategories, + StoryFeatureMosaic, } diff --git a/docs/brand-icon-preview.html b/docs/brand-icon-preview.html new file mode 100644 index 0000000..ad72cd3 --- /dev/null +++ b/docs/brand-icon-preview.html @@ -0,0 +1,376 @@ + + + + + + Brand Icon Preview + + + +
+
+

Brand Icon Preview

+

+ Preview SVG line icons for a HomAroma-style brand icon set. These are standalone SVG sketches using + currentColor, so the color can follow the theme later. +

+
+ +
+
+
+ +
+
1. Aroma CandlearomaCandle
+
+ +
+
+ +
+
2. Diffuserdiffuser
+
+ +
+
+ +
+
3. Essential OilessentialOil
+
+ +
+
+ +
+
4. Gift SetgiftSet
+
+ +
+
+ +
+
5. Floral ScentfloralScent
+
+ +
+
+ +
+
6. Citrus ScentcitrusScent
+
+ +
+
+ +
+
7. Woody ScentwoodyScent
+
+ +
+
+ +
+
8. Herbal ScentherbalScent
+
+ +
+
+ +
+
9. Searchsearch
+
+ +
+
+ +
+
10. Wishlistwishlist
+
+ +
+
+ +
+
11. Shopping BagshoppingBag
+
+ +
+
+ +
+
12. Languagelanguage
+
+ +
+
+ +
+
13. Free ShippingfreeShipping
+
+ +
+
+ +
+
14. Same-Day ShippingsameDayShipping
+
+ +
+
+ +
+
15. Gift WrappinggiftWrapping
+
+ +
+
+ +
+
16. Customer SupportcustomerSupport
+
+
+
+ +