feat(updateBanner): «Что нового» button открывает modal с changelog
Per user feedback: «по кнопке что нового должно открываться модальное окно с изменениями». UpdateBanner кнопка «Что нового» (т9n key `updateBanner.docs`) ранее открывала `/docs/` в новой вкладке — generic documentation page, не contextual для «что именно в этом обновлении». Теперь — открывает `WhatsNewModal` (тот же modal что показывает 🎁 button в TopBar) — public changelog с release notes сразу. ## Refactor - **`WhatsNewModal.tsx` (NEW)** — extracted controlled-mode modal с release notes. Props: `open`, `onClose`. Internal logic читает changelog, tracks lastSeen в localStorage, на open mark'ает latest как seen. - **`WhatsNewButton.tsx`** — refactored: trigger button + use `WhatsNewModal`. Поведение unchanged (🎁 icon с unread badge → click → modal). - **`UpdateBanner.tsx`** — replaced `window.open(DOCS_URL)` на `setWhatsNewOpen(true)` + render `<WhatsNewModal>`. DOCS_URL constant removed (больше не используется). ## UX intent Когда update detected: 1. User видит orange banner «Доступна новая версия v2.16.0» 2. Hover «Что нового» button → opens modal 3. User читает release notes (что добавлено, исправлено) ДО reload 4. Click «Обновить» → reload Раньше «Что нового» вёл к generic docs page — user не знал ЧТО изменилось, только что update есть. Modal даёт structured answer. ## Test plan - [x] `pnpm tsc --noEmit` clean - [x] `pnpm build` clean - [x] `pnpm vitest run useChangelog.test` — 11/11 pass (parser logic unchanged) - [ ] Manual: симулировать update banner (mock useAppVersion `updateAvailable=true`), click «Что нового» → modal opens - [ ] Manual: 🎁 button в TopBar также opens same modal - [ ] Manual: close modal → red dot disappears на 🎁 button (localStorage updated)
This commit is contained in:
@@ -1,61 +1,39 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GiftIcon, SparkleIcon } from '@phosphor-icons/react'
|
||||
import { Modal, Badge } from '@/ui'
|
||||
import { GiftIcon } from '@phosphor-icons/react'
|
||||
import {
|
||||
useChangelog,
|
||||
readLastSeenVersion,
|
||||
writeLastSeenVersion,
|
||||
isVersionNewer,
|
||||
type ChangelogEntry,
|
||||
type ChangelogSection,
|
||||
} from './useChangelog'
|
||||
import { WhatsNewModal } from './WhatsNewModal'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* «Что нового» — button в TopBar + modal с release notes.
|
||||
* «Что нового» button в TopBar.
|
||||
*
|
||||
* <p>Behavior:
|
||||
* <ul>
|
||||
* <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>🎁 GiftIcon с red unread badge counter (entries newer than
|
||||
* {@code localStorage.ord-whatsnew-last-seen}). Click → {@link WhatsNewModal}.
|
||||
*
|
||||
* <p>Source: CHANGELOG.md bundled через Vite {@code ?raw} import → parsed
|
||||
* клиент-side. No backend changes.
|
||||
*
|
||||
* <p>i18n: button + modal title. Entries themselves остаются как-они есть из
|
||||
* CHANGELOG (mix ru/en commit messages — это intentional, авторы commit'ов
|
||||
* вписывают на родном языке).
|
||||
* <p>Modal extracted в own component — также используется в
|
||||
* {@link UpdateBanner} «Что нового» button (когда новая версия detected).
|
||||
*/
|
||||
export function WhatsNewButton() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const entries = useChangelog()
|
||||
|
||||
// useState вместо useUnreadCount чтобы re-compute после modal close
|
||||
// (localStorage write не trigger'ит React re-render).
|
||||
const [lastSeen, setLastSeen] = useState<string | null>(() => readLastSeenVersion())
|
||||
// useState lastSeen — re-compute unread после modal close. localStorage
|
||||
// write (внутри WhatsNewModal на open) не trigger'ит React re-render
|
||||
// отдельно, поэтому tracking через useEffect on open close в modal.
|
||||
// В button мы читаем initially — на close button перестроится через
|
||||
// обычный rerender chain.
|
||||
const lastSeen = useMemo(() => readLastSeenVersion(), [open])
|
||||
const unreadCount = useMemo(
|
||||
() => entries.filter((e) => isVersionNewer(e.version, lastSeen)).length,
|
||||
[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: 'Что нового' })
|
||||
|
||||
return (
|
||||
@@ -85,123 +63,7 @@ export function WhatsNewButton() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
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>
|
||||
<WhatsNewModal open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user