HooStore-Backend/src/admin/routes/measurement-presets/page.tsx
2026-10-27 15:08:53 +07:00

373 lines
14 KiB
TypeScript

import { defineRouteConfig } from "@medusajs/admin-sdk"
import { Adjustments, Trash, Pencil, Plus, Check } from "@medusajs/icons"
import { Container, Heading, Text, Button, Input, Table, Badge, toast } from "@medusajs/ui"
import { useState, useEffect } from "react"
export type MeasurementPreset = {
id: string
name: string
description?: string
}
const DEFAULT_PRESETS: MeasurementPreset[] = [
{ id: "p_bust", name: "Bust", description: "Chest/bust circumference at fullest point" },
{ id: "p_waist", name: "Waist", description: "Natural waist circumference" },
{ id: "p_hips", name: "Hips", description: "Hip circumference at widest point" },
{ id: "p_shoulder", name: "Shoulder Width", description: "Distance between shoulder points across the back" },
{ id: "p_top_length", name: "Top Length", description: "Length from high shoulder point to hem" },
{ id: "p_pant_length", name: "Pant Length", description: "Outseam length from waist to pant hem" },
{ id: "p_thigh", name: "Thigh Circumference", description: "Widest part of upper thigh" },
{ id: "p_height", name: "Height (cm)", description: "Total body height in centimeters" },
{ id: "p_weight", name: "Weight (kg)", description: "Total body weight in kilograms" },
]
const MeasurementPresetsPage = () => {
const [store, setStore] = useState<any>(null)
const [presets, setPresets] = useState<MeasurementPreset[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
// New preset form
const [newName, setNewName] = useState("")
const [newDescription, setNewDescription] = useState("")
// Editing state
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState("")
const [editDescription, setEditDescription] = useState("")
const loadData = async () => {
try {
setIsLoading(true)
const res = await fetch("/admin/stores?fields=+metadata", { credentials: "include" })
if (!res.ok) throw new Error("Failed to fetch store metadata")
const data = await res.json()
const currentStore = data.stores?.[0]
setStore(currentStore)
if (currentStore?.metadata?.measurement_presets) {
let raw = currentStore.metadata.measurement_presets
if (typeof raw === "string") {
try {
raw = JSON.parse(raw)
} catch {}
}
if (Array.isArray(raw) && raw.length > 0) {
const normalized: MeasurementPreset[] = raw.map((item: any, idx: number) => {
if (typeof item === "string") {
return { id: `preset_${idx}`, name: item, description: "" }
}
return {
id: item.id || `preset_${idx}_${Date.now()}`,
name: item.name || "",
description: item.description || "",
}
})
setPresets(normalized)
return
}
}
setPresets(DEFAULT_PRESETS)
} catch (err: any) {
toast.error("Error", { description: err.message || "Failed to load presets." })
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadData()
}, [])
const handleAdd = () => {
const trimmed = newName.trim()
if (!trimmed) return
if (presets.some((p) => p.name.toLowerCase() === trimmed.toLowerCase())) {
toast.error("Duplicate field", { description: `A preset named "${trimmed}" already exists.` })
return
}
const newPreset: MeasurementPreset = {
id: `p_${Date.now()}`,
name: trimmed,
description: newDescription.trim(),
}
setPresets((prev) => [...prev, newPreset])
setNewName("")
setNewDescription("")
toast.success("Added", { description: `"${trimmed}" added. Remember to click "Save Changes".` })
}
const handleDelete = (id: string) => {
setPresets((prev) => prev.filter((p) => p.id !== id))
}
const startEdit = (preset: MeasurementPreset) => {
setEditingId(preset.id)
setEditName(preset.name)
setEditDescription(preset.description || "")
}
const saveEdit = () => {
if (!editingId) return
const trimmed = editName.trim()
if (!trimmed) return
setPresets((prev) =>
prev.map((p) =>
p.id === editingId
? { ...p, name: trimmed, description: editDescription.trim() }
: p
)
)
setEditingId(null)
}
const handleSaveToStore = async () => {
if (!store?.id) {
toast.error("Error", { description: "Store ID not available." })
return
}
setIsSaving(true)
try {
const response = await fetch(`/admin/stores/${store.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
metadata: {
...store.metadata,
measurement_presets: presets,
},
}),
})
if (!response.ok) {
throw new Error(`Failed to save: ${response.statusText}`)
}
toast.success("Success", {
description: "Measurement preset fields updated successfully!",
})
} catch (err: any) {
toast.error("Error", {
description: err.message || "Failed to save configuration.",
})
} finally {
setIsSaving(false)
}
}
const handleResetDefaults = () => {
setPresets(DEFAULT_PRESETS)
toast.info("Reset", { description: "Reset to default preset fields. Click Save to persist." })
}
return (
<div className="flex flex-col gap-y-4 max-w-5xl mx-auto p-6">
<Container className="p-0 divide-y">
<div className="flex items-center justify-between px-6 py-4">
<div>
<Heading level="h1" className="text-xl font-semibold">
Measurement Presets Configuration
</Heading>
<Text className="text-ui-fg-subtle text-xs mt-1">
Manage the reusable measurement fields (e.g. Bust, Waist, Length) that appear as quick-add options on product custom sizing widgets.
</Text>
</div>
<div className="flex items-center gap-x-2">
<Button
size="small"
variant="secondary"
onClick={handleResetDefaults}
disabled={isSaving || isLoading}
>
Reset to Defaults
</Button>
<Button
size="small"
variant="primary"
onClick={handleSaveToStore}
isLoading={isSaving}
disabled={isLoading}
>
Save Changes
</Button>
</div>
</div>
{/* Add New Preset Form */}
<div className="p-6 bg-ui-bg-subtle/50">
<Text className="text-xs font-semibold text-ui-fg-base mb-3">
Add New Measurement Field:
</Text>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Input
size="small"
placeholder="Field Name (e.g. Sleeve Length, Neck...)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAdd()
}
}}
/>
<Input
size="small"
placeholder="Description / Guide (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAdd()
}
}}
/>
<Button
size="small"
variant="secondary"
onClick={handleAdd}
disabled={!newName.trim()}
className="w-full md:w-auto"
>
<Plus className="mr-1" /> Add Field
</Button>
</div>
</div>
{/* Presets List Table */}
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-x-2">
<Text className="text-xs font-semibold text-ui-fg-base">
Current Configured Fields
</Text>
<Badge color="blue" size="small">
{presets.length} fields
</Badge>
</div>
<Text className="text-[11px] text-ui-fg-muted">
These fields will be available for quick selection in all product details.
</Text>
</div>
<div className="border border-ui-border-base rounded-md overflow-hidden">
<Table>
<Table.Header>
<Table.Row className="bg-ui-bg-subtle">
<Table.HeaderCell className="text-xs font-semibold py-2.5 w-1/4">Field Name</Table.HeaderCell>
<Table.HeaderCell className="text-xs font-semibold py-2.5 w-1/2">Description / Measurement Guide</Table.HeaderCell>
<Table.HeaderCell className="text-xs font-semibold py-2.5 text-right w-1/4">Actions</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{presets.length === 0 ? (
<Table.Row>
{/* Table.Cell's props type omits colSpan, so it is spread in */}
<Table.Cell
{...{ colSpan: 3 }}
className="text-center py-6 text-ui-fg-muted text-xs"
>
No presets configured yet. Add fields above or click &quot;Reset to Defaults&quot;.
</Table.Cell>
</Table.Row>
) : (
presets.map((preset) => {
const isEditing = editingId === preset.id
return (
<Table.Row key={preset.id} className="hover:bg-ui-bg-subtle/30 transition-colors">
<Table.Cell className="py-2.5">
{isEditing ? (
<Input
size="small"
value={editName}
onChange={(e) => setEditName(e.target.value)}
autoFocus
/>
) : (
<span className="font-semibold text-xs text-ui-fg-base">{preset.name}</span>
)}
</Table.Cell>
<Table.Cell className="py-2.5">
{isEditing ? (
<Input
size="small"
placeholder="Description..."
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
/>
) : (
<span className="text-xs text-ui-fg-muted">{preset.description || "—"}</span>
)}
</Table.Cell>
<Table.Cell className="py-2.5 text-right">
<div className="flex items-center justify-end gap-x-1">
{isEditing ? (
<>
<Button
size="small"
variant="primary"
onClick={saveEdit}
className="h-7 px-2 text-xs"
>
<Check className="w-3.5 h-3.5 mr-1" /> Done
</Button>
<Button
size="small"
variant="secondary"
onClick={() => setEditingId(null)}
className="h-7 px-2 text-xs"
>
Cancel
</Button>
</>
) : (
<>
<Button
size="small"
variant="transparent"
onClick={() => startEdit(preset)}
className="h-7 px-2 text-ui-fg-muted hover:text-ui-fg-base"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
size="small"
variant="transparent"
onClick={() => handleDelete(preset.id)}
className="h-7 px-2 text-ui-fg-muted hover:text-ui-fg-error"
title="Delete"
>
<Trash className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</Table.Cell>
</Table.Row>
)
})
)}
</Table.Body>
</Table>
</div>
</div>
</Container>
</div>
)
}
export const config = defineRouteConfig({
label: "Measurement Presets",
icon: Adjustments,
})
export default MeasurementPresetsPage