@empty-complete-org/medusa-product-attributes 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +367 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/models/category-custom-attribute.d.ts +20 -0
- package/dist/models/category-custom-attribute.d.ts.map +1 -0
- package/dist/models/category-custom-attribute.js +24 -0
- package/dist/models/category-custom-attribute.js.map +1 -0
- package/dist/models/product-custom-attribute.d.ts +20 -0
- package/dist/models/product-custom-attribute.d.ts.map +1 -0
- package/dist/models/product-custom-attribute.js +19 -0
- package/dist/models/product-custom-attribute.js.map +1 -0
- package/dist/service.d.ts +223 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +66 -0
- package/dist/service.js.map +1 -0
- package/package.json +70 -0
- package/src/admin/lib/sdk.ts +9 -0
- package/src/admin/widgets/category-attribute-templates.tsx +237 -0
- package/src/admin/widgets/product-attribute-values.tsx +186 -0
- package/src/api/admin/category/[categoryId]/custom-attributes/route.ts +49 -0
- package/src/api/admin/product/[productId]/custom-attributes/route.ts +51 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
|
2
|
+
import { DetailWidgetProps, AdminProduct } from "@medusajs/framework/types"
|
|
3
|
+
import { Container, Heading, Button, Input, Text } from "@medusajs/ui"
|
|
4
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
5
|
+
import { useState, useEffect } from "react"
|
|
6
|
+
import { sdk } from "../lib/sdk"
|
|
7
|
+
|
|
8
|
+
type ProductCustomAttribute = {
|
|
9
|
+
id: string
|
|
10
|
+
value: string
|
|
11
|
+
type: "text" | "number" | "file" | "boolean"
|
|
12
|
+
product_id: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type CategoryCustomAttribute = {
|
|
16
|
+
id: string
|
|
17
|
+
label: string
|
|
18
|
+
type: "text" | "number" | "file" | "boolean"
|
|
19
|
+
category_id: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type FormValues = Record<string, string>
|
|
23
|
+
|
|
24
|
+
const ProductAttributeValuesWidget = ({
|
|
25
|
+
data,
|
|
26
|
+
}: DetailWidgetProps<AdminProduct>) => {
|
|
27
|
+
const productId = data.id
|
|
28
|
+
const categoryId = (data.categories as { id: string }[])?.[0]?.id ?? null
|
|
29
|
+
|
|
30
|
+
const qc = useQueryClient()
|
|
31
|
+
const [formValues, setFormValues] = useState<FormValues>({})
|
|
32
|
+
const [saveSuccess, setSaveSuccess] = useState(false)
|
|
33
|
+
|
|
34
|
+
const attributesQuery = useQuery<{
|
|
35
|
+
category_custom_attributes: CategoryCustomAttribute[]
|
|
36
|
+
}>({
|
|
37
|
+
queryKey: ["category-custom-attributes", categoryId],
|
|
38
|
+
queryFn: () =>
|
|
39
|
+
sdk.client.fetch(`/admin/category/${categoryId}/custom-attributes`),
|
|
40
|
+
enabled: !!categoryId,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const valuesQuery = useQuery<{
|
|
44
|
+
product_custom_attributes: ProductCustomAttribute[]
|
|
45
|
+
}>({
|
|
46
|
+
queryKey: ["product-custom-attributes", productId],
|
|
47
|
+
queryFn: () =>
|
|
48
|
+
sdk.client.fetch(`/admin/product/${productId}/custom-attributes`),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!attributesQuery.data || !valuesQuery.data) return
|
|
53
|
+
const attributes = attributesQuery.data.category_custom_attributes
|
|
54
|
+
const values = valuesQuery.data.product_custom_attributes
|
|
55
|
+
const initial: FormValues = {}
|
|
56
|
+
for (const attr of attributes) {
|
|
57
|
+
const existing = values.find((v) => v.id === attr.id)
|
|
58
|
+
initial[attr.id] = existing ? existing.value : ""
|
|
59
|
+
}
|
|
60
|
+
setFormValues(initial)
|
|
61
|
+
}, [attributesQuery.data, valuesQuery.data])
|
|
62
|
+
|
|
63
|
+
const saveMutation = useMutation({
|
|
64
|
+
mutationFn: (body: { attributes: Array<{ id: string; value: string }> }) =>
|
|
65
|
+
sdk.client.fetch(`/admin/product/${productId}/custom-attributes`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
body,
|
|
68
|
+
}),
|
|
69
|
+
onSuccess: () => {
|
|
70
|
+
qc.invalidateQueries({
|
|
71
|
+
queryKey: ["product-custom-attributes", productId],
|
|
72
|
+
})
|
|
73
|
+
setSaveSuccess(true)
|
|
74
|
+
setTimeout(() => setSaveSuccess(false), 2000)
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const handleSave = () => {
|
|
79
|
+
if (!attributesQuery.data) return
|
|
80
|
+
const attributes = attributesQuery.data.category_custom_attributes
|
|
81
|
+
const attributesToUpdate = attributes
|
|
82
|
+
.filter(
|
|
83
|
+
(attr) =>
|
|
84
|
+
formValues[attr.id] !== undefined && formValues[attr.id] !== ""
|
|
85
|
+
)
|
|
86
|
+
.map((attr) => ({ id: attr.id, value: formValues[attr.id] }))
|
|
87
|
+
|
|
88
|
+
saveMutation.mutate({ attributes: attributesToUpdate })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!categoryId) {
|
|
92
|
+
return (
|
|
93
|
+
<Container className="divide-y p-0">
|
|
94
|
+
<div className="px-6 py-4">
|
|
95
|
+
<Heading level="h2">Характеристики</Heading>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="px-6 py-4">
|
|
98
|
+
<Text className="text-ui-fg-muted text-sm">
|
|
99
|
+
Назначьте категорию товару, чтобы заполнить характеристики.
|
|
100
|
+
</Text>
|
|
101
|
+
</div>
|
|
102
|
+
</Container>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const isLoading = attributesQuery.isLoading || valuesQuery.isLoading
|
|
107
|
+
const isError = attributesQuery.isError || valuesQuery.isError
|
|
108
|
+
const attributes = attributesQuery.data?.category_custom_attributes ?? []
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Container className="divide-y p-0">
|
|
112
|
+
<div className="px-6 py-4">
|
|
113
|
+
<Heading level="h2">Характеристики</Heading>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{isLoading && (
|
|
117
|
+
<div className="px-6 py-4">
|
|
118
|
+
<Text className="text-ui-fg-muted text-sm">Загрузка…</Text>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{isError && (
|
|
123
|
+
<div className="px-6 py-4">
|
|
124
|
+
<Text className="text-ui-fg-error text-sm">
|
|
125
|
+
Не удалось загрузить характеристики.
|
|
126
|
+
</Text>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{!isLoading && !isError && attributes.length === 0 && (
|
|
131
|
+
<div className="px-6 py-4">
|
|
132
|
+
<Text className="text-ui-fg-muted text-sm">
|
|
133
|
+
В категории нет атрибутов. Добавьте их в настройках категории.
|
|
134
|
+
</Text>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{!isLoading && !isError && attributes.length > 0 && (
|
|
139
|
+
<>
|
|
140
|
+
<div className="divide-y">
|
|
141
|
+
{attributes.map((attr) => (
|
|
142
|
+
<div
|
|
143
|
+
key={attr.id}
|
|
144
|
+
className="grid grid-cols-2 items-center gap-4 px-6 py-3"
|
|
145
|
+
>
|
|
146
|
+
<Text size="small" weight="plus" className="text-ui-fg-base">
|
|
147
|
+
{attr.label}
|
|
148
|
+
</Text>
|
|
149
|
+
<div className="flex items-center gap-2">
|
|
150
|
+
<Input
|
|
151
|
+
type={attr.type === "number" ? "number" : "text"}
|
|
152
|
+
value={formValues[attr.id] ?? ""}
|
|
153
|
+
onChange={(e) =>
|
|
154
|
+
setFormValues((prev) => ({
|
|
155
|
+
...prev,
|
|
156
|
+
[attr.id]: e.target.value,
|
|
157
|
+
}))
|
|
158
|
+
}
|
|
159
|
+
placeholder={attr.type === "number" ? "0" : "Значение"}
|
|
160
|
+
className="h-8 text-sm flex-1"
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div className="flex justify-end px-6 py-4">
|
|
168
|
+
<Button
|
|
169
|
+
size="small"
|
|
170
|
+
onClick={handleSave}
|
|
171
|
+
isLoading={saveMutation.isPending}
|
|
172
|
+
>
|
|
173
|
+
{saveSuccess ? "Сохранено ✓" : "Сохранить"}
|
|
174
|
+
</Button>
|
|
175
|
+
</div>
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
</Container>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const config = defineWidgetConfig({
|
|
183
|
+
zone: "product.details.after",
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
export default ProductAttributeValuesWidget
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { CUSTOM_ATTRIBUTE_MODULE } from "../../../../../index"
|
|
3
|
+
|
|
4
|
+
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
5
|
+
const { categoryId } = req.params
|
|
6
|
+
const service = req.scope.resolve(CUSTOM_ATTRIBUTE_MODULE)
|
|
7
|
+
|
|
8
|
+
const category_custom_attributes = await service.getCategoryAttributes(categoryId)
|
|
9
|
+
|
|
10
|
+
res.json({ category_custom_attributes })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
14
|
+
const { categoryId } = req.params
|
|
15
|
+
const { label, type, unit, sort_order } = req.body as {
|
|
16
|
+
label: string
|
|
17
|
+
type?: string
|
|
18
|
+
unit?: string
|
|
19
|
+
sort_order?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const service = req.scope.resolve(CUSTOM_ATTRIBUTE_MODULE)
|
|
23
|
+
|
|
24
|
+
const category_custom_attribute = await service.createCategoryAttribute({
|
|
25
|
+
label,
|
|
26
|
+
type: type || "text",
|
|
27
|
+
unit: unit || null,
|
|
28
|
+
category_id: categoryId,
|
|
29
|
+
sort_order,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
res.status(201).json({ category_custom_attribute })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function PATCH(req: MedusaRequest, res: MedusaResponse) {
|
|
36
|
+
const service = req.scope.resolve(CUSTOM_ATTRIBUTE_MODULE)
|
|
37
|
+
const { id, ...data } = req.body as {
|
|
38
|
+
id: string
|
|
39
|
+
label?: string
|
|
40
|
+
type?: string
|
|
41
|
+
unit?: string
|
|
42
|
+
sort_order?: number
|
|
43
|
+
deleted_at?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const category_custom_attribute = await service.updateCategoryAttribute(id, data)
|
|
47
|
+
|
|
48
|
+
res.json({ category_custom_attribute })
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import { CUSTOM_ATTRIBUTE_MODULE } from "../../../../../index"
|
|
3
|
+
|
|
4
|
+
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
5
|
+
const { productId } = req.params
|
|
6
|
+
const service = req.scope.resolve(CUSTOM_ATTRIBUTE_MODULE)
|
|
7
|
+
|
|
8
|
+
const product_custom_attributes = await service.getProductAttributes(productId)
|
|
9
|
+
|
|
10
|
+
res.json({ product_custom_attributes })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
14
|
+
const { productId } = req.params
|
|
15
|
+
const { attributes } = req.body as {
|
|
16
|
+
attributes: Array<{ id: string; value: string }>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const service = req.scope.resolve(CUSTOM_ATTRIBUTE_MODULE)
|
|
20
|
+
|
|
21
|
+
// Get existing product attributes
|
|
22
|
+
const existing = await service.getProductAttributes(productId)
|
|
23
|
+
|
|
24
|
+
const results = []
|
|
25
|
+
|
|
26
|
+
for (const attr of attributes) {
|
|
27
|
+
const existingAttr = existing.find(
|
|
28
|
+
(e: any) => e.category_custom_attribute?.id === attr.id
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if (existingAttr) {
|
|
32
|
+
// Update existing
|
|
33
|
+
const updated = await service.updateProductAttribute(existingAttr.id, {
|
|
34
|
+
value: attr.value,
|
|
35
|
+
value_numeric: isNaN(Number(attr.value)) ? null : Number(attr.value),
|
|
36
|
+
})
|
|
37
|
+
results.push(updated)
|
|
38
|
+
} else {
|
|
39
|
+
// Create new
|
|
40
|
+
const created = await service.createProductAttribute({
|
|
41
|
+
product_id: productId,
|
|
42
|
+
category_custom_attribute_id: attr.id,
|
|
43
|
+
value: attr.value,
|
|
44
|
+
value_numeric: isNaN(Number(attr.value)) ? null : Number(attr.value),
|
|
45
|
+
})
|
|
46
|
+
results.push(created)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
res.json({ product_custom_attributes: results })
|
|
51
|
+
}
|