diff --git a/apps/admin/src/components/page-builder/SectionSpacingControls.tsx b/apps/admin/src/components/page-builder/SectionSpacingControls.tsx new file mode 100644 index 0000000..88105c8 --- /dev/null +++ b/apps/admin/src/components/page-builder/SectionSpacingControls.tsx @@ -0,0 +1,130 @@ +import { MoveVertical, Sparkles } from 'lucide-react'; + +interface SectionSpacingControlsProps { + payload: Record; + updatePayloadValue: (field: string, value: any) => void; +} + +export default function SectionSpacingControls({ + payload, + updatePayloadValue, +}: SectionSpacingControlsProps) { + const currentTop = payload?.paddingTop || payload?.sectionPadding || 'normal'; + const currentBottom = payload?.paddingBottom || payload?.sectionPadding || 'normal'; + + const applyPreset = (top: string, bottom: string) => { + updatePayloadValue('paddingTop', top); + updatePayloadValue('paddingBottom', bottom); + updatePayloadValue('sectionPadding', top === bottom ? top : 'normal'); + }; + + return ( +
+
+
+ + + ระยะห่างแนวตั้งของบล็อก (Vertical Spacing) + +
+
+ Top: {currentTop} + • + Bottom: {currentBottom} +
+
+ + {/* Quick Spacing Presets */} +
+ + Preset: + + + + + +
+ + {/* Manual Fine-grained Controls */} +
+
+ + +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/admin/src/pages/PageBuilderPage.tsx b/apps/admin/src/pages/PageBuilderPage.tsx index d413337..131c7d4 100644 --- a/apps/admin/src/pages/PageBuilderPage.tsx +++ b/apps/admin/src/pages/PageBuilderPage.tsx @@ -68,6 +68,7 @@ import DividerForm from '../components/page-builder/DividerForm'; import AddSectionModal from '../components/page-builder/AddSectionModal'; import FeaturedTilesForm from '../components/page-builder/FeaturedTilesForm'; import HeroVideoForm from '../components/page-builder/HeroVideoForm'; +import SectionSpacingControls from '../components/page-builder/SectionSpacingControls'; import { HeroMinimalForm, HeroSplitMinimalForm, @@ -1496,6 +1497,14 @@ export default function PageBuilderPage() { )} + {/* ── Universal Section Spacing Controls (Full Control for all block types) ── */} + {editingSection.type?.toUpperCase() !== 'DIVIDER' && ( + + )} + {/* Dynamic Forms based on Type */} {editingSection.type?.toUpperCase() === 'HERO_MINIMAL' && ( +
{linkUrl ? ( {bannerInner} diff --git a/apps/spa/src/components/DynamicCategories.tsx b/apps/spa/src/components/DynamicCategories.tsx index 578bc17..2200d6f 100644 --- a/apps/spa/src/components/DynamicCategories.tsx +++ b/apps/spa/src/components/DynamicCategories.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { getCategoryImage, resolveCategoryMediaUrl, type Category, type CategoriesSectionPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getSectionPaddingClasses } from '../utils/spacing'; interface DynamicCategoriesProps { payload: CategoriesSectionPayload; @@ -26,7 +27,7 @@ export default function DynamicCategories({ payload, categories, loading }: Dyna }, [categories, categoryIds]); return ( -
+

diff --git a/apps/spa/src/components/DynamicEditorialTile.tsx b/apps/spa/src/components/DynamicEditorialTile.tsx index 3a8cc22..20b72c6 100644 --- a/apps/spa/src/components/DynamicEditorialTile.tsx +++ b/apps/spa/src/components/DynamicEditorialTile.tsx @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'; import type { EditorialTileSectionPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getSectionPaddingClasses } from '../utils/spacing'; interface DynamicEditorialTileProps { payload: EditorialTileSectionPayload; @@ -54,7 +55,7 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr return (
diff --git a/apps/spa/src/components/DynamicProductCarousel.tsx b/apps/spa/src/components/DynamicProductCarousel.tsx index 7b16e61..bc7fd1a 100644 --- a/apps/spa/src/components/DynamicProductCarousel.tsx +++ b/apps/spa/src/components/DynamicProductCarousel.tsx @@ -3,6 +3,7 @@ import { useRef } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getSectionPaddingClasses } from '../utils/spacing'; interface DynamicProductCarouselProps { payload: any; @@ -21,7 +22,7 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' }); return ( -
+
{(title || description) && (
diff --git a/apps/spa/src/components/DynamicProducts.tsx b/apps/spa/src/components/DynamicProducts.tsx index b9af2c3..47c216a 100644 --- a/apps/spa/src/components/DynamicProducts.tsx +++ b/apps/spa/src/components/DynamicProducts.tsx @@ -5,6 +5,7 @@ import ProductCard from './ProductCard'; import type { Category, Product, ProductsSectionPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getSectionPaddingClasses } from '../utils/spacing'; interface DynamicProductsProps { payload: ProductsSectionPayload & { products?: Product[] }; @@ -24,7 +25,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' }); return ( -
+
diff --git a/apps/spa/src/components/DynamicQuoteCallout.tsx b/apps/spa/src/components/DynamicQuoteCallout.tsx index 1df0a15..7259271 100644 --- a/apps/spa/src/components/DynamicQuoteCallout.tsx +++ b/apps/spa/src/components/DynamicQuoteCallout.tsx @@ -1,6 +1,7 @@ import type { QuoteCalloutPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getSectionPaddingClasses } from '../utils/spacing'; interface DynamicQuoteCalloutProps { payload: QuoteCalloutPayload & { role?: string }; @@ -14,7 +15,7 @@ export default function DynamicQuoteCallout({ payload }: DynamicQuoteCalloutProp const role = getLocalizedField(payload.role, lang); return ( -
+
diff --git a/apps/spa/src/components/DynamicRichText.tsx b/apps/spa/src/components/DynamicRichText.tsx index cfd7b18..9327f14 100644 --- a/apps/spa/src/components/DynamicRichText.tsx +++ b/apps/spa/src/components/DynamicRichText.tsx @@ -1,6 +1,7 @@ import type { PageSection } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getSectionPaddingClasses } from '../utils/spacing'; interface DynamicRichTextProps { section: PageSection; @@ -17,7 +18,7 @@ export default function DynamicRichText({ section }: DynamicRichTextProps) { if (!content) return null; return ( -
+
{title && (

diff --git a/apps/spa/src/components/DynamicScentNotesBreakdown.tsx b/apps/spa/src/components/DynamicScentNotesBreakdown.tsx index adcf33b..1a1edc0 100644 --- a/apps/spa/src/components/DynamicScentNotesBreakdown.tsx +++ b/apps/spa/src/components/DynamicScentNotesBreakdown.tsx @@ -1,6 +1,7 @@ import type { ScentNotesBreakdownPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getSectionPaddingClasses } from '../utils/spacing'; interface DynamicScentNotesBreakdownProps { payload: ScentNotesBreakdownPayload; @@ -28,7 +29,7 @@ export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNote const baseNotes = notes(payload.baseNotes); return ( -
+

{title || 'Scent Notes'} diff --git a/apps/spa/src/components/DynamicText.tsx b/apps/spa/src/components/DynamicText.tsx index 2e0fbe1..286b692 100644 --- a/apps/spa/src/components/DynamicText.tsx +++ b/apps/spa/src/components/DynamicText.tsx @@ -1,6 +1,7 @@ import type { TextSectionPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getSectionPaddingClasses } from '../utils/spacing'; interface DynamicTextProps { payload: TextSectionPayload; @@ -20,7 +21,7 @@ export default function DynamicText({ payload }: DynamicTextProps) { }[textAlign] || 'text-left'; return ( -
+
{title && (

diff --git a/apps/spa/src/components/sections/AccordionSection.tsx b/apps/spa/src/components/sections/AccordionSection.tsx index 3b6906f..f1cd9b3 100644 --- a/apps/spa/src/components/sections/AccordionSection.tsx +++ b/apps/spa/src/components/sections/AccordionSection.tsx @@ -3,6 +3,7 @@ import type { AccordionSectionPayload } from '@homaroma/types'; import { ChevronDown, ChevronUp } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../../utils/localization'; +import { getSectionPaddingClasses } from '../../utils/spacing'; interface AccordionSectionProps { payload: AccordionSectionPayload & { title?: string }; @@ -20,7 +21,7 @@ export default function AccordionSection({ payload }: AccordionSectionProps) { return (
diff --git a/apps/spa/src/components/sections/ContactInfoSection.tsx b/apps/spa/src/components/sections/ContactInfoSection.tsx index 68777b5..f044474 100644 --- a/apps/spa/src/components/sections/ContactInfoSection.tsx +++ b/apps/spa/src/components/sections/ContactInfoSection.tsx @@ -3,6 +3,7 @@ import type { ContactInfoSectionPayload } from '@homaroma/types'; import { MapPin, Mail, MessageCircle, Clock } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../../utils/localization'; +import { getSectionPaddingClasses } from '../../utils/spacing'; interface ContactInfoSectionProps { payload: ContactInfoSectionPayload & { title?: string }; @@ -30,7 +31,7 @@ export default function ContactInfoSection({ payload }: ContactInfoSectionProps) ] as const; return ( -
+
{title && (
diff --git a/apps/spa/src/utils/spacing.ts b/apps/spa/src/utils/spacing.ts new file mode 100644 index 0000000..e06dcab --- /dev/null +++ b/apps/spa/src/utils/spacing.ts @@ -0,0 +1,37 @@ +export function getSectionPaddingClasses( + payload?: Record, + fallback: 'normal' | 'none' | 'compact' | 'spacious' = 'normal', +): string { + const padTop = payload?.paddingTop || payload?.sectionPadding || fallback; + const padBottom = payload?.paddingBottom || payload?.sectionPadding || fallback; + + const getPt = (p: string) => { + switch (p) { + case 'none': + return 'pt-0'; + case 'compact': + return 'pt-6 md:pt-10'; + case 'spacious': + return 'pt-20 md:pt-28'; + case 'normal': + default: + return 'pt-10 md:pt-16'; + } + }; + + const getPb = (p: string) => { + switch (p) { + case 'none': + return 'pb-0'; + case 'compact': + return 'pb-6 md:pb-10'; + case 'spacious': + return 'pb-20 md:pb-28'; + case 'normal': + default: + return 'pb-10 md:pb-16'; + } + }; + + return `${getPt(padTop)} ${getPb(padBottom)}`; +} diff --git a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx index b868435..60328cd 100644 --- a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx +++ b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx @@ -64,6 +64,39 @@ function renderAccentTitle(text: string, accentClassName = 'ui-accent-text') { +export function getSectionPaddingClasses( + payload?: Record, + fallback: 'normal' | 'none' | 'compact' | 'spacious' = 'normal', +) { + const padTop = (typeof payload?.paddingTop === 'string' + ? payload.paddingTop + : (typeof payload?.sectionPadding === 'string' ? payload.sectionPadding : fallback)) as string + + const padBottom = (typeof payload?.paddingBottom === 'string' + ? payload.paddingBottom + : (typeof payload?.sectionPadding === 'string' ? payload.sectionPadding : fallback)) as string + + const getPt = (p: string) => { + switch (p) { + case 'none': return 'pt-0' + case 'compact': return 'pt-6 md:pt-10' + case 'spacious': return 'pt-20 md:pt-28' + case 'normal': default: return 'pt-10 md:pt-16' + } + } + + const getPb = (p: string) => { + switch (p) { + case 'none': return 'pb-0' + case 'compact': return 'pb-6 md:pb-10' + case 'spacious': return 'pb-20 md:pb-28' + case 'normal': default: return 'pb-10 md:pb-16' + } + } + + return `px-6 ${getPt(padTop)} ${getPb(padBottom)}` +} + function AnnouncementBar({ payload, lang }: { payload: Record, lang: string }) { const i18nLang = useLang() const resolvedLang = lang === 'all' ? i18nLang : lang @@ -1040,7 +1073,7 @@ function CategoriesSection({ const displayed = categoryIds.length ? sourceCategories.filter((category) => categoryIds.includes(category.id)) : sourceCategories return ( -
+

{title}

@@ -1095,7 +1128,7 @@ function ProductsSection({ payload, lang }: { payload: Record, const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' }) return ( -
+
@@ -1196,7 +1229,7 @@ function BannerSection({ payload, lang }: { payload: Record, la
) - return
{linkUrl ? {content} : content}
+ return
{linkUrl ? {content} : content}
} function TextSection({ payload, lang }: { payload: Record, lang: string }) { @@ -1211,7 +1244,7 @@ function TextSection({ payload, lang }: { payload: Record, lang : 'text-left' return ( -
+
{title &&

{title}

} {content &&
} @@ -1234,7 +1267,7 @@ function ArticleHeaderSection({ payload, lang }: { payload: Record +
{category &&

{category}

} @@ -1312,7 +1345,7 @@ function ArticleMediaSection({ payload, lang }: { payload: Record +
{!media ? copy : imagePosition === 'top' ? (
@@ -1344,7 +1377,7 @@ function ArticleGallerySection({ payload, lang }: { payload: Record +
{title &&

{title}

}
@@ -1378,7 +1411,7 @@ function ArticleFactsSection({ payload, lang }: { payload: Record +
{title &&

{title}

}
@@ -1430,7 +1463,7 @@ function ArticleVideoSection({ payload, lang }: { payload: Record +
{title &&

{title}

}
@@ -1461,7 +1494,7 @@ function ArticleReferencesSection({ payload, lang }: { payload: Record +

{title || 'References'}

    @@ -1498,7 +1531,7 @@ function ArticleTableSection({ payload, lang }: { payload: Record +
    {title &&

    {title}

    }
    @@ -1549,7 +1582,7 @@ function ArticleDownloadSection({ payload, lang }: { payload: Record +
    {title &&

    {title}

    } {description &&

    {description}

    } @@ -1594,7 +1627,7 @@ function ArticleCtaSection({ payload, lang }: { payload: Record const secondaryClass = "inline-flex min-h-12 items-center justify-center rounded-lg border border-line-primary px-6 py-3 font-semibold text-text-main transition-colors hover:bg-surface-page" return ( -
    +

    {title}

    {description &&

    {description}

    } @@ -1663,7 +1696,7 @@ function EditorialTileSection({ payload, lang }: { payload: Record +
    {imageUrl && (
    @@ -2115,7 +2148,7 @@ function ScentNotesBreakdownSection({ payload, lang }: { payload: Record +

    {title}

    @@ -2143,7 +2176,7 @@ function QuoteCalloutSection({ payload, lang }: { payload: Record +

    "

    @@ -2170,7 +2203,7 @@ function AccordionSection({ payload, lang }: { payload: Record, const iconColor = typeof payload.iconColor === 'string' ? payload.iconColor : undefined return ( -
    +
    {sectionTitle &&

    {sectionTitle}

    } {categories.map((category, categoryIndex) => { @@ -2228,7 +2261,7 @@ function ContactInfoSection({ payload, lang }: { payload: Record +
    {title && (
    diff --git a/packages/types/index.ts b/packages/types/index.ts index fb9f382..24af3be 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -635,6 +635,16 @@ export const DefaultTextColorSchemeSchema = (fallback: TextColorScheme = 'light' .optional() .transform((val) => (val ? (val as TextColorScheme) : fallback)); +export const SectionPaddingLevelSchema = z.enum(['none', 'compact', 'normal', 'spacious']); +export type SectionPaddingLevel = z.infer; + +export const SectionSpacingPayloadSchema = z.object({ + paddingTop: SectionPaddingLevelSchema.optional(), + paddingBottom: SectionPaddingLevelSchema.optional(), + sectionPadding: SectionPaddingLevelSchema.optional(), +}); +export type SectionSpacingPayload = z.infer; + export const HeroSectionPayloadSchema = z.object({ tagline: LocalizedStringSchema.optional(), title: LocalizedStringSchema.optional(),