base commit
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
const KEY = 'craftshop_admin_token'
|
||||
const TOKEN_EVENT = 'craftshop_admin_token_change'
|
||||
|
||||
export function getAdminToken(): string | null {
|
||||
return sessionStorage.getItem(KEY)
|
||||
}
|
||||
|
||||
function notifyTokenListeners(): void {
|
||||
window.dispatchEvent(new Event(TOKEN_EVENT))
|
||||
}
|
||||
|
||||
/** Подписаться на смену токена (в т. ч. после setAdminToken). */
|
||||
export function subscribeAdminTokenChange(cb: () => void): () => void {
|
||||
window.addEventListener(TOKEN_EVENT, cb)
|
||||
return () => window.removeEventListener(TOKEN_EVENT, cb)
|
||||
}
|
||||
|
||||
export function setAdminToken(token: string): void {
|
||||
sessionStorage.setItem(KEY, token)
|
||||
notifyTokenListeners()
|
||||
}
|
||||
|
||||
export function clearAdminToken(): void {
|
||||
sessionStorage.removeItem(KEY)
|
||||
notifyTokenListeners()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function getErrorMessage(error: unknown, fallback = 'Произошла ошибка'): string {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
type OrderLike = {
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function groupOrdersByStatus<T extends OrderLike>(items: T[], statuses: readonly string[]) {
|
||||
const byStatus = new Map<string, T[]>()
|
||||
for (const status of statuses) byStatus.set(status, [])
|
||||
|
||||
for (const item of items) {
|
||||
const list = byStatus.get(item.status) ?? []
|
||||
list.push(item)
|
||||
byStatus.set(item.status, list)
|
||||
}
|
||||
|
||||
return statuses
|
||||
.map((status) => ({
|
||||
status,
|
||||
items: (byStatus.get(status) ?? []).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { QueryClient, QueryKey } from '@tanstack/react-query'
|
||||
|
||||
export async function invalidateQueryKeys(queryClient: QueryClient, keys: QueryKey[]): Promise<void> {
|
||||
await Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export function useEditDialogState<T>() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<T | null>(null)
|
||||
|
||||
const openCreateDialog = useCallback(() => {
|
||||
setEditing(null)
|
||||
setDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const openEditDialog = useCallback((item: T) => {
|
||||
setEditing(item)
|
||||
setDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogOpen(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dialogOpen,
|
||||
editing,
|
||||
openCreateDialog,
|
||||
openEditDialog,
|
||||
closeDialog,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createEffect, createEvent, createStore, sample } from 'effector'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null }
|
||||
export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null; isAdmin?: boolean }
|
||||
|
||||
const TOKEN_KEY = 'craftshop_auth_token'
|
||||
|
||||
@@ -14,11 +14,6 @@ export const $token = createStore<string | null>(null)
|
||||
|
||||
export const $user = createStore<AuthUser | null>(null).reset(logout)
|
||||
|
||||
export const changePasswordFx = createEffect(async (params: { currentPassword?: string; newPassword: string }) => {
|
||||
const { data } = await apiClient.patch<{ user: AuthUser }>('me/password', params)
|
||||
return data.user
|
||||
})
|
||||
|
||||
export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
|
||||
await apiClient.post('me/change-email/request-code', { newEmail })
|
||||
})
|
||||
@@ -35,10 +30,6 @@ export const updateProfileFx = createEffect(async (params: UpdateProfileParams)
|
||||
return data.user
|
||||
})
|
||||
|
||||
export const $changePasswordError = createStore<unknown | null>(null)
|
||||
.on(changePasswordFx.failData, (_, e) => e)
|
||||
.reset(changePasswordFx, logout)
|
||||
|
||||
export const $requestEmailChangeCodeError = createStore<unknown | null>(null)
|
||||
.on(requestEmailChangeCodeFx.failData, (_, e) => e)
|
||||
.reset(requestEmailChangeCodeFx, logout)
|
||||
@@ -70,7 +61,7 @@ sample({
|
||||
})
|
||||
|
||||
sample({
|
||||
clock: [changePasswordFx.doneData, verifyEmailChangeFx.doneData, updateProfileFx.doneData],
|
||||
clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData],
|
||||
target: $user,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
type EntityRowActionsProps = {
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
deleteDisabled?: boolean
|
||||
confirmDeleteMessage?: string
|
||||
editLabel?: string
|
||||
deleteLabel?: string
|
||||
}
|
||||
|
||||
export function EntityRowActions({
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteDisabled = false,
|
||||
confirmDeleteMessage,
|
||||
editLabel = 'Изменить',
|
||||
deleteLabel = 'Удалить',
|
||||
}: EntityRowActionsProps) {
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} sx={{ justifyContent: 'flex-end' }}>
|
||||
{onEdit && (
|
||||
<Button size="small" onClick={onEdit}>
|
||||
{editLabel}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={deleteDisabled}
|
||||
onClick={() => {
|
||||
if (confirmDeleteMessage && !confirm(confirmDeleteMessage)) return
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect } from 'react'
|
||||
import FormatBoldOutlinedIcon from '@mui/icons-material/FormatBoldOutlined'
|
||||
import FormatItalicOutlinedIcon from '@mui/icons-material/FormatItalicOutlined'
|
||||
import FormatListBulletedOutlinedIcon from '@mui/icons-material/FormatListBulletedOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import TiptapStarterKit from '@tiptap/starter-kit'
|
||||
|
||||
type RichTextMessageEditorProps = {
|
||||
value: string
|
||||
onChange: (next: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function RichTextMessageEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Введите сообщение',
|
||||
disabled = false,
|
||||
}: RichTextMessageEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
TiptapStarterKit.configure({ heading: false, codeBlock: false, blockquote: false, horizontalRule: false }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
content: value,
|
||||
editable: !disabled,
|
||||
onUpdate: ({ editor: tiptap }) => onChange(tiptap.getText()),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.setEditable(!disabled)
|
||||
}, [disabled, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (editor.getText() === value) return
|
||||
editor.commands.setContent(value, false)
|
||||
}, [editor, value])
|
||||
|
||||
return (
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, overflow: 'hidden', bgcolor: 'background.paper' }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ p: 0.75, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
color={editor?.isActive('bold') ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
aria-label="Жирный"
|
||||
>
|
||||
<FormatBoldOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
color={editor?.isActive('italic') ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
aria-label="Курсив"
|
||||
>
|
||||
<FormatItalicOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
color={editor?.isActive('bulletList') ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
aria-label="Список"
|
||||
>
|
||||
<FormatListBulletedOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
'& .ProseMirror': {
|
||||
minHeight: 72,
|
||||
outline: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
'& .ProseMirror p.is-editor-empty:first-of-type::before': {
|
||||
content: `"${placeholder}"`,
|
||||
color: 'text.disabled',
|
||||
pointerEvents: 'none',
|
||||
float: 'left',
|
||||
height: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user