Merge branch 'refactor'
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
export const DELIVERY_CARRIER_CODES = ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'] as const
|
||||
import { DELIVERY_CARRIERS as SHARED_DELIVERY_CARRIERS } from '@shared/constants/delivery-carrier'
|
||||
|
||||
export const DELIVERY_CARRIER_CODES = SHARED_DELIVERY_CARRIERS as typeof SHARED_DELIVERY_CARRIERS
|
||||
|
||||
export type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number]
|
||||
|
||||
/** Варианты для формы чекаута (код → подпись). */
|
||||
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [
|
||||
{ code: 'RUSSIAN_POST', label: 'Почта России' },
|
||||
{ code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' },
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
export const ORDER_STATUSES = [
|
||||
'DRAFT',
|
||||
'DELIVERY_FEE_ADJUSTMENT',
|
||||
'PENDING_PAYMENT',
|
||||
'PAYMENT_VERIFICATION',
|
||||
'PAID',
|
||||
'IN_PROGRESS',
|
||||
'SHIPPED',
|
||||
'READY_FOR_PICKUP',
|
||||
'DONE',
|
||||
'CANCELLED',
|
||||
] as const
|
||||
import { ORDER_STATUSES as SHARED_ORDER_STATUSES } from '@shared/constants/order-status'
|
||||
|
||||
export const ORDER_STATUSES = SHARED_ORDER_STATUSES as typeof SHARED_ORDER_STATUSES
|
||||
|
||||
export type OrderStatus = (typeof ORDER_STATUSES)[number]
|
||||
|
||||
/** Следующие статусы, доступные админу (смена через PATCH). */
|
||||
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Должно совпадать с `getProductImageMaxFileBytes()` на сервере (по умолчанию 20 МБ). */
|
||||
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||
import { ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT } from '@shared/constants/upload-limits'
|
||||
|
||||
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||
|
||||
export function formatAdminImageMaxSizeHint(): string {
|
||||
return `${Math.round(ADMIN_UPLOAD_IMAGE_MAX_BYTES / (1024 * 1024))} МБ`
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createEffect, createEvent, createStore } from 'effector'
|
||||
|
||||
export function createErrorStore<Fx extends ReturnType<typeof createEffect>>(fx: Fx) {
|
||||
const reset = createEvent()
|
||||
const $error = createStore<unknown | null>(null)
|
||||
.on(fx.failData, (_, e) => e)
|
||||
.reset([fx, reset])
|
||||
|
||||
return { $error, reset }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const TOKEN_KEY = 'craftshop_auth_token'
|
||||
|
||||
export function readStoredToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function persistToken(token: string | null): void {
|
||||
try {
|
||||
if (!token) localStorage.removeItem(TOKEN_KEY)
|
||||
else localStorage.setItem(TOKEN_KEY, token)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function removeStoredToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,32 @@
|
||||
import { createEffect, createEvent, createStore, sample } from 'effector'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { createErrorStore } from '@/shared/lib/create-error-store'
|
||||
import { persistToken } from '@/shared/lib/persist-token'
|
||||
|
||||
export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null; isAdmin?: boolean }
|
||||
|
||||
const TOKEN_KEY = 'craftshop_auth_token'
|
||||
|
||||
export const tokenSet = createEvent<string | null>()
|
||||
export const logout = createEvent()
|
||||
|
||||
// ----- Token persistence -----
|
||||
|
||||
const persistTokenFx = createEffect<string | null, void>({
|
||||
handler: (token) => persistToken(token),
|
||||
})
|
||||
|
||||
export const $token = createStore<string | null>(null)
|
||||
.on(tokenSet, (_, t) => t)
|
||||
.reset(logout)
|
||||
|
||||
sample({
|
||||
clock: $token,
|
||||
target: persistTokenFx,
|
||||
})
|
||||
|
||||
// ----- User -----
|
||||
|
||||
export const $user = createStore<AuthUser | null>(null).reset(logout)
|
||||
|
||||
export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
|
||||
await apiClient.post('me/change-email/request-code', { newEmail })
|
||||
})
|
||||
|
||||
export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => {
|
||||
const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params)
|
||||
return data.user
|
||||
})
|
||||
|
||||
export type UpdateProfileParams = { name: string | null; phone?: string | null }
|
||||
|
||||
export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
|
||||
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
|
||||
return data.user
|
||||
})
|
||||
|
||||
export const $requestEmailChangeCodeError = createStore<unknown | null>(null)
|
||||
.on(requestEmailChangeCodeFx.failData, (_, e) => e)
|
||||
.reset(requestEmailChangeCodeFx, logout)
|
||||
|
||||
export const $verifyEmailChangeError = createStore<unknown | null>(null)
|
||||
.on(verifyEmailChangeFx.failData, (_, e) => e)
|
||||
.reset(verifyEmailChangeFx, logout)
|
||||
|
||||
export const $updateProfileError = createStore<unknown | null>(null)
|
||||
.on(updateProfileFx.failData, (_, e) => e)
|
||||
.reset(updateProfileFx, logout)
|
||||
|
||||
export const meFx = createEffect(async (token: string) => {
|
||||
const { data } = await apiClient.get<{ user: AuthUser | null }>('me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -60,37 +45,39 @@ sample({
|
||||
target: $user,
|
||||
})
|
||||
|
||||
// ----- Email change -----
|
||||
|
||||
export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
|
||||
await apiClient.post('me/change-email/request-code', { newEmail })
|
||||
})
|
||||
|
||||
export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => {
|
||||
const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params)
|
||||
return data.user
|
||||
})
|
||||
|
||||
// ----- Profile update -----
|
||||
|
||||
export type UpdateProfileParams = { name: string | null; phone?: string | null }
|
||||
|
||||
export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
|
||||
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
|
||||
return data.user
|
||||
})
|
||||
|
||||
// ----- Error stores -----
|
||||
|
||||
export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error
|
||||
export const $verifyEmailChangeError = createErrorStore(verifyEmailChangeFx).$error
|
||||
export const $updateProfileError = createErrorStore(updateProfileFx).$error
|
||||
|
||||
// ----- Re-exports -----
|
||||
|
||||
export { readStoredToken } from '@/shared/lib/persist-token'
|
||||
|
||||
// ----- Sync user from profile/email changes -----
|
||||
|
||||
sample({
|
||||
clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData],
|
||||
target: $user,
|
||||
})
|
||||
|
||||
let tokenPersistInitialized = false
|
||||
$token.watch((t) => {
|
||||
try {
|
||||
if (!tokenPersistInitialized) {
|
||||
tokenPersistInitialized = true
|
||||
return
|
||||
}
|
||||
if (!t) localStorage.removeItem(TOKEN_KEY)
|
||||
else localStorage.setItem(TOKEN_KEY, t)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
logout.watch(() => {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
export function readStoredToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: ReactNode
|
||||
children: ReactNode
|
||||
actions?: ReactNode
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
maxWidth?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function AdminDialog({ open, onClose, title, children, actions, loading, error, maxWidth = 'sm' }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth={maxWidth}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading && <Typography>Загрузка…</Typography>}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
{!loading && !error && children}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{actions}
|
||||
<Button onClick={onClose}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Skeleton from '@mui/material/Skeleton'
|
||||
import Table from '@mui/material/Table'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
|
||||
export type AdminTableColumn = {
|
||||
key: string
|
||||
label: string
|
||||
align?: 'left' | 'right' | 'center'
|
||||
}
|
||||
|
||||
type Props = {
|
||||
columns: AdminTableColumn[]
|
||||
children: ReactNode
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
skeletonRows?: number
|
||||
}
|
||||
|
||||
export function AdminTable({ columns, children, loading, error, skeletonRows = 3 }: Props) {
|
||||
return (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} align={col.align}>
|
||||
{col.label}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{error && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{loading && !error && (
|
||||
<>
|
||||
{Array.from({ length: skeletonRows }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!loading && !error && children}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AdminTable } from './AdminTable'
|
||||
export type { Column as AdminTableColumn } from './AdminTable'
|
||||
Reference in New Issue
Block a user