initial: client
This commit is contained in:
Executable
+21
@@ -0,0 +1,21 @@
|
||||
import type { CartItem } from '@/entities/cart/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type CartResponse = { items: CartItem[] }
|
||||
|
||||
export async function fetchMyCart(): Promise<CartResponse> {
|
||||
const { data } = await apiClient.get<CartResponse>('me/cart')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function addToCart(body: { productId: string; qty?: number }): Promise<void> {
|
||||
await apiClient.post('me/cart/items', body)
|
||||
}
|
||||
|
||||
export async function setCartQty(id: string, qty: number): Promise<void> {
|
||||
await apiClient.patch(`me/cart/items/${id}`, { qty })
|
||||
}
|
||||
|
||||
export async function removeCartItem(id: string): Promise<void> {
|
||||
await apiClient.delete(`me/cart/items/${id}`)
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
export type { CartItem } from './model/types'
|
||||
export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api'
|
||||
export type { CartResponse } from './api/cart-api'
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { fetchMyCart } from '../api/cart-api'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
|
||||
export function useCartQuery() {
|
||||
const user = useUnit($user)
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['me', 'cart'],
|
||||
queryFn: fetchMyCart,
|
||||
enabled: Boolean(user),
|
||||
})
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
import type { Product } from '@/entities/product/model/types'
|
||||
|
||||
export type CartItem = {
|
||||
id: string
|
||||
qty: number
|
||||
product: Product
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { CatalogSliderSlide, AdminCatalogSliderSlide } from '../model/types'
|
||||
|
||||
export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> {
|
||||
const { data } = await apiClient.get<{ slides: CatalogSliderSlide[] }>('catalog-slider')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminCatalogSlider(): Promise<{ slides: AdminCatalogSliderSlide[] }> {
|
||||
const { data } = await apiClient.get<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function putAdminCatalogSlider(body: {
|
||||
slides: Array<{ galleryImageId: string; caption: string; textColor?: string }>
|
||||
}): Promise<{ slides: AdminCatalogSliderSlide[] }> {
|
||||
const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body)
|
||||
return data
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api'
|
||||
export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types'
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export type CatalogSliderSlide = {
|
||||
id: string
|
||||
url: string
|
||||
caption: string
|
||||
textColor?: string
|
||||
}
|
||||
|
||||
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
||||
galleryImageId: string
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import type { GalleryImageItem } from '@/entities/gallery/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { apiBaseURL } from '@/shared/config'
|
||||
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
|
||||
export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> {
|
||||
const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteGalleryImage(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/gallery/${id}`)
|
||||
}
|
||||
|
||||
export async function uploadGalleryImages(files: File[]): Promise<string[]> {
|
||||
for (const f of files) {
|
||||
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const fd = new FormData()
|
||||
for (const f of files) {
|
||||
fd.append('files', f, f.name)
|
||||
}
|
||||
const token = localStorage.getItem('craftshop_auth_token')
|
||||
const base = apiBaseURL.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/admin/gallery/upload`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
})
|
||||
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
|
||||
if (!res.ok) {
|
||||
if (res.status === 413) {
|
||||
throw new Error(
|
||||
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
|
||||
)
|
||||
}
|
||||
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
|
||||
}
|
||||
if (!Array.isArray(payload.urls)) {
|
||||
throw new Error('Некорректный ответ сервера')
|
||||
}
|
||||
return payload.urls
|
||||
}
|
||||
|
||||
export async function resizeGalleryImage(id: string): Promise<{ url: string }> {
|
||||
const { data } = await apiClient.post<{ url: string }>(`admin/gallery/${id}/resize`)
|
||||
return data
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
|
||||
export type { GalleryImageItem } from './model/types'
|
||||
export { GalleryGrid } from './ui/GalleryGrid'
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
export type GalleryImageItem = {
|
||||
id: string
|
||||
url: string
|
||||
isResized: boolean
|
||||
createdAt: string
|
||||
inUse?: boolean
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import AutoFixHighOutlinedIcon from '@mui/icons-material/AutoFixHighOutlined'
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import type { GalleryImageItem } from '../model/types'
|
||||
|
||||
type Props = {
|
||||
items: GalleryImageItem[]
|
||||
deleting?: boolean
|
||||
resizing?: string | null
|
||||
onDelete: (id: string) => void
|
||||
onResize: (id: string) => void
|
||||
}
|
||||
|
||||
export function GalleryGrid({ items, deleting, resizing, onDelete, onResize }: Props) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
aspectRatio: '1',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={item.url}
|
||||
alt=""
|
||||
sizes="140px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', top: 4, left: 4 }}>
|
||||
{item.isResized ? (
|
||||
<Chip
|
||||
label="Готово"
|
||||
size="small"
|
||||
color="success"
|
||||
icon={<CheckCircleOutlineOutlinedIcon fontSize="small" />}
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label="Не обработано"
|
||||
size="small"
|
||||
color="warning"
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
|
||||
{!item.isResized && (
|
||||
<Tooltip title="Обработать (resize)">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': { bgcolor: 'primary.light', color: 'primary.contrastText' },
|
||||
}}
|
||||
disabled={resizing === item.id}
|
||||
onClick={() => onResize(item.id)}
|
||||
>
|
||||
<AutoFixHighOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Удалить из галереи">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
|
||||
}}
|
||||
disabled={deleting}
|
||||
onClick={() => onDelete(item.id)}
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export interface UserNotificationSettings {
|
||||
id: string
|
||||
userId: string
|
||||
globalEnabled: boolean
|
||||
orderCreated: boolean
|
||||
orderStatusChanged: boolean
|
||||
orderMessageReceived: boolean
|
||||
paymentStatusChanged: boolean
|
||||
deliveryFeeAdjusted: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminNotificationSettings {
|
||||
id: string
|
||||
emailEnabled: boolean
|
||||
telegramEnabled: boolean
|
||||
telegramChatId: string | null
|
||||
newOrder: boolean
|
||||
newOrderMessage: boolean
|
||||
newReview: boolean
|
||||
authCodeDuplicate: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export async function fetchUserNotificationSettings(): Promise<{ settings: UserNotificationSettings }> {
|
||||
const { data } = await apiClient.get<{ settings: UserNotificationSettings }>('me/notifications/settings')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUserNotificationSettings(
|
||||
settings: Partial<UserNotificationSettings>,
|
||||
): Promise<{ settings: UserNotificationSettings }> {
|
||||
const { data } = await apiClient.put<{ settings: UserNotificationSettings }>('me/notifications/settings', settings)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminNotificationSettings(): Promise<{ settings: AdminNotificationSettings }> {
|
||||
const { data } = await apiClient.get<{ settings: AdminNotificationSettings }>('admin/notifications/settings')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAdminNotificationSettings(
|
||||
settings: Partial<AdminNotificationSettings>,
|
||||
): Promise<{ settings: AdminNotificationSettings }> {
|
||||
const { data } = await apiClient.put<{ settings: AdminNotificationSettings }>(
|
||||
'admin/notifications/settings',
|
||||
settings,
|
||||
)
|
||||
return data
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
fetchUserNotificationSettings,
|
||||
updateUserNotificationSettings,
|
||||
fetchAdminNotificationSettings,
|
||||
updateAdminNotificationSettings,
|
||||
} from './api/notifications-api'
|
||||
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type AdminOrderListItem = {
|
||||
id: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryFeeLocked: boolean
|
||||
deliveryCarrier?: string | null
|
||||
paymentMethod?: 'online' | 'on_pickup'
|
||||
totalCents: number
|
||||
currency: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
user: { id: string; email: string }
|
||||
itemsCount: number
|
||||
}
|
||||
|
||||
export type AdminOrdersListResponse = {
|
||||
items: AdminOrderListItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type AdminOrderDetailResponse = {
|
||||
item: {
|
||||
id: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: string | null
|
||||
paymentMethod?: 'online' | 'on_pickup'
|
||||
itemsSubtotalCents: number
|
||||
deliveryFeeCents: number
|
||||
deliveryFeeLocked: boolean
|
||||
totalCents: number
|
||||
currency: string
|
||||
addressSnapshotJson: string | null
|
||||
comment: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
displayName: string | null
|
||||
avatar?: string | null
|
||||
avatarStyle?: string | null
|
||||
}
|
||||
items: Array<{
|
||||
id: string
|
||||
productId: string
|
||||
qty: number
|
||||
titleSnapshot: string
|
||||
priceCentsSnapshot: number
|
||||
}>
|
||||
messages: Array<{
|
||||
id: string
|
||||
authorType: string
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminOrdersSummary(): Promise<{ attentionCount: number }> {
|
||||
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminOrders(params?: {
|
||||
status?: string
|
||||
deliveryType?: 'delivery' | 'pickup'
|
||||
q?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<AdminOrdersListResponse> {
|
||||
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminOrder(id: string): Promise<AdminOrderDetailResponse> {
|
||||
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function setAdminOrderStatus(id: string, status: string): Promise<void> {
|
||||
await apiClient.patch(`admin/orders/${id}/status`, { status })
|
||||
}
|
||||
|
||||
export async function patchAdminOrderDeliveryFee(id: string, deliveryFeeCents: number): Promise<void> {
|
||||
await apiClient.patch(`admin/orders/${id}/delivery-fee`, { deliveryFeeCents })
|
||||
}
|
||||
|
||||
export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
|
||||
await apiClient.post(`admin/orders/${id}/messages`, { text })
|
||||
}
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
|
||||
|
||||
export type OrderListItem = {
|
||||
id: string
|
||||
status: string
|
||||
totalCents: number
|
||||
currency: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
itemsCount: number
|
||||
}
|
||||
|
||||
export type OrderListResponse = { items: OrderListItem[] }
|
||||
|
||||
export type OrderPaymentMethod = 'online' | 'on_pickup'
|
||||
|
||||
export type OrderDetailResponse = {
|
||||
item: {
|
||||
id: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: DeliveryCarrierCode | null
|
||||
paymentMethod?: OrderPaymentMethod
|
||||
itemsSubtotalCents: number
|
||||
deliveryFeeCents: number
|
||||
deliveryFeeLocked: boolean
|
||||
totalCents: number
|
||||
currency: string
|
||||
addressSnapshotJson: string | null
|
||||
comment: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
items: Array<{
|
||||
id: string
|
||||
productId: string
|
||||
qty: number
|
||||
titleSnapshot: string
|
||||
priceCentsSnapshot: number
|
||||
}>
|
||||
messages: Array<{
|
||||
id: string
|
||||
authorType: 'user' | 'admin'
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrder(body: {
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: DeliveryCarrierCode | null
|
||||
paymentMethod?: OrderPaymentMethod
|
||||
addressId?: string | null
|
||||
comment?: string | null
|
||||
}): Promise<{ orderId: string }> {
|
||||
const { data } = await apiClient.post<{ orderId: string }>('me/orders', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchMyOrders(): Promise<OrderListResponse> {
|
||||
const { data } = await apiClient.get<OrderListResponse>('me/orders')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchMyOrder(id: string): Promise<OrderDetailResponse> {
|
||||
const { data } = await apiClient.get<OrderDetailResponse>(`me/orders/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */
|
||||
export async function createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> {
|
||||
const { data } = await apiClient.post<{ confirmationUrl: string }>(`me/orders/${orderId}/pay`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Получить статус платежа для заказа. */
|
||||
export async function getOrderPaymentStatus(orderId: string): Promise<{ status: string | null; paid: boolean }> {
|
||||
const { data } = await apiClient.get<{ status: string | null; paid: boolean }>(`me/orders/${orderId}/payment`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function postOrderMessage(id: string, text: string): Promise<void> {
|
||||
await apiClient.post(`me/orders/${id}/messages`, { text })
|
||||
}
|
||||
|
||||
export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> {
|
||||
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
|
||||
return data
|
||||
}
|
||||
|
||||
export type ReviewEligibilityItem = { productId: string; title: string; hasReview: boolean }
|
||||
|
||||
export async function fetchOrderReviewEligibility(orderId: string): Promise<{
|
||||
canReview: boolean
|
||||
items: ReviewEligibilityItem[]
|
||||
}> {
|
||||
const { data } = await apiClient.get<{ canReview: boolean; items: ReviewEligibilityItem[] }>(
|
||||
`me/orders/${orderId}/review-eligibility`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
fetchMyOrders,
|
||||
createOrder,
|
||||
confirmOrderReceived,
|
||||
fetchMyOrder,
|
||||
fetchOrderReviewEligibility,
|
||||
} from './api/order-api'
|
||||
export { createOrderPayment, getOrderPaymentStatus, postOrderMessage } from './api/order-api'
|
||||
export type { OrderListResponse, OrderDetailResponse } from './api/order-api'
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { apiBaseURL } from '@/shared/config'
|
||||
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
|
||||
export async function fetchAdminProducts(): Promise<Product[]> {
|
||||
const { data } = await apiClient.get<Product[]>('admin/products')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createProduct(body: {
|
||||
title: string
|
||||
slug?: string
|
||||
shortDescription?: string | null
|
||||
description?: string | null
|
||||
quantity: number
|
||||
materials?: string[]
|
||||
priceCents: number
|
||||
imageUrl?: string | null
|
||||
imageUrls?: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}): Promise<Product> {
|
||||
const { data } = await apiClient.post<Product>('admin/products', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateProduct(
|
||||
id: string,
|
||||
body: Partial<{
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string | null
|
||||
description: string | null
|
||||
quantity: number
|
||||
materials: string[]
|
||||
priceCents: number
|
||||
imageUrl: string | null
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}>,
|
||||
): Promise<Product> {
|
||||
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/products/${id}`)
|
||||
}
|
||||
|
||||
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
|
||||
const { data } = await apiClient.post<Category>('admin/categories', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminCategories(): Promise<Category[]> {
|
||||
const { data } = await apiClient.get<{ items: Category[] }>('admin/categories')
|
||||
return data.items
|
||||
}
|
||||
|
||||
export async function updateAdminCategory(
|
||||
id: string,
|
||||
body: Partial<{ name: string; slug: string; sort: number }>,
|
||||
): Promise<Category> {
|
||||
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAdminCategory(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/categories/${id}`)
|
||||
}
|
||||
|
||||
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
|
||||
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
||||
const list = Array.from(files)
|
||||
for (const f of list) {
|
||||
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const fd = new FormData()
|
||||
for (const f of list) {
|
||||
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) {
|
||||
if (res.status === 413) {
|
||||
throw new Error(
|
||||
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
|
||||
)
|
||||
}
|
||||
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
|
||||
}
|
||||
if (!Array.isArray(payload.urls)) {
|
||||
throw new Error('Некорректный ответ сервера')
|
||||
}
|
||||
return payload.urls
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type PublicProductsResponse = {
|
||||
items: Product[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export async function fetchPublicProducts(params?: {
|
||||
categorySlug?: string
|
||||
q?: string
|
||||
sort?: 'price_asc' | 'price_desc' | ''
|
||||
page?: number
|
||||
pageSize?: number
|
||||
priceMinCents?: number
|
||||
priceMaxCents?: number
|
||||
}): Promise<PublicProductsResponse> {
|
||||
const { data } = await apiClient.get<PublicProductsResponse>('products', {
|
||||
params: {
|
||||
categorySlug: params?.categorySlug || undefined,
|
||||
q: params?.q || undefined,
|
||||
sort: params?.sort || undefined,
|
||||
page: params?.page || undefined,
|
||||
pageSize: params?.pageSize || undefined,
|
||||
priceMin: params?.priceMinCents ?? undefined,
|
||||
priceMax: params?.priceMaxCents ?? undefined,
|
||||
},
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchPublicProduct(id: string): Promise<Product> {
|
||||
const { data } = await apiClient.get<Product>(`products/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCategories(): Promise<Category[]> {
|
||||
const { data } = await apiClient.get<Category[]>('categories')
|
||||
return data
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
|
||||
export type { PublicProductsResponse } from './api/product-api'
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
export type Category = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export type ProductReviewsSummary = {
|
||||
approvedReviewCount: number
|
||||
avgRating: number | null
|
||||
latestApprovedText: string | null
|
||||
}
|
||||
|
||||
export type Product = {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string | null
|
||||
description: string | null
|
||||
quantity: number
|
||||
materials?: string[]
|
||||
priceCents: number
|
||||
imageUrl: string | null
|
||||
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
|
||||
published: boolean
|
||||
categoryId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
category?: Category
|
||||
images?: { id: string; url: string; sort: number }[]
|
||||
/** Для опубликованных товаров с публичного API. */
|
||||
reviewsSummary?: ProductReviewsSummary | null
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardMedia from '@mui/material/CardMedia'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
import type { Product } from '@/entities/product/model/types'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import type { Swiper as SwiperType } from 'swiper/types'
|
||||
|
||||
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
|
||||
|
||||
const ProductCardInner = ({ product, mediaHeight = 390, actions }: Props) => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery('(max-width:600px)')
|
||||
const swiperRef = useRef<SwiperType | null>(null)
|
||||
const imageUrls = useMemo(() => {
|
||||
const fromImages = (product.images ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map((x) => x.url)
|
||||
const urls = fromImages.length ? fromImages : product.imageUrl ? [product.imageUrl] : []
|
||||
return urls
|
||||
}, [product.images, product.imageUrl])
|
||||
|
||||
const materials = (product.materials ?? []).slice(0, 3)
|
||||
const moreMaterials = Math.max(0, (product.materials?.length ?? 0) - materials.length)
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!swiperRef.current) return
|
||||
if (imageUrls.length <= 1) return
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
|
||||
const idx = Math.min(imageUrls.length - 1, Math.floor(rel * imageUrls.length))
|
||||
swiperRef.current.slideTo(idx, 0)
|
||||
}
|
||||
|
||||
const goToProduct = useCallback(() => {
|
||||
navigate(`/products/${product.id}`)
|
||||
}, [navigate, product.id])
|
||||
|
||||
const stockLabel = product.quantity > 0 ? null : { label: 'Нет в наличии', color: 'default' as const }
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={goToProduct}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '16px 16px 12px 12px',
|
||||
border: 'none',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
transition: 'transform 250ms ease, box-shadow 300ms ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-6px)',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.12)',
|
||||
},
|
||||
'&:hover .product-card__media': { transform: 'scale(1.06)' },
|
||||
'&:hover .product-card__title': { color: 'primary.main' },
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:hover': { transform: 'none' },
|
||||
'&:hover .product-card__media': { transform: 'none' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{imageUrls.length ? (
|
||||
<Box
|
||||
onMouseMove={!isMobile ? onMouseMove : undefined}
|
||||
sx={{ width: '100%', aspectRatio: '3/4', maxHeight: mediaHeight, overflow: 'hidden' }}
|
||||
>
|
||||
<Swiper
|
||||
slidesPerView={1}
|
||||
spaceBetween={16}
|
||||
allowTouchMove={!isMobile}
|
||||
onSwiper={(s) => {
|
||||
swiperRef.current = s
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
{imageUrls.map((url) => (
|
||||
<SwiperSlide key={url}>
|
||||
<Box
|
||||
className="product-card__media"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transition: 'transform 320ms ease',
|
||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||
userSelect: 'none',
|
||||
bgcolor: 'grey.50',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={url}
|
||||
alt={product.title}
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
sx={{
|
||||
width: '101%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Box>
|
||||
) : (
|
||||
<CardMedia
|
||||
component="div"
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '3/4',
|
||||
maxHeight: mediaHeight,
|
||||
bgcolor: 'grey.50',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.disabled" variant="body2">
|
||||
Нет фото
|
||||
</Typography>
|
||||
</CardMedia>
|
||||
)}
|
||||
|
||||
{stockLabel && (
|
||||
<Chip
|
||||
label={stockLabel.label}
|
||||
size="small"
|
||||
color={stockLabel.color}
|
||||
variant="filled"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 2,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
backdropFilter: 'blur(4px)',
|
||||
bgcolor: 'rgba(0,0,0,0.55)',
|
||||
color: 'common.white',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', p: 2, pb: 2 }}>
|
||||
<Stack spacing={1.25} sx={{ flexGrow: 1 }}>
|
||||
{product.category && (
|
||||
<Chip
|
||||
label={product.category.name}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.65rem',
|
||||
height: 22,
|
||||
letterSpacing: 0.3,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="h2"
|
||||
className="product-card__title"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
transition: 'color 150ms ease',
|
||||
}}
|
||||
>
|
||||
{product.title}
|
||||
</Typography>
|
||||
|
||||
{(product.materials?.length ?? 0) > 0 && (
|
||||
<Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap' }}>
|
||||
{materials.map((m) => (
|
||||
<Chip
|
||||
key={m}
|
||||
label={m}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: 'chip.default',
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.7rem',
|
||||
height: 22,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{moreMaterials > 0 && (
|
||||
<Chip
|
||||
label={`+${moreMaterials}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: 'chip.default',
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.7rem',
|
||||
height: 22,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{product.shortDescription ?? 'Описание появится позже.'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="p"
|
||||
color="primary"
|
||||
sx={{ fontWeight: 700, fontSize: '1.1rem', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{formatPriceRub(product.priceCents)}
|
||||
</Typography>
|
||||
{actions}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductCard = React.memo(ProductCardInner, (prev, next) => {
|
||||
return prev.product.id === next.product.id && prev.mediaHeight === next.mediaHeight && prev.actions === next.actions
|
||||
})
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type AdminReview = {
|
||||
id: string
|
||||
rating: number
|
||||
text: string | null
|
||||
status: string
|
||||
createdAt: string
|
||||
moderatedAt: string | null
|
||||
user: { id: string; email: string; displayName: string | null }
|
||||
product: { id: string; title: string }
|
||||
}
|
||||
|
||||
export type AdminReviewsListResponse = {
|
||||
items: AdminReview[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export async function fetchAdminReviews(params?: {
|
||||
status?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<AdminReviewsListResponse> {
|
||||
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function moderateReview(id: string, action: 'approve' | 'reject'): Promise<void> {
|
||||
await apiClient.patch(`admin/reviews/${id}`, { action })
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
|
||||
export async function postProductReview(
|
||||
productId: string,
|
||||
body: { rating: number; text?: string | null; imageUrl?: string | null },
|
||||
): Promise<void> {
|
||||
await apiClient.post(`products/${productId}/reviews`, body)
|
||||
}
|
||||
|
||||
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
|
||||
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
|
||||
throw new Error(`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`)
|
||||
}
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('file', file, file.name)
|
||||
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
|
||||
return data
|
||||
}
|
||||
|
||||
export type PublicReviewFeedItem = {
|
||||
id: string
|
||||
rating: number
|
||||
text: string | null
|
||||
imageUrl: string | null
|
||||
createdAt: string
|
||||
authorId: string
|
||||
authorDisplay: string
|
||||
authorAvatar?: string | null
|
||||
authorAvatarStyle?: string | null
|
||||
product: {
|
||||
id: string
|
||||
title: string
|
||||
published: boolean
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PublicReviewsLatestResponse = {
|
||||
items: PublicReviewFeedItem[]
|
||||
}
|
||||
|
||||
export async function fetchLatestApprovedReviews(limit = 5): Promise<PublicReviewsLatestResponse> {
|
||||
const { data } = await apiClient.get<PublicReviewsLatestResponse>('reviews/latest', {
|
||||
params: { limit },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export type PublicProductReviewItem = {
|
||||
id: string
|
||||
rating: number
|
||||
text: string | null
|
||||
imageUrl: string | null
|
||||
createdAt: string
|
||||
authorId: string
|
||||
authorDisplay: string
|
||||
authorAvatar?: string | null
|
||||
authorAvatarStyle?: string | null
|
||||
}
|
||||
|
||||
export type PublicProductReviewsResponse = {
|
||||
items: PublicProductReviewItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export async function fetchPublicProductReviews(
|
||||
productId: string,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
): Promise<PublicProductReviewsResponse> {
|
||||
const { data } = await apiClient.get<PublicProductReviewsResponse>(`products/${productId}/reviews`, {
|
||||
params: { page: params?.page, pageSize: params?.pageSize },
|
||||
})
|
||||
return data
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
postProductReview,
|
||||
uploadReviewImage,
|
||||
fetchLatestApprovedReviews,
|
||||
fetchPublicProductReviews,
|
||||
} from './api/reviews-api'
|
||||
export type {
|
||||
PublicReviewFeedItem,
|
||||
PublicReviewsLatestResponse,
|
||||
PublicProductReviewItem,
|
||||
PublicProductReviewsResponse,
|
||||
} from './api/reviews-api'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type ChecklistResultDto = {
|
||||
passed: boolean
|
||||
comment: string | null
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export type TestChecklistResponse = {
|
||||
results: Record<string, ChecklistResultDto>
|
||||
}
|
||||
|
||||
export type UpdateChecklistItemResponse = {
|
||||
itemKey: string
|
||||
passed: boolean
|
||||
comment: string | null
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
|
||||
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateTestChecklistItem(
|
||||
itemKey: string,
|
||||
passed: boolean,
|
||||
comment?: string | null,
|
||||
): Promise<UpdateChecklistItemResponse> {
|
||||
const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', {
|
||||
itemKey,
|
||||
passed,
|
||||
comment: passed ? null : (comment ?? null),
|
||||
})
|
||||
return data.result
|
||||
}
|
||||
|
||||
export async function resetTestChecklist(): Promise<void> {
|
||||
await apiClient.post('admin/test-checklist/reset')
|
||||
}
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
import type { ShippingAddress } from '@/entities/user/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type AddressesListResponse = { items: ShippingAddress[] }
|
||||
|
||||
export async function fetchMyAddresses(): Promise<AddressesListResponse> {
|
||||
const { data } = await apiClient.get<AddressesListResponse>('me/addresses')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createMyAddress(body: {
|
||||
label?: string | null
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment?: string | null
|
||||
lat: number
|
||||
lng: number
|
||||
isDefault?: boolean
|
||||
}): Promise<{ item: ShippingAddress }> {
|
||||
const { data } = await apiClient.post<{ item: ShippingAddress }>('me/addresses', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateMyAddress(
|
||||
id: string,
|
||||
body: Partial<{
|
||||
label: string | null
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment: string | null
|
||||
lat: number
|
||||
lng: number
|
||||
isDefault: boolean
|
||||
}>,
|
||||
): Promise<{ item: ShippingAddress }> {
|
||||
const { data } = await apiClient.patch<{ item: ShippingAddress }>(`me/addresses/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteMyAddress(id: string): Promise<void> {
|
||||
await apiClient.delete(`me/addresses/${id}`)
|
||||
}
|
||||
|
||||
export async function setMyAddressDefault(id: string): Promise<{ item: ShippingAddress }> {
|
||||
const { data } = await apiClient.post<{ item: ShippingAddress }>(`me/addresses/${id}/default`)
|
||||
return data
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export async function fetchUnreadMessageCount(): Promise<{ count: number }> {
|
||||
const { data } = await apiClient.get<{ count: number }>('me/messages/unread-count')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function markOrderMessagesRead(orderId: string): Promise<void> {
|
||||
await apiClient.post(`me/orders/${orderId}/messages/read`)
|
||||
}
|
||||
|
||||
export type ConversationSummary = {
|
||||
orderId: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
lastMessageAt: string
|
||||
preview: string
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export async function fetchMyConversations(): Promise<{ items: ConversationSummary[] }> {
|
||||
const { data } = await apiClient.get<{ items: ConversationSummary[] }>('me/conversations')
|
||||
return data
|
||||
}
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
import type { AdminUser } from '@/entities/user/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type AdminUsersListResponse = {
|
||||
items: AdminUser[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export async function fetchAdminUsers(params?: {
|
||||
q?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<AdminUsersListResponse> {
|
||||
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAdminUser(body: { email: string; displayName?: string | null }): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>('admin/users', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAdminUser(
|
||||
id: string,
|
||||
body: Partial<{ email: string; displayName: string | null }>,
|
||||
): Promise<AdminUser> {
|
||||
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export type AdminAvatarResponse = {
|
||||
avatar: string | null
|
||||
avatarStyle: string | null
|
||||
}
|
||||
|
||||
export async function fetchAdminAvatar(): Promise<AdminAvatarResponse> {
|
||||
const { data } = await apiClient.get<AdminAvatarResponse>('admin/avatar')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/users/${id}`)
|
||||
}
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
export type { AdminUser, ShippingAddress } from './model/types'
|
||||
export { fetchAdminUsers, createAdminUser, updateAdminUser, deleteAdminUser } from './api/user-api'
|
||||
export type { AdminUsersListResponse } from './api/user-api'
|
||||
export {
|
||||
fetchMyAddresses,
|
||||
createMyAddress,
|
||||
updateMyAddress,
|
||||
deleteMyAddress,
|
||||
setMyAddressDefault,
|
||||
} from './api/address-api'
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
export type AdminUser = {
|
||||
id: string
|
||||
email: string
|
||||
displayName: string | null
|
||||
avatar?: string | null
|
||||
avatarStyle?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type ShippingAddress = {
|
||||
id: string
|
||||
label: string | null
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment: string | null
|
||||
lat: number
|
||||
lng: number
|
||||
isDefault: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
Reference in New Issue
Block a user