17.05.2026 funny

This commit is contained in:
2026-05-17 13:06:18 +03:00
parent 73d1fd9135
commit bd3d747ede
10 changed files with 157 additions and 39 deletions

View File

@@ -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()

View 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)
},
})
}

View File

@@ -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'

View File

@@ -221,7 +221,7 @@
} }
.tab { .tab {
flex: 0 0 50%; flex: 1 1 0;
} }
.field { .field {

View File

@@ -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}>

View File

@@ -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 {

View File

@@ -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>
) )

View File

@@ -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;

View File

@@ -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}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> {avatarLink ? (
<circle cx="12" cy="8" r="4" /> <img src={avatarLink} alt="avatar" className={styles.avatarImg} />
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" /> ) : (
</svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" />
</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