feat(s30): плашка ТЕСТОВЫЙ СТЕНД + robots noindex (шаг 6/9)

StageBanner — жёлтая полоса сверху всех страниц, когда /api/dev/
quick-login/accounts отвечает 200 (стенд). В проде эндпоинт 404 →
компонент возвращает null, никакая полоса не рисуется.

Решение «stage vs prod» кэшируется в sessionStorage, чтобы не стрелять
запрос на каждой навигации в SPA (один запрос за открытие вкладки).

На стенде динамически добавляется <meta name=robots content=noindex,
nofollow> — не хотим чтобы Google индексировал test.admin.food-market.kz.
Один web-билд ходит в оба окружения; никаких отдельных index.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 15:42:49 +05:00
parent 6582333def
commit 5728b729a8
3 changed files with 77 additions and 1 deletions

View file

@ -31,7 +31,7 @@
- [x] 5. **Страница входа (web)** — если `GET .../accounts` вернул 200 — над
обычной формой блок «Быстрый вход (тестовый стенд)» с карточками + поиск.
Клик — моментальный логин через мастер-пароль. На проде блока нет.
- [ ] 6. **Плашка «ТЕСТОВЫЙ СТЕНД»** — при Enabled=true жёлтая полоса
- [x] 6. **Плашка «ТЕСТОВЫЙ СТЕНД»** — при Enabled=true жёлтая полоса
сверху + `<meta robots="noindex,nofollow">` в index.html стенда.
- [ ] 7. **Чистка автотестового мусора** — SQL-скрипт для стейджа + расширение
`HousekeepingJobs.PruneQualityTestOrgsAsync` на новые autotest-патéрны,

View file

@ -30,6 +30,7 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
import { RoleGuard } from '@/components/RoleGuard'
import { AdvancedModeGuard } from '@/components/AdvancedModeGuard'
import { Toaster } from '@/components/Toaster'
import { StageBanner } from '@/components/StageBanner'
import { toast } from '@/lib/toast'
import { FormSkeleton } from '@/components/Skeleton'
@ -117,6 +118,7 @@ export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Toaster />
<StageBanner />
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />

View file

@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
/**
* Жёлтая полоса сверху «ТЕСТОВЫЙ СТЕНД — не вводите реальные данные».
* Рендерится ТОЛЬКО когда /api/dev/quick-login/accounts отвечает 200
* (стенд). В проде эндпоинт 404 → компонент возвращает null, пустое
* место сверху не занимается.
*
* Решение «показывать / скрывать» делается один раз при первом монтировании
* (на уровне App.tsx) и кешируется в sessionStorage, чтобы не делать запрос
* на каждой навигации. Ключ очищается при закрытии вкладки.
*/
const CACHE_KEY = 'fm.stage-flag' // "stage" | "prod"
export function StageBanner() {
const [state, setState] = useState<'stage' | 'prod' | 'loading'>(() => {
const cached = typeof window !== 'undefined' ? sessionStorage.getItem(CACHE_KEY) : null
if (cached === 'stage') return 'stage'
if (cached === 'prod') return 'prod'
return 'loading'
})
useEffect(() => {
if (state !== 'loading') return
let cancelled = false
api
.get('/api/dev/quick-login/accounts')
.then(() => {
if (cancelled) return
setState('stage')
sessionStorage.setItem(CACHE_KEY, 'stage')
})
.catch(() => {
if (cancelled) return
setState('prod')
sessionStorage.setItem(CACHE_KEY, 'prod')
})
return () => { cancelled = true }
}, [state])
// На стенде добавляем <meta name="robots" content="noindex,nofollow"> —
// не хотим чтобы Google индексировал test.admin.food-market.kz. Инъектим
// динамически (а не в index.html), чтобы один билд ходил и в прод (там
// стенда нет и meta не добавляется).
useEffect(() => {
if (state !== 'stage') return
const existing = document.querySelector<HTMLMetaElement>('meta[name="robots"]')
if (existing) return // уже есть — не ломаем
const meta = document.createElement('meta')
meta.name = 'robots'
meta.content = 'noindex,nofollow'
meta.setAttribute('data-origin', 'stage-banner')
document.head.appendChild(meta)
return () => {
// при переходе prod ↔ stage в рамках одной SPA-сессии не нужно
// держать meta — но на самом деле это не произойдёт (мы кешируем
// решение). На всякий случай очищаем, если флаг улетел в prod.
if (meta.parentNode) meta.parentNode.removeChild(meta)
}
}, [state])
if (state !== 'stage') return null
return (
<div
role="alert"
data-testid="stage-banner"
className="w-full bg-amber-400 text-amber-950 font-semibold text-xs sm:text-sm text-center py-1 shadow-sm"
>
⚠️ ТЕСТОВЫЙ СТЕНД · не вводите реальные пароли и ОФД-токены
</div>
)
}