chore(history): squash 67 commit(s) from 2026-09-25

- feat(accounts): persist device links with opaque hashed tokens, list and revoke endpoints
- feat(frontend): app shell, routing and landing page with the chat-command hero
- feat(frontend): Cyrillic-first fonts (Unbounded, Onest, JetBrains Mono); add i18next and motion
- docs: free mod, bilingual site, one-click download, theme editor, public profiles, rich landing in plans
- feat(accounts): internal gRPC AuthenticateDevice guarded by internal key
- feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch
- feat(accounts): GET /me profile endpoint
- feat(gateway): scaffold crate with config validation and health check
- feat(gateway): reverse proxy to accounts and configs services
- feat(gateway): resolve identity once from access JWT or device token via gRPC
- feat(gateway): per-route and global rate limits with Retry-After
- feat(gateway): CORS for the site origin; docs for gateway and internal contract
- feat(configs): scaffold service with schema, config validation and health check
- feat(configs): four config slots per account with list, get and save
- feat(configs): permanent share codes with regenerate and public load-by-code
- feat(accounts): GetPublicProfiles gRPC for showcase author info
- style(accounts,common): apply rustfmt to existing sources
- feat(configs): public showcase with publish, browse, detail and copy-to-slot
- feat(backend): public profile endpoint and showcase author filter
- fix(gateway): silence clippy collapsible-if and needless-ref warnings
- docs(backend): configs-service implemented; Подсистема 1 backend complete
- feat(mod): add Optimize module skeleton with OptimizeState holder
- feat(mod): gate glass blur behind Optimize no_glass knob
- feat(mod): cut MotionBlur and DoF sample counts behind lite_post knob
- feat(mod): trim procedural sky noise behind lite_sky knob
- feat(mod): drop fade gradients and digit rolls behind lean_hud knob
- docs(todo): mark Optimize module phase 9.2 complete
- refactor(mod): drop dead Renderer2D compatibility shims
- refactor(mod): prune unreachable Renderer2D overload towers
- refactor(mod): remove unused Renderer2D overloads and imports
- docs(todo): mark Renderer2D giant-splitting done (2179 to 1597)
- refactor(mod): extract shader id constants from LoVisualRenderPipelines
- docs(todo): record registry wave 2026-09-25 (Renderer2D, pipelines)
- refactor(mod): move Renderer2D instance state into base class
- refactor(mod): extract Renderer2DRounded drawing family
- refactor(mod): extract Renderer2DPath connector and chamfer family
- refactor(mod): extract Renderer2DShapes circle line and texture primitives
- refactor(mod): extract Renderer2DGlass and Renderer2DItem families
- refactor(mod): prune Renderer2D imports after facade split
- docs(todo): record Renderer2D facade inheritance split (1597 to 475)
- docs: easter eggs — .env honeypot, konami troll mode, devtools banner, IDDQD config, breakable 404 block, 418 teapot
- feat(mod): introduce surface style system core (SurfaceStyle, StyleSpec, StyleConfig, SurfaceRenderer)
- refactor(mod): delegate HudRenderUtil liquid glass draws to SurfaceRenderer (dedupe glass constants)
- refactor(mod): route bespoke glass call sites through SurfaceRenderer.plateSpec
- feat(mod): add Auto option to HUD bg effects via shared HudBgStyles resolution
- feat(mod): flat fallback for no-glass optimize mode and persist global HUD config
- feat(mod): default HUD bg effects to Auto so the global surface style drives widgets
- feat(mod): add global cycle-style hotkey with surface style notification
- feat(mod): add surface style swatch strip under the global style picker
- feat(gateway): reject ambiguous paths and answer .env probes with a honeypot
- fix(gateway): charge failed credentials against the rate limit, allow stale ones on /auth
- feat(frontend): ClickGui theme pipeline generated from the mod, live site theming
- feat(frontend): landing v2 hero — voxel/particle backdrop, live ClickGui, theme strip
- docs(todo): drop the FPS A/B measurement from phase 9.3, close phase 9
- feat(gateway): answer /coffee with a 418 teapot
- feat(frontend): land the rest of landing v2 — HUD, module wall, showcase, FAQ, footer
- feat(frontend): one-click download from GitHub releases, changelog page, release CI
- feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links
- fix(frontend): landing HUD playground now shows real mod widgets (fps, coordinates, module list, keybinds, ping)
- style(frontend): apply ClickGui glass effect to landing HUD playground widgets
- fix(frontend): prevent color field row overflow in theme editor grid
- fix(frontend): never attach stale bearer token to /auth/* requests
- fix(configs): unpublish/publish can no longer bypass moderation
- refactor(accounts): shrink auth/handlers.rs under the 250-line cap
- fix(accounts): tolerate concurrent refresh without killing every session
- fix(gateway): minor hardening from the backend review
- feat(configs): IDDQD easter egg config
This commit is contained in:
loki5512344 2026-09-25 20:22:13 +02:00
parent 72bc4c7148
commit 7f4b532f99
257 changed files with 13085 additions and 6582 deletions

View file

@ -68,7 +68,7 @@ export function createApiClient(
headers.set('content-type', 'application/json')
body = JSON.stringify(options.json)
}
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`)
if (accessToken && !path.startsWith('/auth/')) headers.set('authorization', `Bearer ${accessToken}`)
return fetchImpl(`${baseUrl}${path}`, {
method: options.method ?? 'GET',
headers,

View file

@ -1,22 +1,24 @@
import type { TFunction } from 'i18next'
import { ApiError } from './client'
const BY_STATUS: Record<number, string> = {
400: 'Проверь введённые данные.',
401: 'Нужно войти в аккаунт.',
403: 'Недостаточно прав.',
404: 'Не найдено.',
409: 'Уже существует.',
}
const BY_STATUS = {
400: 'errors.badRequest',
401: 'errors.unauthorized',
403: 'errors.forbidden',
404: 'errors.notFound',
409: 'errors.conflict',
} as const
export function describeError(err: unknown, overrides: Partial<Record<number, string>> = {}): string {
export function describeError(t: TFunction<'common'>, err: unknown, overrides: Partial<Record<number, string>> = {}): string {
if (err instanceof ApiError) {
if (err.status === 429) {
return err.retryAfter
? `Слишком много попыток. Подожди ${err.retryAfter} с.`
: 'Слишком много попыток. Подожди немного.'
? t('errors.rateLimited', { seconds: err.retryAfter })
: t('errors.rateLimitedGeneric')
}
if (err.status >= 500) return 'Сервер не ответил. Попробуй через минуту.'
return overrides[err.status] ?? BY_STATUS[err.status] ?? 'Проверь введённые данные.'
if (err.status >= 500) return t('errors.serverError')
const key = BY_STATUS[err.status as keyof typeof BY_STATUS]
return overrides[err.status] ?? (key ? t(key) : t('errors.badRequest'))
}
return 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.'
return t('errors.network')
}

View file

@ -1,8 +1,11 @@
import { expect, test } from 'vitest'
import { initI18n } from '../../../app/i18n'
import { ApiError, createApiClient } from '../client'
import { describeError } from '../errors'
import { json, mockFetch } from '../../../test/fetch'
const t = initI18n('ru').getFixedT('ru', 'common')
test('sends json with bearer token and cookies', async () => {
const fetch = mockFetch({ 'PUT /configs/1': () => json({ ok: true }) })
const api = createApiClient()
@ -62,6 +65,15 @@ test('auth endpoints never trigger refresh', async () => {
expect(fetch).toHaveBeenCalledTimes(1)
})
test('never sends a stale bearer token on /auth/* requests', async () => {
const fetch = mockFetch({ 'POST /auth/logout': () => new Response(null, { status: 204 }) })
const api = createApiClient()
api.setAccessToken('stale')
await api.request('/auth/logout', { method: 'POST' })
const [, init] = fetch.mock.calls[0]
expect(new Headers(init?.headers).get('authorization')).toBeNull()
})
test('429 carries retry-after; 204 resolves undefined', async () => {
mockFetch({
'POST /auth/login': () => json({ error: 'too many requests' }, 429, { 'retry-after': '42' }),
@ -73,10 +85,10 @@ test('429 carries retry-after; 204 resolves undefined', async () => {
})
test('describeError covers 429, network failure and status defaults', () => {
expect(describeError(new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
expect(describeError(new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
expect(describeError(new TypeError('Failed to fetch'))).toBe(
expect(describeError(t, new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
expect(describeError(t, new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
expect(describeError(t, new TypeError('Failed to fetch'))).toBe(
'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
)
expect(describeError(new ApiError(404, 'x'))).toBe('Не найдено.')
expect(describeError(t, new ApiError(404, 'x'))).toBe('Не найдено.')
})

View file

@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next'
const LANGS = ['ru', 'en'] as const
export function LanguageSwitch() {
const { i18n, t } = useTranslation()
const current = i18n.language
return (
<div className="flex items-center gap-1 text-sm text-frost" role="group" aria-label="Language">
{LANGS.map((lng) => (
<button
key={lng}
type="button"
aria-pressed={current === lng}
onClick={() => i18n.changeLanguage(lng)}
className={`rounded-md px-2 py-1 transition-colors ${current === lng ? 'text-snow' : 'hover:text-snow'}`}
>
{t(`languageSwitch.${lng}`)}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,52 @@
import type { commonRu, Translation } from './common.ru'
export const commonEn = {
skipToContent: 'Skip to content',
nav: {
mainNav: 'Main navigation',
showcase: 'Showcase',
myConfigs: 'My configs',
linkGame: 'Link game',
menu: 'Menu',
login: 'Log in',
},
notFound: {
title: 'Page not found',
body: 'This link may be outdated.',
home: 'Go home',
},
shareCode: {
copy: 'Copy',
copied: 'Copied',
},
languageSwitch: {
ru: 'RU',
en: 'EN',
},
footer: {
about: 'A free visuals mod for Minecraft 26.2 on Fabric.',
sectionProduct: 'Product',
sectionProject: 'Project',
github: 'GitHub',
download: 'Download',
legalTitle: 'Legal',
disclaimer: 'Not affiliated with Mojang or Microsoft.',
},
time: {
days_one: '{{count}} day',
days_few: '{{count}} days',
days_many: '{{count}} days',
days_other: '{{count}} days',
},
errors: {
badRequest: 'Check what you entered.',
unauthorized: 'You need to log in.',
forbidden: 'Not enough permissions.',
notFound: 'Not found.',
conflict: 'Already exists.',
rateLimited: 'Too many attempts. Wait {{seconds}}s.',
rateLimitedGeneric: 'Too many attempts. Wait a bit.',
serverError: "The server didn't respond. Try again in a minute.",
network: 'No connection to the server. Check your internet and try again.',
},
} satisfies Translation<typeof commonRu>

View file

@ -0,0 +1,54 @@
export const commonRu = {
skipToContent: 'Перейти к содержимому',
nav: {
mainNav: 'Основная навигация',
showcase: 'Витрина',
myConfigs: 'Мои конфиги',
linkGame: 'Привязать игру',
menu: 'Меню',
login: 'Войти',
},
notFound: {
title: 'Такой страницы нет',
body: 'Возможно, ссылка устарела.',
home: 'На главную',
},
shareCode: {
copy: 'Скопировать',
copied: 'Скопировано',
},
languageSwitch: {
ru: 'RU',
en: 'EN',
},
footer: {
about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.',
sectionProduct: 'Продукт',
sectionProject: 'Проект',
github: 'GitHub',
download: 'Скачать',
legalTitle: 'Правовое',
disclaimer: 'Не связано с Mojang или Microsoft.',
},
time: {
days_one: '{{count}} день',
days_few: '{{count}} дня',
days_many: '{{count}} дней',
days_other: '{{count}} дня',
},
errors: {
badRequest: 'Проверь введённые данные.',
unauthorized: 'Нужно войти в аккаунт.',
forbidden: 'Недостаточно прав.',
notFound: 'Не найдено.',
conflict: 'Уже существует.',
rateLimited: 'Слишком много попыток. Подожди {{seconds}} с.',
rateLimitedGeneric: 'Слишком много попыток. Подожди немного.',
serverError: 'Сервер не ответил. Попробуй через минуту.',
network: 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
},
} as const
export type Translation<T> = {
[K in keyof T]: T[K] extends string ? string : Translation<T[K]>
}

View file

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { I18nextProvider, useTranslation } from 'react-i18next'
import { expect, test } from 'vitest'
import { initI18n } from '../../../app/i18n'
import { LanguageSwitch } from '../LanguageSwitch'
function Probe() {
const { t } = useTranslation('auth')
return <p>{t('login.submit')}</p>
}
test('switching language re-renders strings and sets <html lang>', async () => {
const user = userEvent.setup()
const i18n = initI18n('ru')
render(
<I18nextProvider i18n={i18n}>
<Probe />
<LanguageSwitch />
</I18nextProvider>,
)
expect(screen.getByText('Войти')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'EN' }))
expect(screen.getByText('Log in')).toBeInTheDocument()
expect(document.documentElement.lang).toBe('en')
})
test('russian plurals', () => {
const i18n = initI18n('ru')
expect(i18n.t('common:time.days', { count: 1 })).toBe('1 день')
expect(i18n.t('common:time.days', { count: 3 })).toBe('3 дня')
expect(i18n.t('common:time.days', { count: 5 })).toBe('5 дней')
})

View file

@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next'
import { Outlet } from 'react-router'
import { Footer } from './Footer'
import { TopBar } from './TopBar'
export function AppShell() {
const { t } = useTranslation('common')
return (
<>
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-2 focus:z-10 focus:rounded-md focus:bg-slate focus:px-3 focus:py-2"
>
{t('skipToContent')}
</a>
<TopBar />
<main id="main" className="mx-auto w-full max-w-[1120px] px-4 sm:px-6">
<Outlet />
</main>
<Footer />
</>
)
}

View file

@ -0,0 +1,49 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { DOWNLOAD_URL, GITHUB_REPO } from '../../features/download/api'
import { LanguageSwitch } from '../i18n/LanguageSwitch'
export function Footer() {
const { t } = useTranslation('common')
const product = [
{ to: '/showcase', label: t('nav.showcase') },
{ to: '/configs', label: t('nav.myConfigs') },
{ to: '/themes', label: 'Themes' },
]
const project = [
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
{ href: DOWNLOAD_URL, label: t('footer.download') },
]
return (
<footer className="mt-8 border-t border-shoal bg-slate">
<div className="mx-auto grid w-full max-w-[1120px] gap-10 px-4 py-12 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr]">
<div className="flex min-w-0 flex-col gap-3">
<Link to="/" className="font-display text-xl font-bold text-snow">LoVisual</Link>
<p className="max-w-[38ch] text-sm text-frost">{t('footer.about')}</p>
<LanguageSwitch />
</div>
<nav aria-label={t('footer.sectionProduct')} className="flex min-w-0 flex-col gap-2">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProduct')}</span>
{product.map((i) => (
<Link key={i.to} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link>
))}
</nav>
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProject')}</span>
{project.map((i) => (
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
{i.label}
</a>
))}
</nav>
</div>
<div className="border-t border-shoal">
<p className="mx-auto w-full max-w-[1120px] px-4 py-5 text-xs text-frost sm:px-6">{t('footer.disclaimer')}</p>
</div>
</footer>
)
}

View file

@ -0,0 +1,94 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, NavLink } from 'react-router'
import { useSession } from '../../features/auth/session'
import { LanguageSwitch } from '../i18n/LanguageSwitch'
import type { Me } from '../types'
import { ButtonLink } from '../ui/Button'
export function TopBar() {
const { t } = useTranslation(['common', 'download'])
const [open, setOpen] = useState(false)
const nav = [
{ to: '/showcase', label: t('nav.showcase') },
{ to: '/configs', label: t('nav.myConfigs') },
{ to: '/link', label: t('nav.linkGame') },
]
return (
<header className="border-b border-shoal bg-slate">
<div className="mx-auto flex w-full max-w-[1120px] flex-wrap items-center gap-x-6 px-4 sm:px-6">
<Link to="/" className="py-4 font-display text-xl font-bold text-snow">
LoVisual
</Link>
<nav
id="site-nav"
aria-label={t('nav.mainNav')}
className={`${open ? 'flex' : 'hidden'} order-last w-full flex-col gap-1 pb-3 sm:order-none sm:flex sm:w-auto sm:flex-row sm:gap-6 sm:pb-0`}
>
{nav.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={() => setOpen(false)}
className={({ isActive }) =>
`py-2 text-sm transition-colors ${isActive ? 'text-snow' : 'text-frost hover:text-snow'}`
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-3">
<ButtonLink to="/download" variant="primary">
{t('download:nav')}
</ButtonLink>
<LanguageSwitch />
<button
type="button"
aria-expanded={open}
aria-controls="site-nav"
onClick={() => setOpen((v) => !v)}
className="rounded-md px-2 py-2 text-sm text-frost hover:text-snow sm:hidden"
>
{t('nav.menu')}
</button>
<AccountButton />
</div>
</div>
</header>
)
}
function AccountButton() {
const { t } = useTranslation('common')
const { status, me } = useSession()
if (status === 'loading') return null
if (!me) {
return (
<ButtonLink to="/login" variant="quiet">
{t('nav.login')}
</ButtonLink>
)
}
return (
<Link to="/account" className="flex min-w-0 items-center gap-2 py-2 text-sm text-snow hover:text-ice">
<Avatar me={me} />
<span className="max-w-[8rem] truncate sm:max-w-[12rem]">{me.display_nick}</span>
</Link>
)
}
function Avatar({ me }: { me: Me }) {
if (me.avatar_url) {
return <img src={me.avatar_url} alt="" className="size-7 shrink-0 rounded-full object-cover" />
}
return (
<span
aria-hidden="true"
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-shoal font-display text-xs font-bold text-snow"
>
{me.display_nick.charAt(0).toUpperCase()}
</span>
)
}

View file

@ -0,0 +1,47 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { routes } from '../../../app/routes'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
test('anonymous visitor sees nav and a login link', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp(routes, '/')
expect(await screen.findByRole('link', { name: 'Войти' })).toHaveAttribute('href', '/login')
expect(screen.getAllByRole('link', { name: 'Витрина' }).length).toBeGreaterThan(0)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Визуалы, которые видно')
})
test('signed-in visitor sees their nick instead of login', async () => {
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json({ id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '' }),
})
renderApp(routes, '/')
expect(await screen.findByRole('link', { name: /Rider/ })).toHaveAttribute('href', '/account')
expect(screen.queryByRole('link', { name: 'Войти' })).not.toBeInTheDocument()
})
test('unknown path shows not found', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp(routes, '/definitely-not-here')
expect(await screen.findByRole('heading', { name: 'Такой страницы нет' })).toBeInTheDocument()
})
test('menu disclosure toggles aria-expanded', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp(routes, '/')
const toggle = await screen.findByRole('button', { name: 'Меню' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
await userEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
})
test('footer carries the github link and the disclaimer', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp(routes, '/')
expect(await screen.findByText('Не связано с Mojang или Microsoft.')).toBeInTheDocument()
const gh = screen.getByRole('link', { name: 'GitHub' })
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
})

View file

@ -0,0 +1,7 @@
/**
* True when the visitor asked for reduced motion. Unknown support (no matchMedia, e.g. jsdom)
* counts as reduced: the safe default, and it keeps tests deterministic.
*/
export function prefersReducedMotion(): boolean {
return typeof window.matchMedia !== 'function' || window.matchMedia('(prefers-reduced-motion: reduce)').matches
}

