Initialize Vyryi front-end project with React, Vite, and TypeScript. Add essential files including configuration, environment variables, and basic structure. Implement authentication, routing, and API interaction for a Telegram Mini App UI. Include README and styling for a cohesive user experience.

This commit is contained in:
Redsandy
2026-08-12 11:27:07 +03:00
commit 59d4a691d6
30 changed files with 3933 additions and 0 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://127.0.0.1:8000

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
.env.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
README.md Normal file
View File

@@ -0,0 +1,32 @@
# Vyryi Front
React (Vite + TypeScript) Telegram Mini App UI for **Vyryi**.
## Stack
- React 19 + React Router
- Local auth via backend `POST /auth/dev-login`
- Dark folk visual style matching the project logo
## Screens
1. `/` — logo + user agreement gate
2. `/home` — balance, stub deposit, slot stubs
3. `/games/:slug` — game placeholder
## Quick start
```bash
cd vyryi-front
npm install
copy .env.example .env
npm run dev
```
Open http://127.0.0.1:5173 — backend must be running on port 8000.
## Env
```
VITE_API_URL=http://127.0.0.1:8000
```

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#2F3027" />
<title>Vyryi</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2076
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "vyryi-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.8",
"@react-three/fiber": "^9.7.0",
"@react-three/postprocessing": "^3.0.5",
"gsap": "^3.15.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"three": "^0.185.1"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

53
src/App.tsx Normal file
View File

@@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from './auth'
import { PRIVACY_DOC, TERMS_DOC } from './legal'
import { AgreementPage } from './pages/AgreementPage'
import { GamePage } from './pages/GamePage'
import { HomePage } from './pages/HomePage'
import { LegalDocumentPage } from './pages/LegalDocumentPage'
import { RootRedirect } from './pages/RootRedirect'
import { GateTransitProvider } from './vfx/GateTransitProvider'
function BootShell({ children }: { children: ReactNode }) {
const { loading } = useAuth()
if (loading) {
return (
<div className="boot">
<img src="/logo.png" alt="Vyryi" className="boot__logo" />
<p className="brand">Vyryi</p>
</div>
)
}
return children
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<GateTransitProvider>
<BootShell>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/onboarding" element={<AgreementPage />} />
<Route
path="/legal/terms"
element={<LegalDocumentPage title={TERMS_DOC.title} paragraphs={TERMS_DOC.paragraphs} />}
/>
<Route
path="/legal/privacy"
element={
<LegalDocumentPage title={PRIVACY_DOC.title} paragraphs={PRIVACY_DOC.paragraphs} />
}
/>
<Route path="/home" element={<HomePage />} />
<Route path="/games/:slug" element={<GamePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BootShell>
</GateTransitProvider>
</BrowserRouter>
</AuthProvider>
)
}

