This commit is contained in:
Your Name committed 2026-07-13 20:04:23 -04:00
1 parent de09293051
commit d8cca4fcfe
7 files changed
+876 -1

No files matched your search

+109
View File
@@ -429,6 +429,115 @@ Acceptance criteria:
- ถ้าต้องการให้ MCP ใช้งานได้ ให้เพิ่ม guidance ใน `section-toolkit.ts` - ถ้าต้องการให้ MCP ใช้งานได้ ให้เพิ่ม guidance ใน `section-toolkit.ts`
- build ทั้ง website/spa/admin ผ่าน - 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 ## Common Mistakes
1. `componentId` ไม่ตรงกับ registry key 1. `componentId` ไม่ตรงกับ registry key
+1 -1
View File
@@ -93,7 +93,7 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
}, },
CUSTOM: { CUSTOM: {
required: ["componentId"], required: ["componentId"],
example: `{ "componentId": "BakeryCategories", "props": { "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "layout": "horizontal", "items": [{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "href": "/shop", "icon": "bakery" }] } }` example: `BakeryCategories: { "componentId": "BakeryCategories", "props": { "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "layout": "horizontal", "items": [{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "href": "/shop", "icon": "bakery" }] } }. StoryFeatureMosaic: { "componentId": "StoryFeatureMosaic", "props": { "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "description": ${localizedStringExample}, "buttonText": ${localizedStringExample}, "buttonHref": "/about", "images": [{ "imageUrl": "https://...", "alt": ${localizedStringExample} }], "features": [{ "icon": "leaf", "title": ${localizedStringExample} }] } }`
} }
}; };
@@ -0,0 +1,193 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight, CakeSlice, CheckCircle, Heart, Leaf, Sparkles, type LucideIcon } from 'lucide-react';
import type { CustomBlockComponentProps } from '../../config/customRegistry';
import { getLocalizedField } from '../../utils/localization';
type LocalizedValue = string | Record<string, string>;
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<string, LucideIcon> = {
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<ResolvedImage[]>((acc, item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return acc;
const record = item as Record<string, unknown>;
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<ResolvedFeature[]>((acc, item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return acc;
const record = item as Record<string, unknown>;
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 (
<div className={`relative aspect-[4/3] overflow-hidden rounded-2xl bg-pop-black/5 shadow-sm ${className}`}>
{image.imageUrl ? (
<img
src={image.imageUrl}
alt={image.alt}
className="h-full w-full object-cover transition duration-700 hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm font-semibold text-pop-black/60">
{image.alt}
</div>
)}
</div>
);
}
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 (
<section className="ui-section-band bg-pop-bg">
<div className="ui-section-inner">
<div className="grid gap-8 lg:grid-cols-12 lg:items-center">
<div className="space-y-4 lg:col-span-3">
<div className="space-y-2">
{subtitle && (
<p className="text-sm font-semibold text-pop-pink md:text-base">
{subtitle}
</p>
)}
<h2 className="text-3xl font-semibold leading-tight text-pop-black md:text-4xl">
{title}
</h2>
</div>
{description && (
<p className="whitespace-pre-line text-sm leading-7 text-pop-black/70 md:text-base">
{description}
</p>
)}
{buttonText && buttonHref && (
<Link
to={buttonHref}
className="inline-flex items-center gap-3 rounded-full border border-pop-pink px-6 py-3 text-sm font-semibold text-pop-black transition hover:bg-pop-pink hover:text-pop-bg"
>
{buttonText}
<ArrowRight className="h-4 w-4" />
</Link>
)}
</div>
<div className="grid gap-4 sm:grid-cols-3 lg:col-span-7 lg:grid-cols-7">
{images.map((image, index) => (
<StoryImage key={`${image.imageUrl}-${index}`} image={image} index={index} />
))}
</div>
<div className="space-y-5 lg:col-span-2">
{features.map((feature, index) => {
const Icon = iconMap[feature.icon] ?? CheckCircle;
return (
<div key={`${feature.title}-${index}`} className="flex items-center gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-pop-pink/30 bg-pop-bg text-pop-pink shadow-sm">
<Icon className="h-7 w-7" strokeWidth={1.7} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold leading-snug text-pop-black">
{feature.title}
</h3>
{feature.subtitle && (
<p className="mt-1 text-xs leading-snug text-pop-black/65">
{feature.subtitle}
</p>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</section>
);
}
+2
View File
@@ -1,6 +1,7 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { CustomSectionPayload } from '@homaroma/types'; import type { CustomSectionPayload } from '@homaroma/types';
import BakeryCategories from '../components/custom/BakeryCategories'; import BakeryCategories from '../components/custom/BakeryCategories';
import StoryFeatureMosaic from '../components/custom/StoryFeatureMosaic';
export type CustomBlockComponentProps = { export type CustomBlockComponentProps = {
props: CustomSectionPayload['props']; props: CustomSectionPayload['props'];
@@ -10,4 +11,5 @@ export type CustomBlockComponent = ComponentType<CustomBlockComponentProps>;
export const customRegistry: Record<string, CustomBlockComponent> = { export const customRegistry: Record<string, CustomBlockComponent> = {
BakeryCategories, BakeryCategories,
StoryFeatureMosaic,
}; };
@@ -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<string, string>
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<string, LucideIcon> = {
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<ResolvedImage[]>((acc, item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return acc
const record = item as Record<string, unknown>
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<ResolvedFeature[]>((acc, item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return acc
const record = item as Record<string, unknown>
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 (
<div className={`relative aspect-[4/3] overflow-hidden rounded-2xl bg-surface-muted shadow-card ${className}`}>
{image.imageUrl ? (
<Image
src={image.imageUrl}
alt={image.alt}
fill
unoptimized
className="object-cover transition duration-700 hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm font-semibold text-muted-text">
{image.alt}
</div>
)}
</div>
)
}
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 (
<section className="ui-section-band bg-surface-page">
<div className="ui-section-shell">
<div className="grid gap-8 lg:grid-cols-12 lg:items-center">
<div className="space-y-4 lg:col-span-3">
<div className="space-y-2">
{subtitle && (
<p className="text-sm font-semibold text-brand-primary md:text-base">
{subtitle}
</p>
)}
<h2 className="text-3xl font-semibold leading-tight text-text-main md:text-4xl">
{title}
</h2>
</div>
{description && (
<p className="whitespace-pre-line text-sm leading-7 text-muted-text md:text-base">
{description}
</p>
)}
{buttonText && buttonHref && (
<Link
href={buttonHref}
className="inline-flex items-center gap-3 rounded-full border border-brand-primary px-6 py-3 text-sm font-semibold text-text-main transition hover:bg-brand-primary hover:text-surface-card"
>
{buttonText}
<ArrowRight className="h-4 w-4" />
</Link>
)}
</div>
<div className="grid gap-4 sm:grid-cols-3 lg:col-span-7 lg:grid-cols-7">
{images.map((image, index) => (
<StoryImage key={`${image.imageUrl}-${index}`} image={image} index={index} />
))}
</div>
<div className="space-y-5 lg:col-span-2">
{features.map((feature, index) => {
const Icon = iconMap[feature.icon] ?? CheckCircle
return (
<div key={`${feature.title}-${index}`} className="flex items-center gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-brand-primary/30 bg-surface-card text-brand-primary shadow-card">
<Icon className="h-7 w-7" strokeWidth={1.7} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold leading-snug text-text-main">
{feature.title}
</h3>
{feature.subtitle && (
<p className="mt-1 text-xs leading-snug text-muted-text">
{feature.subtitle}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
</section>
)
}
@@ -1,6 +1,7 @@
import type { ComponentType } from 'react' import type { ComponentType } from 'react'
import type { CustomSectionPayload } from '@homaroma/types' import type { CustomSectionPayload } from '@homaroma/types'
import BakeryCategories from '../components/custom/BakeryCategories' import BakeryCategories from '../components/custom/BakeryCategories'
import StoryFeatureMosaic from '../components/custom/StoryFeatureMosaic'
export type CustomBlockComponentProps = { export type CustomBlockComponentProps = {
props: CustomSectionPayload['props'] props: CustomSectionPayload['props']
@@ -11,4 +12,5 @@ export type CustomBlockComponent = ComponentType<CustomBlockComponentProps>
export const customRegistry: Record<string, CustomBlockComponent> = { export const customRegistry: Record<string, CustomBlockComponent> = {
BakeryCategories, BakeryCategories,
StoryFeatureMosaic,
} }
+376
View File
@@ -0,0 +1,376 @@
<!doctype html>
<html lang="th">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Brand Icon Preview</title>
<style>
:root {
--page: #fbf6ee;
--ink: #725234;
--ink-soft: #b78d63;
--line: #e8dccd;
--card: #fffaf2;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 34rem),
linear-gradient(135deg, #fffaf3 0%, var(--page) 45%, #fff8ee 100%);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(1120px, calc(100vw - 32px));
margin: 0 auto;
padding: 56px 0 72px;
}
header {
margin-bottom: 28px;
}
h1 {
margin: 0;
font-size: clamp(28px, 4vw, 44px);
line-height: 1.05;
letter-spacing: 0;
}
header p {
max-width: 680px;
margin: 12px 0 0;
color: rgba(114, 82, 52, 0.72);
font-size: 15px;
line-height: 1.7;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 34px 54px;
}
.tile {
text-align: center;
}
.icon-box {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border: 1px solid var(--line);
border-radius: 24px;
background: rgba(255, 250, 242, 0.72);
box-shadow: 0 12px 32px rgba(114, 82, 52, 0.06);
}
.icon-box svg {
width: 68%;
height: 68%;
color: var(--ink);
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.label {
margin-top: 10px;
font-size: 18px;
line-height: 1.3;
}
.label code {
display: block;
margin-top: 3px;
color: rgba(114, 82, 52, 0.58);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
@media (max-width: 900px) {
.grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 28px;
}
}
@media (max-width: 640px) {
main {
width: min(420px, calc(100vw - 28px));
padding-top: 34px;
}
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px 18px;
}
.icon-box {
border-radius: 18px;
}
.label {
font-size: 14px;
}
}
</style>
</head>
<body>
<main>
<header>
<h1>Brand Icon Preview</h1>
<p>
Preview SVG line icons for a HomAroma-style brand icon set. These are standalone SVG sketches using
<code>currentColor</code>, so the color can follow the theme later.
</p>
</header>
<section class="grid" aria-label="Brand icon preview grid">
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M18 26c0-6 28-6 28 0" />
<path d="M18 26v20c0 6 28 6 28 0V26" />
<path d="M24 35h16v12H24z" />
<path d="M32 27V14" />
<path d="M32 14c-5 7-5 12 0 16c5-4 5-9 0-16z" />
</svg>
</div>
<div class="label">1. Aroma Candle<code>aromaCandle</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M21 34c0-8 4-14 11-14s11 6 11 14v10c0 6-4 10-11 10S21 50 21 44V34z" />
<path d="M22 40c5-3 9-3 14 0s8 3 13 0" />
<path d="M22 46c5-3 9-3 14 0s8 3 13 0" />
<path d="M26 55h3" />
<path d="M35 55h3" />
<path d="M27 13c-3 4-3 7 0 11" />
<path d="M35 10c-3 5-3 8 0 13" />
<path d="M43 13c-3 4-3 7 0 11" />
<circle cx="32" cy="49" r="2" />
</svg>
</div>
<div class="label">2. Diffuser<code>diffuser</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M25 17h14" />
<path d="M24 21h16" />
<path d="M25 21v-9h14v9" />
<path d="M26 25h12c3 0 5 2 5 5v22c0 3-2 5-5 5H26c-3 0-5-2-5-5V30c0-3 2-5 5-5z" />
<path d="M21 37h22" />
<path d="M21 49h22" />
<path d="M31 46c-4-4-4-9 0-14c4 5 4 10 0 14z" />
<path d="M39 55c5-11 12-17 18-19" />
<path d="M48 44c-1-5 2-9 7-12c1 6-2 10-7 12z" />
<path d="M45 50c-5-2-8-6-8-12c5 2 8 6 8 12z" />
</svg>
</div>
<div class="label">3. Essential Oil<code>essentialOil</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M16 24l7-10l32 5l-7 10z" />
<path d="M16 24v23l32 6V29" />
<path d="M55 19v23l-7 11" />
<path d="M29 30h13v16H29z" />
<path d="M36 30v16" />
<path d="M25 50c6-8 17-8 23 0" />
<path d="M31 47c-3-2-7-1-9 2c4 1 7 1 9-2z" />
<path d="M39 47c3-2 7-1 9 2c-4 1-7 1-9-2z" />
<path d="M44 31h5v12h-5z" />
<path d="M45 27h3" />
</svg>
</div>
<div class="label">4. Gift Set<code>giftSet</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M34 55c0-14 6-25 19-32" />
<path d="M44 39c7-1 10-5 12-12c-7 1-11 5-12 12z" />
<path d="M30 30c-8 2-14-2-16-9c5-6 13-4 16 3c3-7 11-9 16-3c-2 7-8 11-16 9z" />
<path d="M30 24v13" />
<path d="M21 27c3 4 7 6 9 3" />
<path d="M39 27c-3 4-7 6-9 3" />
<circle cx="30" cy="30" r="3" />
<circle cx="13" cy="36" r="1.5" />
<circle cx="49" cy="17" r="1.5" />
<circle cx="52" cy="28" r="1.2" />
</svg>
</div>
<div class="label">5. Floral Scent<code>floralScent</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<circle cx="28" cy="35" r="15" />
<circle cx="43" cy="36" r="10" />
<path d="M28 20c0-8 6-11 12-10c-1 7-5 11-12 10z" />
<path d="M28 20c-2-6-7-9-13-8c2 6 6 9 13 8z" />
<path d="M43 27v18" />
<path d="M34 36h18" />
<path d="M36 30l14 12" />
<path d="M50 30L36 42" />
<path d="M21 29h.1" />
<path d="M17 38h.1" />
<path d="M27 46h.1" />
</svg>
</div>
<div class="label">6. Citrus Scent<code>citrusScent</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<circle cx="28" cy="35" r="15" />
<circle cx="28" cy="35" r="10" />
<circle cx="28" cy="35" r="5" />
<path d="M20 22c5-5 13-7 21-4" />
<path d="M41 19c4 3 6 8 6 13" />
<path d="M42 47c4-10 10-16 17-19" />
<path d="M50 36c-1-5 2-9 7-12c1 6-2 10-7 12z" />
<path d="M46 43c-5-2-8-6-8-12c5 2 8 6 8 12z" />
</svg>
</div>
<div class="label">7. Woody Scent<code>woodyScent</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M17 53c10-17 24-30 40-39" />
<path d="M30 39c-6-1-10-5-11-12c7 1 11 5 11 12z" />
<path d="M38 31c-1-7 2-12 8-16c2 7-1 13-8 16z" />
<path d="M45 25c6 0 10 3 12 9c-7 1-11-2-12-9z" />
<path d="M25 45c-7 0-12-4-14-10c7-1 12 3 14 10z" />
<path d="M43 44c5-2 10-1 14 4c-6 3-11 1-14-4z" />
<path d="M50 17c-3-4-3-8 1-12c4 5 4 9-1 12z" />
</svg>
</div>
<div class="label">8. Herbal Scent<code>herbalScent</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<circle cx="27" cy="27" r="17" />
<path d="M39 39l15 15" />
<path d="M48 48l5-5l6 6l-5 5z" />
</svg>
</div>
<div class="label">9. Search<code>search</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M32 53S13 38 13 24c0-8 6-13 13-13c4 0 7 2 10 6c3-4 6-6 10-6c7 0 13 5 13 13c0 14-19 29-27 29z" />
</svg>
</div>
<div class="label">10. Wishlist<code>wishlist</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M18 22h30l3 30H15z" />
<path d="M25 22v-4c0-5 4-9 9-9s9 4 9 9v4" />
<path d="M25 22h18" />
</svg>
</div>
<div class="label">11. Shopping Bag<code>shoppingBag</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<circle cx="32" cy="32" r="21" />
<path d="M11 32h42" />
<path d="M32 11c6 6 9 13 9 21s-3 15-9 21" />
<path d="M32 11c-6 6-9 13-9 21s3 15 9 21" />
<path d="M16 21h32" />
<path d="M16 43h32" />
</svg>
</div>
<div class="label">12. Language<code>language</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M8 39h26V24H8" />
<path d="M34 29h12l8 8v9H34z" />
<path d="M12 47a5 5 0 1 0 10 0a5 5 0 0 0-10 0z" />
<path d="M42 47a5 5 0 1 0 10 0a5 5 0 0 0-10 0z" />
<path d="M2 31h10" />
<path d="M5 25h9" />
<path d="M15 34h15" />
<path d="M18 29h10" />
</svg>
</div>
<div class="label">13. Free Shipping<code>freeShipping</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M16 20l18-8l18 8v24l-18 8l-18-8z" />
<path d="M16 20l18 8l18-8" />
<path d="M34 28v24" />
<path d="M25 16l18 8" />
<circle cx="47" cy="45" r="11" />
<path d="M47 38v8h6" />
</svg>
</div>
<div class="label">14. Same-Day Shipping<code>sameDayShipping</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M16 28h32v24H16z" />
<path d="M13 20h38v8H13z" />
<path d="M32 20v32" />
<path d="M18 28c6-9 14-9 14-1" />
<path d="M46 28c-6-9-14-9-14-1" />
<path d="M48 36l8 4l-5 3l2 6l-8-4z" />
</svg>
</div>
<div class="label">15. Gift Wrapping<code>giftWrapping</code></div>
</article>
<article class="tile">
<div class="icon-box">
<svg viewBox="0 0 64 64" aria-hidden="true">
<path d="M14 30c0-10 9-18 20-18s20 8 20 18s-9 18-20 18c-3 0-6-.5-9-2l-11 5l3-10c-2-3-3-7-3-11z" />
<circle cx="26" cy="30" r="1.8" />
<circle cx="34" cy="30" r="1.8" />
<circle cx="42" cy="30" r="1.8" />
</svg>
</div>
<div class="label">16. Customer Support<code>customerSupport</code></div>
</article>
</section>
</main>
</body>
</html>