View file

@ -1,7 +1,11 @@
import type { ButtonHTMLAttributes } from 'react'
import { Link, type LinkProps } from 'react-router'
type Variant = 'primary' | 'quiet' | 'danger'
const BASE =
'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors'
const VARIANTS: Record<Variant, string> = {
primary: 'bg-ice text-abyss hover:bg-ice/90',
quiet: 'bg-transparent text-snow border border-shoal hover:border-frost',
@ -21,8 +25,13 @@ export function Button({
type={type}
disabled={disabled || busy}
aria-busy={busy || undefined}
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
className={`${BASE} disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
{...rest}
/>
)
}
/** A router link that looks like a button — for navigation CTAs. */
export function ButtonLink({ variant = 'primary', className = '', ...rest }: LinkProps & { variant?: Variant }) {
return <Link className={`${BASE} ${VARIANTS[variant]} ${className}`} {...rest} />
}

View file

@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from './Button'
export function ShareCode({ code, label }: { code: string; label?: string }) {
const { t } = useTranslation('common')
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
@ -19,10 +21,10 @@ export function ShareCode({ code, label }: { code: string; label?: string }) {
{label ? <span className="text-sm text-frost">{label}</span> : null}
<code className="font-code text-lg tracking-widest text-ice">{code}</code>
<Button variant="quiet" onClick={copy}>
Скопировать
{t('shareCode.copy')}
</Button>
<span aria-live="polite" className="text-sm text-frost">
{copied ? 'Скопировано' : ''}
{copied ? t('shareCode.copied') : ''}
</span>
</div>
)