83
src/api.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { GameStub, TokenResponse, User, Wallet } from './types'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000'
const TOKEN_KEY = 'vyryi_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers)
headers.set('Content-Type', 'application/json')
const token = getToken()
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
const response = await fetch(`${API_URL}${path}`, {
...options,
headers,
})
if (!response.ok) {
let detail = `Request failed (${response.status})`
try {
const body = (await response.json()) as { detail?: string }
if (body.detail) detail = body.detail
} catch {
/* ignore */
}
throw new Error(detail)
}
return (await response.json()) as T
}
export const api = {
async devLogin(payload?: {
telegram_id?: number
username?: string
first_name?: string
}): Promise<TokenResponse> {
return request<TokenResponse>('/auth/dev-login', {
method: 'POST',
body: JSON.stringify(payload ?? {}),
})
},
getMe(): Promise<User> {
return request<User>('/me')
},
acceptAgreement(): Promise<User> {
return request<User>('/me/agreement', { method: 'POST' })
},
getWallet(): Promise<Wallet> {
return request<Wallet>('/wallet')
},
deposit(amount: number): Promise<Wallet> {
return request<Wallet>('/wallet/deposit', {
method: 'POST',
body: JSON.stringify({ amount }),
})
},
listGames(): Promise<GameStub[]> {
return request<GameStub[]>('/games')
},
getGame(slug: string): Promise<GameStub> {
return request<GameStub>(`/games/${slug}`)
},
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

83
src/auth.tsx Normal file
View File

@@ -0,0 +1,83 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { api, clearToken, getToken, setToken } from './api'
import type { User } from './types'
type AuthState = {
user: User | null
loading: boolean
error: string | null
refresh: () => Promise<void>
acceptAgreement: () => Promise<void>
setUser: (user: User) => void
}
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const bootstrap = useCallback(async () => {
setLoading(true)
setError(null)
try {
// Local MVP: no Telegram bot yet — always ensure a JWT via dev-login.
if (!getToken()) {
const token = await api.devLogin({
telegram_id: 100001,
username: 'dev_user',
first_name: 'Путник',
})
setToken(token.access_token)
}
const me = await api.getMe()
setUser(me)
} catch (err) {
clearToken()
setUser(null)
setError(err instanceof Error ? err.message : 'Не удалось войти')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void bootstrap()
}, [bootstrap])
const acceptAgreement = useCallback(async () => {
const updated = await api.acceptAgreement()
setUser(updated)
}, [])
const value = useMemo(
() => ({
user,
loading,
error,
refresh: bootstrap,
acceptAgreement,
setUser,
}),
[user, loading, error, bootstrap, acceptAgreement],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error('useAuth must be used within AuthProvider')
}
return ctx
}

618
src/index.css Normal file
View File

@@ -0,0 +1,618 @@
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;0,600;0,700;1,500&family=Source+Sans+3:wght@400;500;600&display=swap');
:root {
--bg-deep: #1a1b16;
--bg: #2f3027;
--bg-lift: #3a3b31;
--gold: #a68946;
--gold-soft: #c4a96a;
--burgundy: #8c2b2b;
--eye: #c8d97d;
--cream: #e8e0d0;
--muted: #9a9486;
--ink: #0f100d;
--radius: 10px;
--font-display: 'Cormorant Garamond', 'Palatino Linotype', serif;
--font-body: 'Source Sans 3', 'Segoe UI', sans-serif;
color: var(--cream);
background: var(--bg-deep);
font-family: var(--font-body);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
margin: 0;
}
body {
min-height: 100vh;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(166, 137, 70, 0.18), transparent 55%),
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(140, 43, 43, 0.12), transparent 50%),
linear-gradient(165deg, var(--bg-deep) 0%, var(--bg) 45%, #25261f 100%);
background-attachment: fixed;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.07;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
z-index: 0;
}
#root {
position: relative;
z-index: 1;
}
a {
color: inherit;
text-decoration: none;
}
button,
input {
font: inherit;
}
.brand {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: clamp(2.8rem, 10vw, 4rem);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--gold-soft);
text-shadow: 0 2px 24px rgba(166, 137, 70, 0.35);
animation: brand-in 0.9s ease-out both;
}
.brand--sm {
font-size: 1.35rem;
letter-spacing: 0.12em;
animation: none;
}
.boot {
min-height: 100vh;
display: grid;
place-content: center;
gap: 0.75rem;
text-align: center;
}
.boot__logo {
width: min(42vw, 180px);
margin: 0 auto;
animation: logo-rise 1.1s ease-out both;
filter: drop-shadow(0 8px 28px rgba(0, 0, 0, 0.55));
}
.gate {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1.25rem 3rem;
position: relative;
overflow: hidden;
transform-origin: center center;
}
.gate__glow {
position: absolute;
width: 280px;
height: 280px;
border-radius: 50%;
background: radial-gradient(circle, rgba(200, 217, 125, 0.16), transparent 70%);
top: 12%;
left: 50%;
transform: translateX(-50%);
animation: pulse-glow 4s ease-in-out infinite;
pointer-events: none;
}
.gate__content {
width: min(100%, 420px);
text-align: center;
position: relative;
}
.gate__logo {
width: min(58vw, 220px);
display: block;
margin: 0 auto 0.5rem;
animation: logo-rise 1.1s ease-out both;
filter: drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6));
}
.gate__tagline {
margin: 0.35rem 0 1.75rem;
color: var(--muted);
font-size: 1.05rem;
font-style: italic;
font-family: var(--font-display);
animation: fade-up 1s 0.2s ease-out both;
}
.agreement {
text-align: left;
animation: fade-up 0.9s 0.35s ease-out both;
}
.agreement__check {
display: flex;
gap: 0.65rem;
align-items: flex-start;
margin-bottom: 1rem;
color: var(--cream);
font-size: 0.95rem;
cursor: pointer;
}
.agreement__check input {
margin-top: 0.2rem;
accent-color: var(--gold);
}
.legal-link {
color: var(--gold-soft);
text-decoration: underline;
text-underline-offset: 0.15em;
}
.legal-link:hover {
color: var(--eye);
}
.legal-page {
max-width: 560px;
margin: 0 auto;
padding: 1.25rem 1.15rem 2.5rem;
}
.legal-doc {
margin-top: 1.25rem;
}
.legal-doc h1 {
margin: 0 0 1rem;
font-family: var(--font-display);
font-size: clamp(1.6rem, 5vw, 2rem);
font-weight: 600;
color: var(--gold-soft);
}
.legal-doc p {
margin: 0 0 0.9rem;
color: var(--muted);
line-height: 1.55;
font-size: 0.98rem;
}
.legal-doc p:last-child {
margin-bottom: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
border: none;
border-radius: var(--radius);
padding: 0.85rem 1.1rem;
cursor: pointer;
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease, opacity 0.15s ease;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn:not(:disabled):active {
transform: translateY(1px);
}
.btn--gold {
background: linear-gradient(180deg, var(--gold-soft), var(--gold));
color: var(--ink);
font-weight: 600;
letter-spacing: 0.04em;
}
.btn--gold:not(:disabled):hover {
filter: brightness(1.06);
}
.btn--sm {
width: auto;
padding: 0.45rem 0.85rem;
font-size: 0.85rem;
}
.btn--outline {
background: transparent;
border: 1px solid var(--gold);
color: var(--gold-soft);
}
.btn--outline:not(:disabled):hover {
background: rgba(166, 137, 70, 0.12);
}
.btn--ghost {
background: transparent;
color: var(--muted);
margin-top: 0.5rem;
}
.error {
color: #e8a0a0;
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.linkish {
display: inline;
margin-left: 0.5rem;
background: none;
border: none;
color: var(--eye);
cursor: pointer;
text-decoration: underline;
padding: 0;
}
.home {
max-width: 520px;
margin: 0 auto;
padding: 1.25rem 1.15rem 2.5rem;
}
.home__top {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.home__brand-row {
display: flex;
align-items: center;
gap: 0.65rem;
}
.home__mark {
width: 42px;
height: 42px;
object-fit: contain;
}
.balance-panel {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
'label btn'
'value btn';
gap: 0.15rem 1rem;
align-items: center;
padding: 0.9rem 1rem;
background: rgba(15, 16, 13, 0.4);
border-bottom: 1px solid rgba(166, 137, 70, 0.4);
}
.balance-panel__label {
grid-area: label;
color: var(--muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.balance-panel__value {
grid-area: value;
font-family: var(--font-display);
font-size: 2rem;
font-weight: 600;
color: var(--eye);
}
.balance-panel .btn {
grid-area: btn;
}
.section-lead {
margin: 0 0 1rem;
color: var(--muted);
font-size: 0.95rem;
}
.games h2 {
margin: 0 0 0.35rem;
font-family: var(--font-display);
font-size: 1.75rem;
font-weight: 600;
color: var(--gold-soft);
}
.games__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.game-link {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.25rem 0.75rem;
padding: 0.95rem 0.25rem 1rem;
border-bottom: 1px solid rgba(166, 137, 70, 0.22);
transition: background 0.2s ease, padding-left 0.2s ease;
}
.game-link:hover {
background: rgba(166, 137, 70, 0.06);
padding-left: 0.35rem;
}
.game-link__title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 600;
}
.game-link__status {
color: var(--burgundy);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
align-self: center;
}
.game-link__desc {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.9rem;
}
.toast {
margin: 0 0 1rem;
color: var(--eye);
font-size: 0.9rem;
}
.modal {
position: fixed;
inset: 0;
background: rgba(10, 10, 8, 0.72);
display: grid;
place-items: center;
padding: 1.25rem;
z-index: 20;
animation: fade-in 0.2s ease;
}
.modal__panel {
width: min(100%, 360px);
padding: 1.35rem 1.2rem;
background: linear-gradient(180deg, var(--bg-lift), var(--bg));
border: 1px solid rgba(166, 137, 70, 0.4);
border-radius: var(--radius);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.modal__panel h3 {
margin: 0 0 0.35rem;
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--gold-soft);
}
.deposit-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.game-page {
max-width: 520px;
margin: 0 auto;
padding: 1.25rem 1.15rem 2.5rem;
}
.back-link {
color: var(--muted);
font-size: 0.9rem;
}
.back-link:hover {
color: var(--gold-soft);
}
.game-stub {
margin-top: 2rem;
text-align: center;
}
.game-stub__mark {
width: 120px;
margin-bottom: 1rem;
opacity: 0.9;
animation: logo-rise 0.8s ease-out both;
}
.game-stub h1 {
margin: 0 0 0.5rem;
font-family: var(--font-display);
font-size: 2rem;
color: var(--gold-soft);
}
.game-stub p {
color: var(--muted);
margin: 0 0 1rem;
}
.game-stub__badge {
display: inline-block;
color: var(--burgundy);
border: 1px solid rgba(140, 43, 43, 0.5);
padding: 0.35rem 0.85rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.75rem;
}
@keyframes logo-rise {
from {
opacity: 0;
transform: translateY(18px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes brand-in {
from {
opacity: 0;
letter-spacing: 0.2em;
}
to {
opacity: 1;
letter-spacing: 0.08em;
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pulse-glow {
0%,
100% {
opacity: 0.55;
transform: translateX(-50%) scale(1);
}
50% {
opacity: 1;
transform: translateX(-50%) scale(1.08);
}
}
@media (min-width: 640px) {
.home__top {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
/* Gate logo fly-in transit */
html.gate-transit-lock,
html.gate-transit-lock body {
overflow: hidden;
touch-action: none;
}
.gate-transit {
position: fixed;
inset: 0;
z-index: 9000;
pointer-events: none;
overflow: hidden;
}
.gate-transit__stage {
position: absolute;
inset: 0;
perspective: 1400px;
perspective-origin: 50% 46%;
}
.gate-transit__hero {
/* Hidden until JS pins it over .gate__logo — avoids first-frame size flash */
position: fixed;
top: 0;
left: 0;
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
display: block;
margin: 0;
pointer-events: none;
user-select: none;
object-fit: contain;
filter: drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6));
}
.gate-transit__veil {
position: absolute;
inset: 0;
background: #000;
opacity: 0;
z-index: 3;
}
.home--from-gate {
animation: home-from-void 0.95s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes home-from-void {
from {
opacity: 0;
transform: translateY(18px) scale(0.985);
filter: blur(8px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.home--from-gate {
animation: fade-in 0.45s ease-out both;
}
}

23
src/legal.ts Normal file
View File

@@ -0,0 +1,23 @@
export const TERMS_DOC = {
title: 'Пользовательское соглашение',
paragraphs: [
'Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между пользователем и сервисом Vyryi (далее — «Сервис») при использовании Telegram Mini App Vyryi.',
'Vyryi — развлекательное приложение. Баланс в Сервисе можно пополнять. В текущей версии пополнение реализовано в тестовом режиме (заглушка). Вывод средств недоступен и не предусмотрен.',
'Игры и иные функции Сервиса носят развлекательный характер. Участвуя в них, пользователь подтверждает, что понимает правила и принимает невозможность вывода денежных средств.',
'Сервис вправе изменять набор игр, интерфейс, условия начисления и отображения баланса, а также временно ограничивать доступ для технического обслуживания.',
'Пользователь обязуется не пытаться обойти ограничения Сервиса, не использовать вредоносное ПО и не нарушать работу приложения.',
'Продолжая использование Сервиса после принятия Соглашения, пользователь подтверждает, что ознакомился с его условиями и полностью их принимает.',
],
}
export const PRIVACY_DOC = {
title: 'Политика обработки персональных данных',
paragraphs: [
'Настоящий документ описывает, какие данные обрабатывает сервис Vyryi и для каких целей.',
'В рамках работы приложения могут обрабатываться идентификатор Telegram, имя пользователя, отображаемое имя, данные о принятии соглашений, баланс и история пополнений.',
'Данные используются для аутентификации, предоставления функций Сервиса, учёта баланса, исполнения пользовательских соглашений и обеспечения безопасности.',
'Данные хранятся на серверах, необходимых для работы приложения, и не передаются третьим лицам, за исключением случаев, прямо предусмотренных законом или технически необходимых для работы инфраструктуры.',
'Пользователь может прекратить использование Сервиса. Вопросы по обработке данных можно направить оператору Сервиса после появления публичных контактов поддержки.',
'Используя Vyryi и принимая соответствующие отметки на стартовом экране, пользователь подтверждает согласие на обработку указанных данных в объёме, необходимом для работы приложения.',
],
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,93 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../auth'
import { useGateTransit } from '../vfx/GateTransitProvider'
export function AgreementPage() {
const { user, loading, error, acceptAgreement, refresh } = useAuth()
const { start, active } = useGateTransit()
const [termsAccepted, setTermsAccepted] = useState(false)
const [privacyAccepted, setPrivacyAccepted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
const canEnter = termsAccepted && privacyAccepted
async function onEnter() {
if (!canEnter || submitting || active) return
setSubmitting(true)
setLocalError(null)
const result = await start(async () => {
if (!user?.agreement_accepted) {
await acceptAgreement()
}
})
if (!result.ok) {
setLocalError(result.error ?? 'Ошибка')
setSubmitting(false)
}
}
return (
<main className="gate">
<div className="gate__glow" aria-hidden />
<div className="gate__content">
<img className="gate__logo" src="/logo.png" alt="Vyryi" />
<h1 className="brand">Vyryi</h1>
<p className="gate__tagline">Врата, за которыми нет возврата.</p>
<section className="agreement" aria-label="Согласия">
<label className="agreement__check">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
/>
<span>
Я принимаю{' '}
<Link to="/legal/terms" className="legal-link" onClick={(e) => e.stopPropagation()}>
пользовательское соглашение
</Link>
</span>
</label>
<label className="agreement__check">
<input
type="checkbox"
checked={privacyAccepted}
onChange={(e) => setPrivacyAccepted(e.target.checked)}
/>
<span>
Я согласен с{' '}
<Link to="/legal/privacy" className="legal-link" onClick={(e) => e.stopPropagation()}>
обработкой персональных данных
</Link>
</span>
</label>
{(error || localError) && (
<p className="error" role="alert">
{localError ?? error}
{error && (
<button type="button" className="linkish" onClick={() => void refresh()}>
Повторить
</button>
)}
</p>
)}
<button
type="button"
className="btn btn--gold"
disabled={!canEnter || submitting || loading || active}
onClick={() => void onEnter()}
>
{submitting || active ? 'Открываем…' : 'Войти'}
</button>
</section>
</div>
</main>
)
}

41
src/pages/GamePage.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import { Link, Navigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../auth'
import type { GameStub } from '../types'
export function GamePage() {
const { slug } = useParams<{ slug: string }>()
const { user, loading } = useAuth()
const [game, setGame] = useState<GameStub | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!slug) return
void api
.getGame(slug)
.then(setGame)
.catch((err: unknown) => setError(err instanceof Error ? err.message : 'Не найдено'))
}, [slug])
if (!loading && user && !user.agreement_accepted) {
return <Navigate to="/" replace />
}
return (
<main className="game-page">
<Link to="/home" className="back-link">
Назад
</Link>
{error && <p className="error">{error}</p>}
{game && (
<section className="game-stub">
<img src="/logo.png" alt="" className="game-stub__mark" />
<h1>{game.title}</h1>
<p>{game.description}</p>
<p className="game-stub__badge">В разработке</p>
</section>
)}
</main>
)
}

118
src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../auth'
import type { GameStub } from '../types'
import { GATE_FROM_KEY } from '../vfx/gateCaps'
const DEPOSIT_AMOUNTS = [100, 500, 1000]
export function HomePage() {
const { user, loading, setUser } = useAuth()
const [games, setGames] = useState<GameStub[]>([])
const [depositOpen, setDepositOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [fromGate, setFromGate] = useState(false)
useEffect(() => {
void api.listGames().then(setGames).catch(() => setGames([]))
}, [])
useEffect(() => {
try {
if (sessionStorage.getItem(GATE_FROM_KEY) === '1') {
sessionStorage.removeItem(GATE_FROM_KEY)
setFromGate(true)
}
} catch {
/* ignore */
}
}, [])
if (!loading && user && !user.agreement_accepted) {
return <Navigate to="/" replace />
}
if (!loading && !user) {
return <Navigate to="/" replace />
}
async function deposit(amount: number) {
setBusy(true)
setMessage(null)
try {
const wallet = await api.deposit(amount)
if (user) {
setUser({ ...user, balance: wallet.balance })
}
setMessage(`Зачислено ${amount}`)
setDepositOpen(false)
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Ошибка пополнения')
} finally {
setBusy(false)
}
}
return (
<main className={fromGate ? 'home home--from-gate' : 'home'}>
<header className="home__top">
<div className="home__brand-row">
<img src="/logo.png" alt="" className="home__mark" />
<span className="brand brand--sm">Vyryi</span>
</div>
<div className="balance-panel">
<span className="balance-panel__label">Баланс</span>
<strong className="balance-panel__value">{user?.balance ?? '—'}</strong>
<button type="button" className="btn btn--gold btn--sm" onClick={() => setDepositOpen(true)}>
Пополнить
</button>
</div>
</header>
{message && <p className="toast">{message}</p>}
<section className="games" aria-labelledby="games-title">
<h2 id="games-title">Слоты</h2>
<p className="section-lead">Врата ещё закрыты пока только предвестники.</p>
<ul className="games__list">
{games.map((game) => (
<li key={game.slug}>
<Link to={`/games/${game.slug}`} className="game-link">
<span className="game-link__title">{game.title}</span>
<span className="game-link__status">Скоро</span>
<span className="game-link__desc">{game.description}</span>
</Link>
</li>
))}
</ul>
</section>
{depositOpen && (
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="deposit-title">
<div className="modal__panel">
<h3 id="deposit-title">Пополнение</h3>
<p className="section-lead">Тестовая заглушка. Вывод недоступен.</p>
<div className="deposit-grid">
{DEPOSIT_AMOUNTS.map((amount) => (
<button
key={amount}
type="button"
className="btn btn--outline"
disabled={busy}
onClick={() => void deposit(amount)}
>
+{amount}
</button>
))}
</div>
<button type="button" className="btn btn--ghost" onClick={() => setDepositOpen(false)}>
Закрыть
</button>
</div>
</div>
)}
</main>
)
}

View File

@@ -0,0 +1,22 @@
import { Link } from 'react-router-dom'
type LegalDocumentPageProps = {
title: string
paragraphs: string[]
}
export function LegalDocumentPage({ title, paragraphs }: LegalDocumentPageProps) {
return (
<main className="legal-page">
<Link to="/onboarding" className="back-link">
Назад
</Link>
<article className="legal-doc">
<h1>{title}</h1>
{paragraphs.map((text, index) => (
<p key={index}>{text}</p>
))}
</article>
</main>
)
}

View File

@@ -0,0 +1,12 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../auth'
/** `/` → home if agreement done, otherwise onboarding. */
export function RootRedirect() {
const { user, loading } = useAuth()
if (loading) return null
if (user?.agreement_accepted) {
return <Navigate to="/home" replace />
}
return <Navigate to="/onboarding" replace />
}

33
src/types.ts Normal file
View File

@@ -0,0 +1,33 @@
export type User = {
id: number
telegram_id: number | null
username: string | null
first_name: string | null
balance: string
agreement_accepted: boolean
agreement_accepted_at: string | null
created_at: string
}
export type Deposit = {
id: number
amount: string
created_at: string
}
export type Wallet = {
balance: string
deposits: Deposit[]
}
export type GameStub = {
slug: string
title: string
description: string
status: string
}
export type TokenResponse = {
access_token: string
token_type: string
}

View File

@@ -0,0 +1,323 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import gsap from 'gsap'
import { GATE_FROM_KEY, prefersReducedMotion, type GateTransitPhase } from './gateCaps'
import {
createLogoFlyTimeline,
createReducedLogoFlyTimeline,
type LogoFlyTargets,
} from './gateTimeline'
type StartResult = {
ok: boolean
error?: string
}
type GateTransitContextValue = {
phase: GateTransitPhase
active: boolean
start: (runAccept: () => Promise<void>) => Promise<StartResult>
}
const GateTransitContext = createContext<GateTransitContextValue | null>(null)
export function useGateTransit() {
const ctx = useContext(GateTransitContext)
if (!ctx) {
throw new Error('useGateTransit must be used within GateTransitProvider')
}
return ctx
}
function collectUiFade(gate: HTMLElement, logo: HTMLElement | null): HTMLElement[] {
const nodes: HTMLElement[] = []
const glow = gate.querySelector<HTMLElement>('.gate__glow')
if (glow) nodes.push(glow)
const content = gate.querySelector<HTMLElement>('.gate__content')
if (content) {
for (const el of Array.from(content.children)) {
if (el instanceof HTMLElement && el !== logo) nodes.push(el)
}
}
return nodes
}
function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
const rect = logo.getBoundingClientRect()
// Geometry only — keep invisible until the original logo is hidden in the same frame
gsap.set(hero, {
position: 'fixed',
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
margin: 0,
zIndex: 2,
opacity: 0,
visibility: 'hidden',
scale: 1,
rotation: 0,
z: 0,
filter: 'drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6))',
objectFit: 'contain',
transformOrigin: '50% 46%',
transformPerspective: 1400,
force3D: true,
willChange: 'transform, filter, opacity',
})
}
function revealHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
gsap.set(logo, { opacity: 0 })
gsap.set(hero, { opacity: 1, visibility: 'visible' })
}
export function GateTransitProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate()
const [phase, setPhase] = useState<GateTransitPhase>('idle')
const [showOverlay, setShowOverlay] = useState(false)
const veilRef = useRef<HTMLDivElement | null>(null)
const stageRef = useRef<HTMLDivElement | null>(null)
const heroRef = useRef<HTMLImageElement | null>(null)
const timelineRef = useRef<gsap.core.Timeline | null>(null)
const acceptPromiseRef = useRef<Promise<void> | null>(null)
const acceptErrorRef = useRef<string | null>(null)
const acceptOkRef = useRef(false)
const navigatedRef = useRef(false)
const resolveStartRef = useRef<((result: StartResult) => void) | null>(null)
const reducedRef = useRef(false)
const cleanupDomLocks = useCallback(() => {
document.documentElement.classList.remove('gate-transit-lock')
const gate = document.querySelector<HTMLElement>('.gate')
const logo = document.querySelector<HTMLElement>('.gate__logo')
const glow = document.querySelector<HTMLElement>('.gate__glow')
if (logo) gsap.set(logo, { clearProps: 'opacity' })
if (glow) gsap.set(glow, { clearProps: 'opacity,filter,transform' })
if (gate) {
gate.querySelectorAll<HTMLElement>('.gate__content > *').forEach((el) => {
gsap.set(el, { clearProps: 'opacity,filter,transform' })
})
}
}, [])
const finish = useCallback(
(result: StartResult) => {
timelineRef.current?.kill()
timelineRef.current = null
setShowOverlay(false)
setPhase(result.ok ? 'done' : 'error')
cleanupDomLocks()
resolveStartRef.current?.(result)
resolveStartRef.current = null
window.setTimeout(() => {
setPhase('idle')
acceptPromiseRef.current = null
acceptErrorRef.current = null
acceptOkRef.current = false
navigatedRef.current = false
}, 80)
},
[cleanupDomLocks],
)
const handleBlackout = useCallback(async () => {
setPhase('blackout')
const waitAccept = async () => {
if (acceptOkRef.current) return true
if (acceptErrorRef.current) return false
if (!acceptPromiseRef.current) return false
try {
await Promise.race([
acceptPromiseRef.current,
new Promise<void>((_, reject) => {
window.setTimeout(() => reject(new Error('Слишком долго. Попробуйте ещё раз.')), 1500)
}),
])
return acceptOkRef.current
} catch (err) {
if (!acceptErrorRef.current) {
acceptErrorRef.current = err instanceof Error ? err.message : 'Ошибка'
}
return false
}
}
const ok = await waitAccept()
if (!ok) {
finish({ ok: false, error: acceptErrorRef.current ?? 'Ошибка' })
return
}
if (!navigatedRef.current) {
navigatedRef.current = true
try {
sessionStorage.setItem(GATE_FROM_KEY, '1')
} catch {
/* ignore */
}
navigate('/home', { replace: true, state: { fromGate: true } })
}
}, [finish, navigate])
const handleRevealStart = useCallback(() => {
setPhase('revealing')
}, [])
const handleComplete = useCallback(() => {
finish({ ok: true })
}, [finish])
const startTimeline = useCallback(() => {
const logo = document.querySelector<HTMLElement>('.gate__logo')
const gate = document.querySelector<HTMLElement>('.gate')
const hero = heroRef.current
if (!hero || !logo || !gate) {
finish({ ok: false, error: 'Не удалось запустить переход' })
return
}
placeHeroOverLogo(hero, logo)
// One frame with hero invisible + correct box, then atomic swap with the page logo
requestAnimationFrame(() => {
if (timelineRef.current) return
placeHeroOverLogo(hero, logo)
revealHeroOverLogo(hero, logo)
const targets: LogoFlyTargets = {
hero,
uiFade: collectUiFade(gate, logo),
originalLogo: logo,
veil: veilRef.current,
stage: stageRef.current,
}
const handlers = {
onBlackout: () => {
void handleBlackout()
},
onRevealStart: handleRevealStart,
onComplete: handleComplete,
}
timelineRef.current = reducedRef.current
? createReducedLogoFlyTimeline(targets, handlers)
: createLogoFlyTimeline(targets, handlers)
})
}, [finish, handleBlackout, handleComplete, handleRevealStart])
useEffect(() => {
if (phase !== 'playing' || !showOverlay) return
let cancelled = false
let tries = 0
const tick = () => {
if (cancelled || timelineRef.current) return
if (!heroRef.current || !document.querySelector('.gate__logo')) {
tries += 1
if (tries < 30) {
window.setTimeout(tick, 32)
return
}
finish({ ok: false, error: 'Не удалось запустить переход' })
return
}
startTimeline()
}
const id = window.setTimeout(tick, 0)
return () => {
cancelled = true
window.clearTimeout(id)
}
}, [phase, showOverlay, startTimeline, finish])
const start = useCallback(
(runAccept: () => Promise<void>) => {
if (phase !== 'idle' && phase !== 'done' && phase !== 'error') {
return Promise.resolve({ ok: false, error: 'Переход уже идёт' })
}
reducedRef.current = prefersReducedMotion()
setShowOverlay(true)
setPhase('playing')
document.documentElement.classList.add('gate-transit-lock')
acceptOkRef.current = false
acceptErrorRef.current = null
navigatedRef.current = false
timelineRef.current = null
const acceptPromise = runAccept()
.then(() => {
acceptOkRef.current = true
})
.catch((err: unknown) => {
acceptErrorRef.current = err instanceof Error ? err.message : 'Ошибка'
throw err
})
acceptPromiseRef.current = acceptPromise
void acceptPromise.catch(() => undefined)
return new Promise<StartResult>((resolve) => {
resolveStartRef.current = resolve
})
},
[phase],
)
useEffect(() => {
return () => {
timelineRef.current?.kill()
cleanupDomLocks()
}
}, [cleanupDomLocks])
const value = useMemo<GateTransitContextValue>(
() => ({
phase,
active: phase === 'playing' || phase === 'blackout' || phase === 'revealing',
start,
}),
[phase, start],
)
const overlayVisible =
showOverlay && (phase === 'playing' || phase === 'blackout' || phase === 'revealing')
return (
<GateTransitContext.Provider value={value}>
{children}
{overlayVisible &&
createPortal(
<div className="gate-transit" aria-hidden>
<div className="gate-transit__stage" ref={stageRef}>
<img
ref={heroRef}
className="gate-transit__hero"
src="/logo.png"
alt=""
draggable={false}
/>
</div>
<div className="gate-transit__veil" ref={veilRef} />
</div>,
document.body,
)}
</GateTransitContext.Provider>
)
}

14
src/vfx/gateCaps.ts Normal file
View File

@@ -0,0 +1,14 @@
export type GateTransitPhase =
| 'idle'
| 'playing'
| 'blackout'
| 'revealing'
| 'done'
| 'error'
export const GATE_FROM_KEY = 'vyryi:from-gate'
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined') return false
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}

130
src/vfx/gateTimeline.ts Normal file
View File

@@ -0,0 +1,130 @@
import gsap from 'gsap'
export type GateTimelineHandlers = {
onBlackout: () => void
onRevealStart: () => void
onComplete: () => void
}
export type LogoFlyTargets = {
hero: HTMLElement
uiFade: HTMLElement[]
originalLogo: HTMLElement | null
veil: HTMLElement | null
stage: HTMLElement | null
}
/** Continuously fly into logo.png (central black arch), then void → home. */
export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTimelineHandlers) {
const { hero, uiFade, veil, stage } = targets
gsap.set(hero, {
transformOrigin: '50% 46%',
transformPerspective: 1400,
force3D: true,
scale: 1,
z: 0,
rotation: 0,
filter: 'brightness(1) contrast(1) drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6))',
})
if (stage) {
gsap.set(stage, { perspective: 1400 })
}
if (veil) {
gsap.set(veil, { opacity: 0 })
}
const tl = gsap.timeline({
onComplete: () => handlers.onComplete(),
})
if (uiFade.length) {
tl.to(
uiFade,
{
opacity: 0,
y: 18,
filter: 'blur(10px)',
duration: 0.4,
stagger: 0.04,
ease: 'power2.out',
},
0,
)
}
// Dolly into the black arch — image stays on screen the whole time
tl.to(
hero,
{
scale: 26,
z: 280,
rotation: 0.4,
duration: 2.15,
ease: 'power3.in',
force3D: true,
},
0.05,
)
tl.to(
hero,
{
filter:
'brightness(0.35) contrast(1.25) drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6))',
duration: 1.1,
ease: 'power2.in',
},
1.1,
)
if (veil) {
tl.to(veil, { opacity: 1, duration: 0.55, ease: 'power2.in' }, 1.75)
}
tl.call(() => handlers.onBlackout(), undefined, 2.15)
tl.to({}, { duration: 0.2 }, 2.15)
tl.call(() => handlers.onRevealStart(), undefined, 2.35)
if (veil) {
tl.to(veil, { opacity: 0, duration: 0.85, ease: 'power2.out' }, 2.35)
}
tl.to(hero, { opacity: 0, duration: 0.35, ease: 'power1.in' }, 2.4)
return tl
}
export function createReducedLogoFlyTimeline(
targets: LogoFlyTargets,
handlers: GateTimelineHandlers,
) {
const { hero, uiFade, veil } = targets
gsap.set(hero, { transformOrigin: '50% 46%', scale: 1 })
if (veil) gsap.set(veil, { opacity: 0 })
const tl = gsap.timeline({ onComplete: () => handlers.onComplete() })
if (uiFade.length) {
tl.to(uiFade, { opacity: 0, duration: 0.3, ease: 'power1.out' }, 0)
}
tl.to(hero, { scale: 14, duration: 0.85, ease: 'power2.in' }, 0)
if (veil) {
tl.to(veil, { opacity: 1, duration: 0.4, ease: 'power2.in' }, 0.45)
}
tl.call(() => handlers.onBlackout(), undefined, 0.85)
tl.to({}, { duration: 0.15 }, 0.85)
tl.call(() => handlers.onRevealStart(), undefined, 1.0)
if (veil) {
tl.to(veil, { opacity: 0, duration: 0.55, ease: 'power2.out' }, 1.0)
}
tl.to(hero, { opacity: 0, duration: 0.25 }, 1.0)
return tl
}

26
tsconfig.app.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
tsconfig.node.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})