mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
Replace the 3.0 upgrade notices with a thank-you / support modal
The 3.0 "what's new" notices have served their purpose, so swap them for a single thank-you notice that comes back once on every fresh install and version bump. It carries Buy Me a Coffee and Ko-fi buttons and only shows on desktop. Adds a per-version recurring mode (new dismissed_app_version column) plus external-link CTAs to support it; the 3.0.14 whitespace-collision admin notice stays active.
This commit is contained in:
@@ -3062,6 +3062,15 @@ function runMigrations(db: Database.Database): void {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
// Remember the app version a notice was dismissed at, so per-version recurring
|
||||
// notices (e.g. the thank-you) re-appear on the next install/upgrade.
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE user_notice_dismissals ADD COLUMN dismissed_app_version TEXT');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -11,128 +11,65 @@ registerPredicate('whitespace-collision-detected', () => {
|
||||
* SYSTEM NOTICE REGISTRY
|
||||
*
|
||||
* Rules for authoring:
|
||||
* - NEVER remove or renumber entries — dismissal tracking is keyed by `id`.
|
||||
* - NEVER reuse a retired `id` — dismissal tracking is keyed by `id`. Retired ids are
|
||||
* listed in RETIRED_NOTICE_IDS so they're never accidentally re-used.
|
||||
* - `id` must be globally unique and stable across deployments.
|
||||
* - Title: ≤40 chars, sentence case, no trailing punctuation.
|
||||
* - Body: markdown (modal) or plain text (banner/toast). ≤400/140/80 chars.
|
||||
* - CTA label: ≤20 chars, a verb.
|
||||
* - CTA label: ≤20 chars.
|
||||
* - Never hardcode version numbers/dates in translated strings — use bodyParams.
|
||||
* - See plans/system-notices/00-overview.md for full authoring guidelines.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retired notices. Kept out of the active list but their ids stay reserved so a future
|
||||
* notice never reuses one (dismissals are keyed by id). Do not re-add these ids.
|
||||
*/
|
||||
export const RETIRED_NOTICE_IDS = [
|
||||
'v3-thankyou',
|
||||
'v3-photos',
|
||||
'v3-journey',
|
||||
'v3-mcp',
|
||||
'v3-features',
|
||||
'welcome-v1',
|
||||
] as const;
|
||||
|
||||
export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
// ── 3.0.0 upgrade notices (shown as a multipage modal to pre-3.0 users) ─────
|
||||
|
||||
// ── Thank-you + support the project — shown once per install AND once per upgrade ──
|
||||
// `recurring: 'per-version'` re-surfaces it whenever the app version moves up.
|
||||
{
|
||||
// Page 1 — breaking change first (warn → sorts before the two info notices)
|
||||
id: 'v3-photos',
|
||||
display: 'modal',
|
||||
severity: 'warn',
|
||||
icon: 'ImageOff',
|
||||
titleKey: 'system_notice.v3_photos.title',
|
||||
bodyKey: 'system_notice.v3_photos.body',
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 90,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
// Page 2 — flagship feature (only when Journey addon is enabled)
|
||||
id: 'v3-journey',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'BookOpen',
|
||||
titleKey: 'system_notice.v3_journey.title',
|
||||
bodyKey: 'system_notice.v3_journey.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_timeline', iconName: 'CalendarDays' },
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_photos', iconName: 'Images' },
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_share', iconName: 'Globe' },
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_export', iconName: 'FileText' },
|
||||
],
|
||||
cta: {
|
||||
kind: 'nav',
|
||||
labelKey: 'system_notice.v3_journey.cta_label',
|
||||
href: '/journey',
|
||||
},
|
||||
dismissible: true,
|
||||
conditions: [
|
||||
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
|
||||
{ kind: 'addonEnabled', addonId: 'journey' },
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 80,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
// Page 3 — MCP OAuth 2.1 upgrade (only when MCP addon is enabled)
|
||||
id: 'v3-mcp',
|
||||
display: 'modal',
|
||||
severity: 'warn',
|
||||
icon: 'Bot',
|
||||
titleKey: 'system_notice.v3_mcp.title',
|
||||
bodyKey: 'system_notice.v3_mcp.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_oauth', iconName: 'KeyRound' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_scopes', iconName: 'ShieldCheck' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_deprecated', iconName: 'AlertTriangle' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_tools', iconName: 'Wrench' },
|
||||
],
|
||||
dismissible: true,
|
||||
conditions: [
|
||||
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
|
||||
{ kind: 'addonEnabled', addonId: 'mcp' },
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 75,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
// Page 4 — other highlights
|
||||
id: 'v3-features',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Sparkles',
|
||||
titleKey: 'system_notice.v3_features.title',
|
||||
bodyKey: 'system_notice.v3_features.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_features.highlight_dashboard', iconName: 'LayoutDashboard' },
|
||||
{ labelKey: 'system_notice.v3_features.highlight_offline', iconName: 'WifiOff' },
|
||||
{ labelKey: 'system_notice.v3_features.highlight_search', iconName: 'Search' },
|
||||
{ labelKey: 'system_notice.v3_features.highlight_import', iconName: 'FileInput' },
|
||||
],
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 70,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
// Page 1 — personal thank-you from the creator (shown first)
|
||||
id: 'v3-thankyou',
|
||||
id: 'thank-you-support',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Heart',
|
||||
titleKey: 'system_notice.v3_thankyou.title',
|
||||
bodyKey: 'system_notice.v3_thankyou.body',
|
||||
titleKey: 'system_notice.thank_you_support.title',
|
||||
bodyKey: 'system_notice.thank_you_support.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.thank_you_support.highlight_opensource', iconName: 'Github' },
|
||||
{ labelKey: 'system_notice.thank_you_support.highlight_free', iconName: 'Infinity' },
|
||||
{ labelKey: 'system_notice.thank_you_support.highlight_community', iconName: 'Users' },
|
||||
],
|
||||
cta: {
|
||||
kind: 'link',
|
||||
labelKey: 'system_notice.thank_you_support.cta_bmc',
|
||||
href: 'https://buymeacoffee.com/mauriceboe',
|
||||
},
|
||||
secondaryCta: {
|
||||
kind: 'link',
|
||||
labelKey: 'system_notice.thank_you_support.cta_kofi',
|
||||
href: 'https://ko-fi.com/mauriceboe',
|
||||
},
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 95,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
// Desktop-only: the support modal is suppressed on small/mobile viewports.
|
||||
desktopOnly: true,
|
||||
conditions: [],
|
||||
publishedAt: '2026-06-27T00:00:00Z',
|
||||
priority: 100,
|
||||
recurring: 'per-version',
|
||||
},
|
||||
|
||||
// ── 3.0.14 admin notice — whitespace migration collision ───────────────────
|
||||
|
||||
// Operational alert (not promo): shown only to admins who upgraded across the
|
||||
// 3.0.14 boundary AND only when the migration actually renamed colliding accounts.
|
||||
{
|
||||
id: 'v3014-whitespace-collision',
|
||||
display: 'banner',
|
||||
@@ -150,29 +87,4 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
priority: 85,
|
||||
minVersion: '3.0.14',
|
||||
},
|
||||
|
||||
// ── Onboarding ─────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: 'welcome-v1',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Sparkles',
|
||||
titleKey: 'system_notice.welcome_v1.title',
|
||||
bodyKey: 'system_notice.welcome_v1.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.welcome_v1.highlight_plan', iconName: 'Map' },
|
||||
{ labelKey: 'system_notice.welcome_v1.highlight_share', iconName: 'Users' },
|
||||
{ labelKey: 'system_notice.welcome_v1.highlight_offline', iconName: 'WifiOff' },
|
||||
],
|
||||
cta: {
|
||||
kind: 'action',
|
||||
labelKey: 'system_notice.welcome_v1.cta_label',
|
||||
actionId: 'open:trip-create',
|
||||
},
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'firstLogin' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 100,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -46,19 +46,32 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
|
||||
'SELECT COUNT(*) AS count FROM trips WHERE user_id = ?'
|
||||
).get(userId) as { count: number };
|
||||
|
||||
const dismissedIds = new Set<string>(
|
||||
(db.prepare('SELECT notice_id FROM user_notice_dismissals WHERE user_id = ?')
|
||||
.all(userId) as Array<{ notice_id: string }>)
|
||||
.map(r => r.notice_id)
|
||||
// Dismissals mapped to the app version they were dismissed at (used by per-version notices).
|
||||
const dismissals = new Map<string, string | null>(
|
||||
(db.prepare('SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?')
|
||||
.all(userId) as Array<{ notice_id: string; dismissed_app_version: string | null }>)
|
||||
.map(r => [r.notice_id, r.dismissed_app_version])
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const currentAppVersion = getCurrentAppVersion();
|
||||
const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now };
|
||||
const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
|
||||
|
||||
const isStillDismissed = (n: SystemNotice): boolean => {
|
||||
if (!dismissals.has(n.id)) return false;
|
||||
if (n.recurring === 'per-version') {
|
||||
// Re-show once the running app version moves past the version it was last dismissed at,
|
||||
// so a per-version notice surfaces again on each install/upgrade.
|
||||
const dismissedVer = semver.coerce(dismissals.get(n.id) ?? '0.0.0')?.version ?? '0.0.0';
|
||||
return semver.gte(dismissedVer, appVer);
|
||||
}
|
||||
return true; // default: permanent one-time dismissal
|
||||
};
|
||||
|
||||
return SYSTEM_NOTICES
|
||||
.filter(n => {
|
||||
if (dismissedIds.has(n.id)) return false;
|
||||
if (isStillDismissed(n)) return false;
|
||||
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
|
||||
return evaluate(n, ctx);
|
||||
})
|
||||
@@ -69,15 +82,20 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
|
||||
if (sw !== 0) return sw;
|
||||
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
|
||||
})
|
||||
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto);
|
||||
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, recurring: _rc, ...dto }) => dto);
|
||||
}
|
||||
|
||||
export function dismissNotice(userId: number, noticeId: string): boolean {
|
||||
const exists = SYSTEM_NOTICES.some(n => n.id === noticeId);
|
||||
if (!exists) return false;
|
||||
// Record the app version at dismissal so per-version notices can re-appear on the next
|
||||
// upgrade. Upsert (not INSERT OR IGNORE) so re-dismissing after a bump refreshes the version.
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO user_notice_dismissals (user_id, notice_id, dismissed_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(userId, noticeId, Date.now());
|
||||
INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, notice_id) DO UPDATE SET
|
||||
dismissed_at = excluded.dismissed_at,
|
||||
dismissed_app_version = excluded.dismissed_app_version
|
||||
`).run(userId, noticeId, Date.now(), getCurrentAppVersion());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface NoticeMedia {
|
||||
|
||||
export type NoticeCta =
|
||||
| { kind: 'nav'; labelKey: string; href: string }
|
||||
| { kind: 'link'; labelKey: string; href: string } // external URL, opens in a new tab
|
||||
| { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
|
||||
export interface SystemNotice {
|
||||
@@ -34,13 +35,19 @@ export interface SystemNotice {
|
||||
media?: NoticeMedia;
|
||||
highlights?: Array<{ labelKey: string; iconName?: string }>;
|
||||
cta?: NoticeCta;
|
||||
secondaryCta?: NoticeCta;
|
||||
// Hide this notice on small/mobile viewports (evaluated client-side).
|
||||
desktopOnly?: boolean;
|
||||
dismissible: boolean;
|
||||
conditions: NoticeCondition[];
|
||||
publishedAt: string;
|
||||
minVersion?: string;
|
||||
maxVersion?: string;
|
||||
priority?: number;
|
||||
// 'per-version': re-show on every app version bump (each install + upgrade) instead of
|
||||
// the default permanent one-time dismissal.
|
||||
recurring?: 'per-version';
|
||||
}
|
||||
|
||||
// DTO sent to client (same shape minus the conditions — server evaluates those)
|
||||
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority'>;
|
||||
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority' | 'recurring'>;
|
||||
|
||||
Reference in New Issue
Block a user