@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.
@@ -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
+ }