17.05.2026 funny
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { getCsrfToken } from '@shared/api/csrf'
|
import { getCsrfToken } from '@shared/api/csrf'
|
||||||
|
import { tokenStore } from '@shared/api/tokenStore'
|
||||||
|
|
||||||
const USERS_API_URL = 'https://app.users.elcsa.ru'
|
const USERS_API_URL = 'https://app.users.elcsa.ru'
|
||||||
|
|
||||||
@@ -9,26 +10,58 @@ export interface MeResponse {
|
|||||||
middle_name: string
|
middle_name: string
|
||||||
last_name: string
|
last_name: string
|
||||||
birth_date: string
|
birth_date: string
|
||||||
crypto_wallet: string | null
|
encrypted_mnemonic: string | null
|
||||||
phone: string
|
phone: string
|
||||||
passport_data: string | null
|
passport_data: string | null
|
||||||
inn: string | null
|
inn: string | null
|
||||||
erc20: string | null
|
erc20: string | null
|
||||||
|
avatar_link: string | null
|
||||||
kyc_verified: boolean
|
kyc_verified: boolean
|
||||||
is_deleted: boolean
|
is_deleted: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
kyc_verified_at: string | null
|
kyc_verified_at: string | null
|
||||||
|
webp_size_bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadAvatarPayload {
|
||||||
|
photo_base64: string
|
||||||
|
decoded_bytes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authedHeaders(): Promise<HeadersInit> {
|
||||||
|
const csrf = await getCsrfToken()
|
||||||
|
const bearer = tokenStore.get()
|
||||||
|
return {
|
||||||
|
'X-CSRF-Token': csrf,
|
||||||
|
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMe(): Promise<MeResponse> {
|
export async function getMe(): Promise<MeResponse> {
|
||||||
const csrf = await getCsrfToken()
|
const headers = await authedHeaders()
|
||||||
|
|
||||||
const res = await fetch(`${USERS_API_URL}/me/`, {
|
const res = await fetch(`${USERS_API_URL}/me/`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers,
|
||||||
'X-CSRF-Token': csrf,
|
})
|
||||||
},
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAvatar(payload: UploadAvatarPayload): Promise<MeResponse> {
|
||||||
|
const headers = await authedHeaders()
|
||||||
|
|
||||||
|
const res = await fetch(`${USERS_API_URL}/me/settings/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
13
src/features/auth/hooks/useUploadAvatar.ts
Normal file
13
src/features/auth/hooks/useUploadAvatar.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { uploadAvatar } from '../api/profileApi'
|
||||||
|
import type { MeResponse, UploadAvatarPayload } from '../api/profileApi'
|
||||||
|
|
||||||
|
export function useUploadAvatar() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation<MeResponse, unknown, UploadAvatarPayload>({
|
||||||
|
mutationFn: uploadAvatar,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(['me'], data)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
export { registrationStart, registrationComplete, loginStart, loginComplete } from './api/registrationApi'
|
export { registrationStart, registrationComplete, loginStart, loginComplete } from './api/registrationApi'
|
||||||
export { getMe } from './api/profileApi'
|
export { getMe, uploadAvatar } from './api/profileApi'
|
||||||
export type { MeResponse } from './api/profileApi'
|
export type { MeResponse, UploadAvatarPayload } from './api/profileApi'
|
||||||
export { useMe } from './hooks/useMe'
|
export { useMe } from './hooks/useMe'
|
||||||
|
export { useUploadAvatar } from './hooks/useUploadAvatar'
|
||||||
export type { RegistrationStartPayload, RegistrationCompletePayload, LoginStartPayload, LoginCompletePayload, AuthResponse } from './api/registrationApi'
|
export type { RegistrationStartPayload, RegistrationCompletePayload, LoginStartPayload, LoginCompletePayload, AuthResponse } from './api/registrationApi'
|
||||||
export { useIsAuthenticated } from './hooks/useIsAuthenticated'
|
export { useIsAuthenticated } from './hooks/useIsAuthenticated'
|
||||||
export { useAuth, AUTH_QUERY_KEY } from './hooks/useAuth'
|
export { useAuth, AUTH_QUERY_KEY } from './hooks/useAuth'
|
||||||
|
|||||||
@@ -221,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
flex: 0 0 50%;
|
flex: 1 1 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
|
|||||||
@@ -61,15 +61,6 @@ export function ConverterSection() {
|
|||||||
>
|
>
|
||||||
КУПИТЬ
|
КУПИТЬ
|
||||||
</button>
|
</button>
|
||||||
{/* <button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
className={styles.tab}
|
|
||||||
data-active={c.mode === 'sell' || undefined}
|
|
||||||
onClick={() => c.setMode('sell')}
|
|
||||||
>
|
|
||||||
ПРОДАТЬ
|
|
||||||
</button> */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
|
|||||||
@@ -197,6 +197,27 @@
|
|||||||
border-top: 1px solid var(--glass-border);
|
border-top: 1px solid var(--glass-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--grad-center);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payBtn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.body {
|
.body {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -217,7 +238,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
flex: 0 0 50%;
|
flex: 1 1 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { useConverter } from '../model/useConverter'
|
|||||||
import { progressPercent } from '../model/tiers'
|
import { progressPercent } from '../model/tiers'
|
||||||
import { usePaymentConfig, usePaymentQuote } from '@features/payment'
|
import { usePaymentConfig, usePaymentQuote } from '@features/payment'
|
||||||
import { useDebounce } from '@shared/lib/hooks/useDebounce'
|
import { useDebounce } from '@shared/lib/hooks/useDebounce'
|
||||||
import { AgreementCheckbox } from './AgreementCheckbox'
|
|
||||||
import { CommissionTable } from './CommissionTable'
|
import { CommissionTable } from './CommissionTable'
|
||||||
import styles from './Converter.module.css'
|
import styles from './Converter.module.css'
|
||||||
import { Title } from '@shared/ui/Title/Title'
|
import { Title } from '@shared/ui/Title/Title'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ROUTES } from '@shared/config/routes'
|
||||||
|
|
||||||
export function Converter() {
|
export function Converter() {
|
||||||
const { data: config } = usePaymentConfig()
|
const { data: config } = usePaymentConfig()
|
||||||
@@ -54,15 +55,6 @@ export function Converter() {
|
|||||||
>
|
>
|
||||||
КУПИТЬ
|
КУПИТЬ
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.tab}
|
|
||||||
data-active={c.mode === 'sell' || undefined}
|
|
||||||
onClick={() => c.setMode('sell')}
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
ПРОДАТЬ
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
@@ -116,9 +108,9 @@ export function Converter() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.bottom}>
|
<Link to={ROUTES.CONVERTER} className={styles.payBtn}>
|
||||||
<AgreementCheckbox checked={c.agreed} onToggle={() => c.setAgreed(!c.agreed)} />
|
Перейти к оплате
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,19 @@
|
|||||||
height: 54px;
|
height: 54px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger, #ff5252);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1,14 +1,59 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
import { Button } from '@shared/ui'
|
import { Button } from '@shared/ui'
|
||||||
|
import { useMe, useUploadAvatar } from '@features/auth'
|
||||||
import styles from './ProfileAvatar.module.css'
|
import styles from './ProfileAvatar.module.css'
|
||||||
|
|
||||||
|
function fileToBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string
|
||||||
|
const comma = result.indexOf(',')
|
||||||
|
resolve(comma >= 0 ? result.slice(comma + 1) : result)
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(reader.error)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfileAvatar() {
|
export function ProfileAvatar() {
|
||||||
|
const { data } = useMe()
|
||||||
|
const { mutateAsync: upload, isPending } = useUploadAvatar()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const avatarLink = data?.avatar_link ?? null
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
if (isPending) return
|
||||||
|
inputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const photo_base64 = await fileToBase64(file)
|
||||||
|
await upload({ photo_base64, decoded_bytes: String(file.size) })
|
||||||
|
} catch {
|
||||||
|
setError('Не удалось загрузить фото')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.col}>
|
<div className={styles.col}>
|
||||||
<div className={styles.avatar}>
|
<div className={styles.avatar} onClick={openPicker}>
|
||||||
|
{avatarLink ? (
|
||||||
|
<img src={avatarLink} alt="avatar" className={styles.avatarImg} />
|
||||||
|
) : (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="8" r="4" />
|
<circle cx="12" cy="8" r="4" />
|
||||||
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" />
|
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" />
|
||||||
</svg>
|
</svg>
|
||||||
|
)}
|
||||||
<div className={styles.overlay}>
|
<div className={styles.overlay}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z" />
|
<path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z" />
|
||||||
@@ -16,10 +61,19 @@ export function ProfileAvatar() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
<div className={styles.addPhoto}>
|
<div className={styles.addPhoto}>
|
||||||
<Button variant="ghost">ДОБАВИТЬ ФОТО</Button>
|
<Button variant="ghost" onClick={openPicker} disabled={isPending}>
|
||||||
|
{isPending ? 'ЗАГРУЗКА...' : 'ДОБАВИТЬ ФОТО'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="danger">УДАЛИТЬ ФОТО</Button>
|
{error && <span className={styles.error}>{error}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user