Files
homaroma/packages/types/index.ts
T
2026-07-14 07:30:16 -04:00

604 lines
18 KiB
TypeScript

import { z } from "zod";
export interface Brand {
id: number
name: string
description?: string | null
}
export interface Category {
id: number
name: TranslatableString
imageUrl?: string | null
_count?: { products: number }
translations?: { langCode: string; name: string }[]
createdAt?: string
updatedAt?: string
}
export interface Variant {
id: number
sku?: string | null
barcode?: string | null
price: number | null
stock?: number
option1: string
option2: string
option3: string
bomItems?: BomItem[]
}
export interface RawMaterial {
id: number
name: string
unit: string
pricePerUnit: number
}
export interface BomItem {
id?: number
rawMaterialId: number
rawMaterial?: RawMaterial
quantity: number
}
export interface ProductImage {
id: number
url: string
order: number
}
export type TranslatableString = string | Record<string, string>;
export interface AttributeDefinitionTranslation {
id: number
attributeDefinitionId: number
langCode: string
label: string
description?: string | null
}
export interface AttributeDefinition {
id: number
code: string
label: TranslatableString
description?: TranslatableString | null
type: 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'SELECT' | 'JSON'
unit?: string | null
options?: TranslatableString[] | null
isRequired: boolean
isFilterable: boolean
sortOrder: number
categoryId?: number | null
category?: Category | null
translations?: AttributeDefinitionTranslation[]
}
export interface ProductAttributeTranslation {
id: number
productAttributeId: number
langCode: string
valueText: string
}
export interface ProductAttribute {
id: number
productId: number
attributeDefinitionId: number
attributeDefinition: AttributeDefinition
valueText?: string | null
valueNumber?: number | null
valueBoolean?: boolean | null
valueJson?: any | null
value?: string | number | boolean | Record<string, any> | null
valueTranslations?: Record<string, string>
translations?: ProductAttributeTranslation[]
}
export interface Product {
id: number
name: TranslatableString
description: TranslatableString | null
details: TranslatableString | null
price: number | null
categoryId: number
category: Category
brand: Brand
options?: Array<{ name: TranslatableString; values: TranslatableString[] }> | null
variants: Variant[]
images: ProductImage[]
attributes?: ProductAttribute[]
}
const DEFAULT_DEV_API_PORT = 3051
function resolveApiBase() {
const viteEnv = (typeof import.meta !== 'undefined' ? (import.meta as any).env : undefined)
const envValue = viteEnv?.VITE_API_URL || (globalThis as any).process?.env?.VITE_API_URL
if (envValue) {
const isDev = Boolean(viteEnv?.DEV)
const isLocalhostUrl = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/api\/?$/.test(envValue)
const win = (globalThis as any).window
if (isDev && typeof win !== 'undefined' && isLocalhostUrl) {
return `${win.location.protocol}//${win.location.hostname}:${DEFAULT_DEV_API_PORT}/api`
}
return envValue
}
const win = (globalThis as any).window
if (typeof win !== 'undefined') {
return `${win.location.protocol}//${win.location.hostname}:${DEFAULT_DEV_API_PORT}/api`
}
return 'http://localhost:3000/api'
}
export const API_BASE = resolveApiBase()
export const CATEGORY_IMAGES: Record<string, string> = {
'Oil Ozone': '/images/oil_ozone.png',
'Aroma Oil': '/images/oil_ozone.png',
'Reed Diffuser': '/images/reed_diffuser.png',
'Room Spray': '/images/reed_diffuser.png',
'Car Diffuser': '/images/oil_ozone.png',
}
export function getCategoryImage(name: string): string {
return CATEGORY_IMAGES[name] ?? '/images/oil_ozone.png'
}
export function formatPrice(price: number | null | undefined): string {
if (price == null) return '—'
return `฿${price.toLocaleString()}`
}
export interface Hero {
id: number
tagline: string
title: string
description: string
imageUrl: string
buttonText: string
buttonLink: string
isActive: boolean
createdAt: string
updatedAt: string
}
export interface StoreSettings {
id: number
brandName: string
logoUrl?: string | null
themePreset?: string | null
themeTokens?: Record<string, string> | null
shippingFee: number
bankName: string
bankAccountName: string
bankAccountNumber: string
pointsEarnRate: number
footerDescription?: string | null
facebookUrl?: string | null
instagramUrl?: string | null
tiktokUrl?: string | null
newsletterTitle?: string | null
newsletterDescription?: string | null
newsletterPlaceholder?: string | null
newsletterButtonText?: string | null
footerSupportHeading?: any | null
footerCopyrightText?: any | null
scrollRevealEnabled?: boolean | null
scrollRevealStyle?: string | null
scrollRevealDuration?: number | null
}
export interface MenuItem {
id: number
label: string
url: string
order: number
columnImage?: string | null
hoverImage?: string | null
translations?: any[]
}
export interface Menu {
id: number
name: string
items: MenuItem[]
payload?: any
rootImage?: string | null
}
export interface OrderItem {
id: number
orderId: number
productId: number
variantId?: number | null
quantity: number
priceAtPurchase: number
productName: string
variantName?: string | null
productImage?: string | null
}
export interface Order {
id: number
customerName: string
customerEmail: string
customerPhone: string
shippingAddress: string
subtotal?: number
promoLevel1Name?: string | null
promoLevel1Disc?: number
promoLevel2Name?: string | null
promoLevel2Disc?: number
couponCode?: string | null
discountAmount?: number
pointsUsed?: number
pointsDiscount?: number
pointsEarned?: number
totalAmount: number
shippingFee: number
status: string
paymentSlipUrl?: string | null
trackingNo?: string | null
customerId?: number | null
customer?: any | null
items: OrderItem[]
createdAt: string
updatedAt: string
}
export interface Article {
id: number
slug: string
title: string
coverImage: string | null
content: string
isPublished: boolean
createdAt: string
updatedAt: string
}
export interface CartItem {
cartItemId: string
productId: number
variantId?: number | null
quantity: number
price: number
productName: string
variantName?: string | null
productImage?: string | null
}
export interface Page {
id: number
slug: string
title: string
isPublished: boolean
sections?: PageSection[]
createdAt?: string
updatedAt?: string
}
export interface PageSection {
id: number
pageId: number
type: string // e.g. 'HERO' | 'CATEGORIES' | 'PRODUCTS' | 'BANNER' | 'TEXT'
order: number
payload: any // Specific payload depending on type
isActive: boolean
createdAt?: string
updatedAt?: string
}
export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
export const HeroSectionPayloadSchema = z.object({
tagline: LocalizedStringSchema,
title: LocalizedStringSchema,
description: LocalizedStringSchema,
imageUrl: z.string().min(1),
buttonText: LocalizedStringSchema,
buttonLink: z.string(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
});
export type HeroSectionPayload = z.infer<typeof HeroSectionPayloadSchema>;
export const CategoriesSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
subtitle: LocalizedStringSchema.optional(),
categoryIds: z.array(z.number()).optional(),
});
export type CategoriesSectionPayload = z.infer<typeof CategoriesSectionPayloadSchema>;
export const ProductsSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
subtitle: LocalizedStringSchema.optional(),
type: z.enum(['featured', 'brand', 'custom']),
categoryId: z.number().optional(),
brandId: z.number().optional(),
productIds: z.array(z.number()).optional(),
limit: z.number().optional(),
layout: z.enum(['grid', 'carousel']).optional(),
enableQuickShop: z.boolean().optional(),
});
export type ProductsSectionPayload = z.infer<typeof ProductsSectionPayloadSchema>;
export const BannerSectionPayloadSchema = z.object({
imageUrl: z.string().min(1),
linkUrl: z.string().optional(),
title: LocalizedStringSchema.optional(),
description: LocalizedStringSchema.optional(),
buttonText: LocalizedStringSchema.optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
});
export type BannerSectionPayload = z.infer<typeof BannerSectionPayloadSchema>;
export const TextSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
content: LocalizedStringSchema,
textAlign: z.enum(['left', 'center', 'right']).optional(),
});
export type TextSectionPayload = z.infer<typeof TextSectionPayloadSchema>;
export const AnnouncementBarSectionPayloadSchema = z.object({
slides: z.array(LocalizedStringSchema).min(1),
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
speed: z.number().optional(),
});
export type AnnouncementBarSectionPayload = z.infer<typeof AnnouncementBarSectionPayloadSchema>;
export const NewsletterSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
description: LocalizedStringSchema,
buttonText: LocalizedStringSchema,
placeholderText: LocalizedStringSchema.optional(),
showPrivacyCheckbox: z.boolean(),
privacyText: LocalizedStringSchema.optional(),
});
export type NewsletterSectionPayload = z.infer<typeof NewsletterSectionPayloadSchema>;
export const EditorialTileSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
description: LocalizedStringSchema,
imageUrl: z.string().min(1),
imagePosition: z.enum(['left', 'right']),
buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(),
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
});
export type EditorialTileSectionPayload = z.infer<typeof EditorialTileSectionPayloadSchema>;
export const EditorialCarouselSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
layout: z.enum(['standard', 'split_screen']).optional(),
imagePosition: z.enum(['left', 'right']).optional(),
slides: z.array(z.object({
category: LocalizedStringSchema,
title: LocalizedStringSchema,
description: LocalizedStringSchema,
imageUrl: z.string().min(1),
buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(),
})).min(1)
});
export type EditorialCarouselSectionPayload = z.infer<typeof EditorialCarouselSectionPayloadSchema>;
export interface CustomerAddress {
id: number
customerId: number
recipientName: string
phone: string
addressLine: string
subdistrict: string
district: string
province: string
zipcode: string
isDefault: boolean
createdAt?: string
updatedAt?: string
}
export const HeroArticleSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
subtitle: LocalizedStringSchema.optional(),
author: LocalizedStringSchema.optional(),
category: LocalizedStringSchema.optional(),
publishDate: z.string().optional(),
date: z.string().optional(),
imageUrl: z.string().optional(),
backgroundImageUrl: z.string().optional(),
overlayColor: z.string().optional(),
});
export type HeroArticleSectionPayload = z.infer<typeof HeroArticleSectionPayloadSchema>;
export const RichTextSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
content: LocalizedStringSchema,
});
export type RichTextSectionPayload = z.infer<typeof RichTextSectionPayloadSchema>;
export const ScentNotesBreakdownSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
description: LocalizedStringSchema.optional(),
topNotes: z.union([LocalizedStringSchema, z.array(LocalizedStringSchema)]).optional(),
heartNotes: z.union([LocalizedStringSchema, z.array(LocalizedStringSchema)]).optional(),
middleNotes: z.union([LocalizedStringSchema, z.array(LocalizedStringSchema)]).optional(),
baseNotes: z.union([LocalizedStringSchema, z.array(LocalizedStringSchema)]).optional(),
});
export type ScentNotesBreakdownSectionPayload = z.infer<typeof ScentNotesBreakdownSectionPayloadSchema>;
export const QuoteCalloutSectionPayloadSchema = z.object({
quote: LocalizedStringSchema,
author: LocalizedStringSchema.optional(),
role: LocalizedStringSchema.optional(),
});
export type QuoteCalloutSectionPayload = z.infer<typeof QuoteCalloutSectionPayloadSchema>;
export const ProductCarouselSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
description: LocalizedStringSchema.optional(),
type: z.enum(['featured', 'brand', 'custom']),
categoryId: z.number().optional(),
brandId: z.number().optional(),
productIds: z.array(z.number()).optional(),
limit: z.number().positive().optional(),
showPrice: z.boolean().optional(),
showAddToCart: z.boolean().optional(),
products: z.array(z.any()).optional(),
}).strict();
export type ProductCarouselSectionPayload = z.infer<typeof ProductCarouselSectionPayloadSchema>;
// Aliases for backwards compatibility in ScentGuideSectionSchema
export const HeroArticlePayloadSchema = HeroArticleSectionPayloadSchema;
export type HeroArticlePayload = HeroArticleSectionPayload;
export const ScentNotesBreakdownPayloadSchema = ScentNotesBreakdownSectionPayloadSchema;
export type ScentNotesBreakdownPayload = ScentNotesBreakdownSectionPayload;
export const QuoteCalloutPayloadSchema = QuoteCalloutSectionPayloadSchema;
export type QuoteCalloutPayload = QuoteCalloutSectionPayload;
export const ProductCarouselPayloadSchema = ProductCarouselSectionPayloadSchema;
export type ProductCarouselPayload = ProductCarouselSectionPayload;
export const RichTextPayloadSchema = RichTextSectionPayloadSchema;
export type RichTextPayload = RichTextSectionPayload;
export const ScentGuideSectionSchema = z.discriminatedUnion("type", [
z.object({
id: z.string().or(z.number()),
type: z.literal("hero_article"),
order: z.number(),
payload: HeroArticlePayloadSchema,
}),
z.object({
id: z.string().or(z.number()),
type: z.literal("rich_text"),
order: z.number(),
payload: RichTextPayloadSchema,
}),
z.object({
id: z.string().or(z.number()),
type: z.literal("scent_notes_breakdown"),
order: z.number(),
payload: ScentNotesBreakdownPayloadSchema,
}),
z.object({
id: z.string().or(z.number()),
type: z.literal("quote_callout"),
order: z.number(),
payload: QuoteCalloutPayloadSchema,
}),
z.object({
id: z.string().or(z.number()),
type: z.literal("product_carousel"),
order: z.number(),
payload: ProductCarouselPayloadSchema,
}),
]);
export const ScentGuidePageSchema = z.object({
pageId: z.string().or(z.number()),
title: z.string(),
slug: z.string(),
status: z.string().optional(),
metadata: z.object({
seoTitle: z.string().optional(),
seoDescription: z.string().optional(),
}).optional(),
sections: z.array(ScentGuideSectionSchema),
});
export type ScentGuidePage = z.infer<typeof ScentGuidePageSchema>;
export const AccordionSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
iconColor: z.string().optional(),
categories: z.array(
z.object({
id: z.string().optional(),
name: LocalizedStringSchema,
icon: z.string().optional(),
questions: z.array(
z.object({
id: z.string().optional(),
q: LocalizedStringSchema,
a: LocalizedStringSchema,
})
),
})
).optional(),
});
export type AccordionSectionPayload = z.infer<typeof AccordionSectionPayloadSchema>;
export const ContactInfoSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
address: LocalizedStringSchema,
email: z.string().min(1),
line: z.string().min(1),
hours: LocalizedStringSchema,
});
export type ContactInfoSectionPayload = z.infer<typeof ContactInfoSectionPayloadSchema>;
export const ContactFormSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
subtitle: LocalizedStringSchema.optional(),
});
export type ContactFormSectionPayload = z.infer<typeof ContactFormSectionPayloadSchema>;
export const HeroV2SectionPayloadSchema = z.object({
tagline: LocalizedStringSchema.optional(),
title: LocalizedStringSchema,
description: LocalizedStringSchema.optional(),
imageUrl: z.string().min(1),
contentAlignment: z.enum(['left', 'center', 'right']).optional(),
showOrnament: z.boolean().optional(),
primaryButtonText: LocalizedStringSchema.optional(),
primaryButtonLink: z.string().optional(),
secondaryButtonText: LocalizedStringSchema.optional(),
secondaryButtonLink: z.string().optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
});
export type HeroV2SectionPayload = z.infer<typeof HeroV2SectionPayloadSchema>;
export const UspBarSectionPayloadSchema = z.object({
backgroundColor: z.string().optional(),
items: z.array(z.object({
icon: z.string(),
title: LocalizedStringSchema,
subtitle: LocalizedStringSchema.optional(),
})).max(4).optional(),
});
export type UspBarSectionPayload = z.infer<typeof UspBarSectionPayloadSchema>;
export const CustomSectionPayloadSchema = z.object({
componentId: z.string().min(1),
props: z.record(z.string(), z.unknown()).optional().default({}),
}).strict();
export type CustomSectionPayload = z.infer<typeof CustomSectionPayloadSchema>;
export const PAGE_SECTION_TYPES = [
'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE',
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM',
'HERO_V2', 'USP_BAR', 'CUSTOM'
] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number];