deploy
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { apiBaseURL } from '@/shared/config'
|
||||
|
||||
export type PublicProductsResponse = {
|
||||
items: Product[]
|
||||
@@ -97,3 +98,26 @@ export async function createCategory(body: { name: string; slug?: string; sort?:
|
||||
const { data } = await apiClient.post<Category>('admin/categories', body)
|
||||
return data
|
||||
}
|
||||
|
||||
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
|
||||
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
||||
const fd = new FormData()
|
||||
for (const f of Array.from(files)) {
|
||||
fd.append('files', f, f.name)
|
||||
}
|
||||
const token = localStorage.getItem('craftshop_auth_token')
|
||||
const base = apiBaseURL.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/admin/uploads`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
})
|
||||
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
|
||||
if (!res.ok) {
|
||||
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
|
||||
}
|
||||
if (!Array.isArray(payload.urls)) {
|
||||
throw new Error('Некорректный ответ сервера')
|
||||
}
|
||||
return payload.urls
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@ export async function postProductReview(
|
||||
|
||||
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
fd.append('file', file, file.name)
|
||||
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
@@ -29,9 +29,9 @@ import {
|
||||
fetchAdminProducts,
|
||||
fetchCategories,
|
||||
updateProduct,
|
||||
uploadAdminProductImages,
|
||||
} from '@/entities/product/api/product-api'
|
||||
import type { Product } from '@/entities/product/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
@@ -228,23 +228,22 @@ export function AdminPage() {
|
||||
else createMut.mutate()
|
||||
}
|
||||
|
||||
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? createCategoryMut.error
|
||||
const productImagesInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadImagesMut = useMutation({
|
||||
mutationFn: async (files: FileList) => {
|
||||
const fd = new FormData()
|
||||
Array.from(files).forEach((f) => fd.append('files', f))
|
||||
const { data } = await apiClient.post<{ urls: string[] }>('admin/uploads', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return data.urls
|
||||
},
|
||||
mutationFn: (picked: File[]) => uploadAdminProductImages(picked),
|
||||
onSuccess: (urls) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true })
|
||||
if (productImagesInputRef.current) {
|
||||
productImagesInputRef.current.value = ''
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const mutationError =
|
||||
createMut.error ?? updateMut.error ?? deleteMut.error ?? createCategoryMut.error ?? uploadImagesMut.error
|
||||
|
||||
const removeImage = (url: string) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
productForm.setValue(
|
||||
@@ -386,6 +385,7 @@ export function AdminPage() {
|
||||
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
|
||||
Выбрать файлы
|
||||
<input
|
||||
ref={productImagesInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
@@ -393,8 +393,7 @@ export function AdminPage() {
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
uploadImagesMut.mutate(files)
|
||||
e.currentTarget.value = ''
|
||||
uploadImagesMut.mutate(Array.from(files))
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import axios from 'axios'
|
||||
import axios, { AxiosHeaders } from 'axios'
|
||||
import { apiBaseURL } from '@/shared/config'
|
||||
|
||||
// Глобальный application/json ломает FormData: axios сериализует его в JSON. Тип для JSON задаёт transformRequest.
|
||||
export const apiClient = axios.create({
|
||||
baseURL: apiBaseURL,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
try {
|
||||
config.headers = AxiosHeaders.from(config.headers ?? {})
|
||||
const token = localStorage.getItem('craftshop_auth_token')
|
||||
if (!token) return config
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
if (token) {
|
||||
config.headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
// FormData: нельзя задавать Content-Type вручную (нужен boundary). Иначе сервер не видит файлы → { urls: [] }.
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type']
|
||||
config.headers.delete('Content-Type')
|
||||
config.headers.delete('content-type')
|
||||
}
|
||||
return config
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user