From e46598c47d01479fae4d6d84a704792ba06a30b4 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Tue, 12 May 2026 23:28:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(updateBanner):=20=C2=AB=D0=A7=D1=82=D0=BE?= =?UTF-8?q?=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=C2=BB=20button=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20modal=20?= =?UTF-8?q?=D1=81=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``. 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) --- .../src/components/version/UpdateBanner.tsx | 78 ++++---- .../src/components/version/WhatsNewButton.tsx | 168 ++--------------- .../src/components/version/WhatsNewModal.tsx | 172 ++++++++++++++++++ 3 files changed, 229 insertions(+), 189 deletions(-) create mode 100644 ordinis-admin-ui/src/components/version/WhatsNewModal.tsx diff --git a/ordinis-admin-ui/src/components/version/UpdateBanner.tsx b/ordinis-admin-ui/src/components/version/UpdateBanner.tsx index e45ee0a..51771ce 100644 --- a/ordinis-admin-ui/src/components/version/UpdateBanner.tsx +++ b/ordinis-admin-ui/src/components/version/UpdateBanner.tsx @@ -3,8 +3,7 @@ import { Button } from '@/ui' import { ArrowClockwise, BookOpen, X } from '@phosphor-icons/react' import { useState } from 'react' import { useAppVersion } from './useAppVersion' - -const DOCS_URL = '/docs/' // ordinis-docs deployment, mirrors prod /docs path +import { WhatsNewModal } from './WhatsNewModal' /** * Banner — показывается top-of-page когда client bundle отстаёт от server. @@ -16,7 +15,10 @@ const DOCS_URL = '/docs/' // ordinis-docs deployment, mirrors prod /docs path * * *

Dismiss button (×) — temporarily скрывает banner до следующего @@ -27,6 +29,7 @@ export function UpdateBanner() { const { t } = useTranslation() const { updateAvailable, serverTag } = useAppVersion() const [dismissed, setDismissed] = useState(false) + const [whatsNewOpen, setWhatsNewOpen] = useState(false) if (!updateAvailable || dismissed) return null @@ -39,40 +42,43 @@ export function UpdateBanner() { : t('updateBanner.messageGeneric') return ( -

-
- - {message} -
- - - + <> +
+
+ + {message} +
+ + + +
-
+ setWhatsNewOpen(false)} /> + ) } diff --git a/ordinis-admin-ui/src/components/version/WhatsNewButton.tsx b/ordinis-admin-ui/src/components/version/WhatsNewButton.tsx index 2b28233..379c7f0 100644 --- a/ordinis-admin-ui/src/components/version/WhatsNewButton.tsx +++ b/ordinis-admin-ui/src/components/version/WhatsNewButton.tsx @@ -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. * - *

Behavior: - *

    - *
  • Button показывает sparkle icon + red dot если есть unread versions - * (entries newer than {@code localStorage.ord-whatsnew-last-seen}).
  • - *
  • Click → modal с list of versions, newest first, max 10 recent.
  • - *
  • Modal open → mark latest version как seen (persisted в localStorage).
  • - *
  • Дальнейшие visits: dot не показывается пока новый release tag не shipped.
  • - *
+ *

🎁 GiftIcon с red unread badge counter (entries newer than + * {@code localStorage.ord-whatsnew-last-seen}). Click → {@link WhatsNewModal}. * - *

Source: CHANGELOG.md bundled через Vite {@code ?raw} import → parsed - * клиент-side. No backend changes. - * - *

i18n: button + modal title. Entries themselves остаются как-они есть из - * CHANGELOG (mix ru/en commit messages — это intentional, авторы commit'ов - * вписывают на родном языке). + *

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(() => 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() { )} - setOpen(false)} - title={ - - - {t('whatsnew.title', { defaultValue: 'Что нового в Ordinis' })} - - } - description={t('whatsnew.description', { - defaultValue: - 'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.', - })} - maxWidth="max-w-2xl" - > -

- {recent.length === 0 ? ( -
- {t('whatsnew.empty', { - defaultValue: 'Список релизов пока пуст.', - })} -
- ) : ( -
    - {recent.map((entry) => ( -
  • - -
  • - ))} -
- )} -
-
- {t('whatsnew.footer', { - defaultValue: 'Полный список изменений — см. CHANGELOG.md в репозитории.', - })} -
- + setOpen(false)} /> ) } - -function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boolean }) { - const { t } = useTranslation() - return ( -
-
- - v{entry.version} - - {entry.date && ( - {entry.date} - )} - {isUnread && ( - - {t('whatsnew.newBadge', { defaultValue: 'новое' })} - - )} -
- {entry.sections.length === 0 ? ( -
- {t('whatsnew.entryEmpty', { - defaultValue: 'Описание не указано.', - })} -
- ) : ( -
- {entry.sections.map((section, i) => ( - - ))} -
- )} -
- ) -} - -function SectionBlock({ section }: { section: ChangelogSection }) { - return ( -
-

- {section.emoji && {section.emoji}} - {section.title} -

-
    - {section.items.map((item, i) => ( -
  • - - - {item.scope && ( - {item.scope}: - )} - {item.description} - {item.commit && item.commitUrl && ( - - {item.commit.substring(0, 7)} - - )} - -
  • - ))} -
-
- ) -} diff --git a/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx b/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx new file mode 100644 index 0000000..134e823 --- /dev/null +++ b/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx @@ -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. + * + *

Open state управляется снаружи (controlled component). Используется + * двумя trigger'ами: + *

    + *
  1. {@link WhatsNewButton} в TopBar — 🎁 icon с unread counter
  2. + *
  3. {@link UpdateBanner} «Что нового» button — когда update detected
  4. + *
+ * + *

При open: marks latest version как seen в localStorage. На next visit + * unread counter = 0 пока не shipped новая версия. + * + *

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(() => 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 ( + + + {t('whatsnew.title', { defaultValue: 'Что нового в Ordinis' })} + + } + description={t('whatsnew.description', { + defaultValue: + 'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.', + })} + maxWidth="max-w-2xl" + > +

+ {recent.length === 0 ? ( +
+ {t('whatsnew.empty', { + defaultValue: 'Список релизов пока пуст.', + })} +
+ ) : ( +
    + {recent.map((entry) => ( +
  • + +
  • + ))} +
+ )} +
+
+ {t('whatsnew.footer', { + defaultValue: 'Полный список изменений — см. CHANGELOG.md в репозитории.', + })} +
+ + ) +} + +function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boolean }) { + const { t } = useTranslation() + return ( +
+
+ + v{entry.version} + + {entry.date && ( + {entry.date} + )} + {isUnread && ( + + {t('whatsnew.newBadge', { defaultValue: 'новое' })} + + )} +
+ {entry.sections.length === 0 ? ( +
+ {t('whatsnew.entryEmpty', { + defaultValue: 'Описание не указано.', + })} +
+ ) : ( +
+ {entry.sections.map((section, i) => ( + + ))} +
+ )} +
+ ) +} + +function SectionBlock({ section }: { section: ChangelogSection }) { + return ( +
+

+ {section.emoji && {section.emoji}} + {section.title} +

+
    + {section.items.map((item, i) => ( +
  • + + + {item.scope && ( + {item.scope}: + )} + {item.description} + {item.commit && item.commitUrl && ( + + {item.commit.substring(0, 7)} + + )} + +
  • + ))} +
+
+ ) +}