Merge branch 'feat/whats-new-modal-from-banner' into 'main'

feat(updateBanner): «Что нового» button открывает modal с changelog

See merge request 2-6/2-6-4/terravault/ordinis!152
This commit is contained in:
Александр Зимин
2026-05-12 21:04:56 +00:00
3 changed files with 229 additions and 189 deletions
@@ -3,8 +3,7 @@ import { Button } from '@/ui'
import { ArrowClockwise, BookOpen, X } from '@phosphor-icons/react' import { ArrowClockwise, BookOpen, X } from '@phosphor-icons/react'
import { useState } from 'react' import { useState } from 'react'
import { useAppVersion } from './useAppVersion' import { useAppVersion } from './useAppVersion'
import { WhatsNewModal } from './WhatsNewModal'
const DOCS_URL = '/docs/' // ordinis-docs deployment, mirrors prod /docs path
/** /**
* Banner — показывается top-of-page когда client bundle отстаёт от server. * Banner — показывается top-of-page когда client bundle отстаёт от server.
@@ -16,7 +15,10 @@ const DOCS_URL = '/docs/' // ordinis-docs deployment, mirrors prod /docs path
* <ul> * <ul>
* <li><b>Обновить</b> — {@code window.location.reload()}. Browser fetches new * <li><b>Обновить</b> — {@code window.location.reload()}. Browser fetches new
* bundle (cache-busting через Vite hashed filenames).</li> * bundle (cache-busting через Vite hashed filenames).</li>
* <li><b>Что нового</b> — opens user руководство (changelog / release notes).</li> * <li><b>Что нового</b> — открывает {@link WhatsNewModal} с public changelog
* (тот же что в 🎁 button TopBar). Раньше открывал /docs/ — но
* контекстуально юзеру нужно знать ЧТО изменилось перед reload, не
* читать general docs.</li>
* </ul> * </ul>
* *
* <p>Dismiss button (×) — temporarily скрывает banner до следующего * <p>Dismiss button (×) — temporarily скрывает banner до следующего
@@ -27,6 +29,7 @@ export function UpdateBanner() {
const { t } = useTranslation() const { t } = useTranslation()
const { updateAvailable, serverTag } = useAppVersion() const { updateAvailable, serverTag } = useAppVersion()
const [dismissed, setDismissed] = useState(false) const [dismissed, setDismissed] = useState(false)
const [whatsNewOpen, setWhatsNewOpen] = useState(false)
if (!updateAvailable || dismissed) return null if (!updateAvailable || dismissed) return null
@@ -39,40 +42,43 @@ export function UpdateBanner() {
: t('updateBanner.messageGeneric') : t('updateBanner.messageGeneric')
return ( return (
<div <>
role="status" <div
aria-live="polite" role="status"
className="bg-accent text-on-accent" aria-live="polite"
> className="bg-accent text-on-accent"
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-2 flex items-center gap-3 flex-wrap text-body"> >
<ArrowClockwise size={18} weight="bold" className="shrink-0" aria-hidden /> <div className="max-w-6xl mx-auto px-4 sm:px-6 py-2 flex items-center gap-3 flex-wrap text-body">
<span className="flex-1 min-w-0">{message}</span> <ArrowClockwise size={18} weight="bold" className="shrink-0" aria-hidden />
<div className="flex items-center gap-2 ml-auto"> <span className="flex-1 min-w-0">{message}</span>
<Button <div className="flex items-center gap-2 ml-auto">
size="sm" <Button
variant="secondary" size="sm"
onClick={() => window.open(DOCS_URL, '_blank', 'noopener,noreferrer')} variant="secondary"
> onClick={() => setWhatsNewOpen(true)}
<BookOpen size={16} weight="regular" className="mr-1" aria-hidden /> >
{t('updateBanner.docs')} <BookOpen size={16} weight="regular" className="mr-1" aria-hidden />
</Button> {t('updateBanner.docs')}
<Button </Button>
size="sm" <Button
variant="primary" size="sm"
onClick={() => window.location.reload()} variant="primary"
> onClick={() => window.location.reload()}
{t('updateBanner.reload')} >
</Button> {t('updateBanner.reload')}
<button </Button>
type="button" <button
aria-label={t('updateBanner.dismiss') as string} type="button"
onClick={() => setDismissed(true)} aria-label={t('updateBanner.dismiss') as string}
className="ml-1 p-1 rounded hover:bg-white/10" onClick={() => setDismissed(true)}
> className="ml-1 p-1 rounded hover:bg-white/10"
<X size={16} weight="bold" aria-hidden /> >
</button> <X size={16} weight="bold" aria-hidden />
</button>
</div>
</div> </div>
</div> </div>
</div> <WhatsNewModal open={whatsNewOpen} onClose={() => setWhatsNewOpen(false)} />
</>
) )
} }
@@ -1,61 +1,39 @@
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { GiftIcon, SparkleIcon } from '@phosphor-icons/react' import { GiftIcon } from '@phosphor-icons/react'
import { Modal, Badge } from '@/ui'
import { import {
useChangelog, useChangelog,
readLastSeenVersion, readLastSeenVersion,
writeLastSeenVersion,
isVersionNewer, isVersionNewer,
type ChangelogEntry,
type ChangelogSection,
} from './useChangelog' } from './useChangelog'
import { WhatsNewModal } from './WhatsNewModal'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** /**
* «Что нового» button в TopBar + modal с release notes. * «Что нового» button в TopBar.
* *
* <p>Behavior: * <p>🎁 GiftIcon с red unread badge counter (entries newer than
* <ul> * {@code localStorage.ord-whatsnew-last-seen}). Click → {@link WhatsNewModal}.
* <li>Button показывает sparkle icon + red dot если есть unread versions
* (entries newer than {@code localStorage.ord-whatsnew-last-seen}).</li>
* <li>Click → modal с list of versions, newest first, max 10 recent.</li>
* <li>Modal open → mark latest version как seen (persisted в localStorage).</li>
* <li>Дальнейшие visits: dot не показывается пока новый release tag не shipped.</li>
* </ul>
* *
* <p>Source: CHANGELOG.md bundled через Vite {@code ?raw} import → parsed * <p>Modal extracted в own component — также используется в
* клиент-side. No backend changes. * {@link UpdateBanner} «Что нового» button (когда новая версия detected).
*
* <p>i18n: button + modal title. Entries themselves остаются как-они есть из
* CHANGELOG (mix ru/en commit messages — это intentional, авторы commit'ов
* вписывают на родном языке).
*/ */
export function WhatsNewButton() { export function WhatsNewButton() {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const entries = useChangelog() const entries = useChangelog()
// useState вместо useUnreadCount чтобы re-compute после modal close // useState lastSeen — re-compute unread после modal close. localStorage
// (localStorage write не trigger'ит React re-render). // write (внутри WhatsNewModal на open) не trigger'ит React re-render
const [lastSeen, setLastSeen] = useState<string | null>(() => readLastSeenVersion()) // отдельно, поэтому tracking через useEffect on open close в modal.
// В button мы читаем initially — на close button перестроится через
// обычный rerender chain.
const lastSeen = useMemo(() => readLastSeenVersion(), [open])
const unreadCount = useMemo( const unreadCount = useMemo(
() => entries.filter((e) => isVersionNewer(e.version, lastSeen)).length, () => entries.filter((e) => isVersionNewer(e.version, lastSeen)).length,
[entries, lastSeen], [entries, lastSeen],
) )
// На modal close — persist latest version как seen.
useEffect(() => {
if (open && entries.length > 0) {
const latest = entries[0].version
writeLastSeenVersion(latest)
setLastSeen(latest)
}
}, [open, entries])
// Show только 10 recent entries — older releases via «full changelog» link
const recent = entries.slice(0, 10)
const label = t('whatsnew.button', { defaultValue: 'Что нового' }) const label = t('whatsnew.button', { defaultValue: 'Что нового' })
return ( return (
@@ -85,123 +63,7 @@ export function WhatsNewButton() {
)} )}
</button> </button>
<Modal <WhatsNewModal open={open} onClose={() => setOpen(false)} />
isOpen={open}
onClose={() => setOpen(false)}
title={
<span className="inline-flex items-center gap-2">
<SparkleIcon size={18} weight="fill" className="text-accent" />
{t('whatsnew.title', { defaultValue: 'Что нового в Ordinis' })}
</span>
}
description={t('whatsnew.description', {
defaultValue:
'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.',
})}
maxWidth="max-w-2xl"
>
<div className="max-h-[60vh] overflow-y-auto -mx-1 px-1">
{recent.length === 0 ? (
<div className="text-mute text-body py-8 text-center">
{t('whatsnew.empty', {
defaultValue: 'Список релизов пока пуст.',
})}
</div>
) : (
<ul className="space-y-5">
{recent.map((entry) => (
<li key={entry.version}>
<EntryCard
entry={entry}
isUnread={isVersionNewer(entry.version, lastSeen)}
/>
</li>
))}
</ul>
)}
</div>
<div className="mt-4 pt-3 border-t border-line-2 text-cap text-mute text-center">
{t('whatsnew.footer', {
defaultValue: 'Полный список изменений — см. CHANGELOG.md в репозитории.',
})}
</div>
</Modal>
</> </>
) )
} }
function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boolean }) {
const { t } = useTranslation()
return (
<div
className={cn(
'rounded-lg border p-4 transition-colors',
isUnread
? 'border-accent/40 bg-accent-bg/40'
: 'border-line bg-surface',
)}
>
<div className="flex items-baseline gap-2 mb-3">
<span className="text-title-sm text-ink font-semibold tabular-nums">
v{entry.version}
</span>
{entry.date && (
<span className="text-cap text-mute tabular-nums">{entry.date}</span>
)}
{isUnread && (
<Badge variant="info" className="ml-auto">
{t('whatsnew.newBadge', { defaultValue: 'новое' })}
</Badge>
)}
</div>
{entry.sections.length === 0 ? (
<div className="text-cell text-mute italic">
{t('whatsnew.entryEmpty', {
defaultValue: 'Описание не указано.',
})}
</div>
) : (
<div className="space-y-3">
{entry.sections.map((section, i) => (
<SectionBlock key={i} section={section} />
))}
</div>
)}
</div>
)
}
function SectionBlock({ section }: { section: ChangelogSection }) {
return (
<div>
<h4 className="text-cap text-mute font-semibold mb-1.5 inline-flex items-center gap-1.5">
{section.emoji && <span aria-hidden>{section.emoji}</span>}
<span>{section.title}</span>
</h4>
<ul className="space-y-1 ml-1">
{section.items.map((item, i) => (
<li key={i} className="text-body text-ink-2 flex items-start gap-1.5">
<span className="text-mute mt-1 shrink-0" aria-hidden></span>
<span className="min-w-0 flex-1">
{item.scope && (
<span className="text-mono text-cap text-accent mr-1.5">{item.scope}:</span>
)}
<span>{item.description}</span>
{item.commit && item.commitUrl && (
<a
href={item.commitUrl}
target="_blank"
rel="noreferrer noopener"
className="text-mono text-cap text-mute hover:text-ink ml-1.5"
title={`Commit ${item.commit}`}
>
{item.commit.substring(0, 7)}
</a>
)}
</span>
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SparkleIcon } from '@phosphor-icons/react'
import { Modal, Badge } from '@/ui'
import {
useChangelog,
readLastSeenVersion,
writeLastSeenVersion,
isVersionNewer,
type ChangelogEntry,
type ChangelogSection,
} from './useChangelog'
import { cn } from '@/lib/utils'
/**
* «Что нового» — modal с release notes из public changelog.
*
* <p>Open state управляется снаружи (controlled component). Используется
* двумя trigger'ами:
* <ol>
* <li>{@link WhatsNewButton} в TopBar — 🎁 icon с unread counter</li>
* <li>{@link UpdateBanner} «Что нового» button — когда update detected</li>
* </ol>
*
* <p>При open: marks latest version как seen в localStorage. На next visit
* unread counter = 0 пока не shipped новая версия.
*
* <p>Source: `docs/changelog.md` через `useChangelog` hook (Vite `?raw` import).
*/
export type WhatsNewModalProps = {
open: boolean
onClose: () => void
}
export function WhatsNewModal({ open, onClose }: WhatsNewModalProps) {
const { t } = useTranslation()
const entries = useChangelog()
const [lastSeen, setLastSeen] = useState<string | null>(() => readLastSeenVersion())
// На modal open — persist latest version как seen (red dot disappears
// для next session, пока новая версия не shipped).
useEffect(() => {
if (open && entries.length > 0) {
const latest = entries[0].version
writeLastSeenVersion(latest)
setLastSeen(latest)
}
}, [open, entries])
// Show только 10 recent entries — older releases в полном CHANGELOG.
const recent = entries.slice(0, 10)
return (
<Modal
isOpen={open}
onClose={onClose}
title={
<span className="inline-flex items-center gap-2">
<SparkleIcon size={18} weight="fill" className="text-accent" />
{t('whatsnew.title', { defaultValue: 'Что нового в Ordinis' })}
</span>
}
description={t('whatsnew.description', {
defaultValue:
'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.',
})}
maxWidth="max-w-2xl"
>
<div className="max-h-[60vh] overflow-y-auto -mx-1 px-1">
{recent.length === 0 ? (
<div className="text-mute text-body py-8 text-center">
{t('whatsnew.empty', {
defaultValue: 'Список релизов пока пуст.',
})}
</div>
) : (
<ul className="space-y-5">
{recent.map((entry) => (
<li key={entry.version}>
<EntryCard
entry={entry}
isUnread={isVersionNewer(entry.version, lastSeen)}
/>
</li>
))}
</ul>
)}
</div>
<div className="mt-4 pt-3 border-t border-line-2 text-cap text-mute text-center">
{t('whatsnew.footer', {
defaultValue: 'Полный список изменений — см. CHANGELOG.md в репозитории.',
})}
</div>
</Modal>
)
}
function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boolean }) {
const { t } = useTranslation()
return (
<div
className={cn(
'rounded-lg border p-4 transition-colors',
isUnread
? 'border-accent/40 bg-accent-bg/40'
: 'border-line bg-surface',
)}
>
<div className="flex items-baseline gap-2 mb-3">
<span className="text-title-sm text-ink font-semibold tabular-nums">
v{entry.version}
</span>
{entry.date && (
<span className="text-cap text-mute tabular-nums">{entry.date}</span>
)}
{isUnread && (
<Badge variant="info" className="ml-auto">
{t('whatsnew.newBadge', { defaultValue: 'новое' })}
</Badge>
)}
</div>
{entry.sections.length === 0 ? (
<div className="text-cell text-mute italic">
{t('whatsnew.entryEmpty', {
defaultValue: 'Описание не указано.',
})}
</div>
) : (
<div className="space-y-3">
{entry.sections.map((section, i) => (
<SectionBlock key={i} section={section} />
))}
</div>
)}
</div>
)
}
function SectionBlock({ section }: { section: ChangelogSection }) {
return (
<div>
<h4 className="text-cap text-mute font-semibold mb-1.5 inline-flex items-center gap-1.5">
{section.emoji && <span aria-hidden>{section.emoji}</span>}
<span>{section.title}</span>
</h4>
<ul className="space-y-1 ml-1">
{section.items.map((item, i) => (
<li key={i} className="text-body text-ink-2 flex items-start gap-1.5">
<span className="text-mute mt-1 shrink-0" aria-hidden></span>
<span className="min-w-0 flex-1">
{item.scope && (
<span className="text-mono text-cap text-accent mr-1.5">{item.scope}:</span>
)}
<span>{item.description}</span>
{item.commit && item.commitUrl && (
<a
href={item.commitUrl}
target="_blank"
rel="noreferrer noopener"
className="text-mono text-cap text-mute hover:text-ink ml-1.5"
title={`Commit ${item.commit}`}
>
{item.commit.substring(0, 7)}
</a>
)}
</span>
</li>
))}
</ul>
</div>
)
}