373 lines
14 KiB
TypeScript
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 "Reset to Defaults".
|
|
</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 |