mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-02 11:35:59 +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:
@@ -62,16 +62,17 @@ function CTALink({
|
||||
if (notice.cta.kind === 'nav') {
|
||||
navigate(notice.cta.href);
|
||||
if (notice.dismissible) onDismiss();
|
||||
} else if (notice.cta.kind === 'link') {
|
||||
window.open(notice.cta.href, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
runNoticeAction(notice.cta.actionId, { navigate });
|
||||
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
if (actionCta.dismissOnAction !== false) onDismiss();
|
||||
if (notice.cta.dismissOnAction !== false) onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
if (!notice.cta) return null;
|
||||
|
||||
if (notice.cta.kind === 'nav') {
|
||||
if (notice.cta.kind === 'nav' || notice.cta.kind === 'link') {
|
||||
return (
|
||||
<a
|
||||
href={notice.cta.href}
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import { ModalRenderer } from './SystemNoticeModal.js';
|
||||
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
|
||||
|
||||
// Mobile breakpoint matches the modal sheet's (max-width: 639px).
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false)
|
||||
);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.('(max-width: 639px)');
|
||||
if (!mq) return;
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
export function SystemNoticeHost() {
|
||||
const { notices, loaded } = useSystemNoticeStore();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Notices are fetched by authStore after login (see App.tsx / authStore modification).
|
||||
// Cold-session fetch (page reload with valid session) is triggered here:
|
||||
@@ -17,9 +33,12 @@ export function SystemNoticeHost() {
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
const modals = notices.filter(n => n.display === 'modal');
|
||||
const banners = notices.filter(n => n.display === 'banner');
|
||||
const toasts = notices.filter(n => n.display === 'toast');
|
||||
// desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile.
|
||||
const visible = isMobile ? notices.filter(n => !n.desktopOnly) : notices;
|
||||
|
||||
const modals = visible.filter(n => n.display === 'modal');
|
||||
const banners = visible.filter(n => n.display === 'banner');
|
||||
const toasts = visible.filter(n => n.display === 'toast');
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight, Coffee } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
@@ -36,6 +36,33 @@ const SEVERITY_ACCENT: Record<string, string> = {
|
||||
critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
|
||||
};
|
||||
|
||||
// Real brand marks (simple-icons single-path logos) for the support buttons, so the
|
||||
// Buy Me a Coffee / Ko-fi buttons carry their actual logo instead of a generic
|
||||
// lucide glyph. Tinted via currentColor.
|
||||
const BRAND_ICON_PATHS: Record<string, string> = {
|
||||
buymeacoffee:
|
||||
'M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z',
|
||||
kofi:
|
||||
'M11.351 2.715c-2.7 0-4.986.025-6.83.26C2.078 3.285 0 5.154 0 8.61c0 3.506.182 6.13 1.585 8.493 1.584 2.701 4.233 4.182 7.662 4.182h.83c4.209 0 6.494-2.234 7.637-4a9.5 9.5 0 0 0 1.091-2.338C21.792 14.688 24 12.22 24 9.208v-.415c0-3.247-2.13-5.507-5.792-5.87-1.558-.156-2.65-.208-6.857-.208m0 1.947c4.208 0 5.09.052 6.571.182 2.624.311 4.13 1.584 4.13 4v.39c0 2.156-1.792 3.844-3.87 3.844h-.935l-.156.649c-.208 1.013-.597 1.818-1.039 2.546-.909 1.428-2.545 3.064-5.922 3.064h-.805c-2.571 0-4.831-.883-6.078-3.195-1.09-2-1.298-4.155-1.298-7.506 0-2.181.857-3.402 3.012-3.714 1.533-.233 3.559-.26 6.39-.26m6.547 2.287c-.416 0-.65.234-.65.546v2.935c0 .311.234.545.65.545 1.324 0 2.051-.754 2.051-2s-.727-2.026-2.052-2.026m-10.39.182c-1.818 0-3.013 1.48-3.013 3.142 0 1.533.858 2.857 1.949 3.897.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.467 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.364 1.974-3.897 0-1.662-1.247-3.142-3.039-3.142-1.065 0-1.792.545-2.338 1.298-.493-.753-1.246-1.298-2.312-1.298',
|
||||
};
|
||||
|
||||
function brandForHref(href?: string): string | null {
|
||||
if (!href) return null;
|
||||
if (href.includes('buymeacoffee')) return 'buymeacoffee';
|
||||
if (href.includes('ko-fi.com') || href.includes('kofi')) return 'kofi';
|
||||
return null;
|
||||
}
|
||||
|
||||
function BrandIcon({ brand, size = 18, className }: { brand: string; size?: number; className?: string }) {
|
||||
const d = BRAND_ICON_PATHS[brand];
|
||||
if (!d) return null;
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" className={className} aria-hidden="true">
|
||||
<path d={d} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
notices: SystemNoticeDTO[];
|
||||
}
|
||||
@@ -46,12 +73,14 @@ interface ContentProps {
|
||||
title: string;
|
||||
body: string;
|
||||
ctaLabel: string | null;
|
||||
secondaryCtaLabel: string | null;
|
||||
titleId: string;
|
||||
bodyId: string;
|
||||
isDark: boolean;
|
||||
onDismiss: () => void;
|
||||
onDismissAll: () => void;
|
||||
onCTA: () => void;
|
||||
onSecondaryCTA: () => void;
|
||||
// Pager
|
||||
total: number;
|
||||
currentPage: number;
|
||||
@@ -61,7 +90,7 @@ interface ContentProps {
|
||||
onGoto: (i: number) => void;
|
||||
}
|
||||
|
||||
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
||||
function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, onSecondaryCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const isLastPage = total <= 1 || currentPage === total - 1;
|
||||
|
||||
@@ -70,6 +99,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon
|
||||
: DefaultIcon;
|
||||
|
||||
// Real brand logo for each support button, detected from the link target.
|
||||
const primaryBrand = notice.cta?.kind === 'link' ? brandForHref(notice.cta.href) : null;
|
||||
const secondaryBrand = notice.secondaryCta?.kind === 'link' ? brandForHref(notice.secondaryCta.href) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
|
||||
{/* Dismiss X button — only on last page so users read all notices */}
|
||||
@@ -104,17 +137,9 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
|
||||
{/* Special warm header for Heart icon (thank-you notice) */}
|
||||
{notice.icon === 'Heart' && !notice.media && (
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-6 text-center">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
|
||||
<div className="relative flex items-center justify-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
|
||||
<LucideIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
|
||||
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2 id={titleId} className="relative text-xl font-bold text-white leading-tight">{title}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -197,24 +222,27 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{/* Highlights — compact pills */}
|
||||
{notice.highlights && notice.highlights.length > 0 && (
|
||||
<ul className="mx-auto mb-4 space-y-2">
|
||||
<div className="flex flex-wrap justify-center gap-2 mb-4">
|
||||
{notice.highlights.map((h, i) => {
|
||||
const HIcon: React.ElementType | null = h.iconName
|
||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
||||
: null;
|
||||
return (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-slate-100 dark:bg-slate-800 px-3 py-1 text-xs font-medium text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{HIcon
|
||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
||||
: <span className="text-blue-500 shrink-0">✓</span>
|
||||
? <HIcon size={13} className="text-indigo-500 dark:text-indigo-400 shrink-0" />
|
||||
: <span className="text-indigo-500 shrink-0">✓</span>
|
||||
}
|
||||
{t(h.labelKey)}
|
||||
</li>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,16 +298,37 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA + dismiss link */}
|
||||
{/* CTA(s) + dismiss link */}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{ctaLabel && isLastPage ? (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onCTA}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
<div className="flex w-full flex-col sm:flex-row gap-2.5">
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onCTA}
|
||||
className={`flex-1 h-11 inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition active:scale-[0.98] ${
|
||||
notice.cta?.kind === 'link'
|
||||
? 'bg-[#FFDD00] text-[#0D0C22] hover:brightness-95'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{primaryBrand ? <BrandIcon brand={primaryBrand} size={18} /> : (notice.cta?.kind === 'link' && <Coffee size={17} aria-hidden="true" />)}
|
||||
{ctaLabel}
|
||||
</button>
|
||||
{secondaryCtaLabel && (
|
||||
<button
|
||||
id={`notice-cta2-${notice.id}`}
|
||||
onClick={onSecondaryCTA}
|
||||
className={`flex-1 h-11 inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition active:scale-[0.98] ${
|
||||
notice.secondaryCta?.kind === 'link'
|
||||
? 'bg-[#FF5E5B] text-white hover:brightness-95'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{secondaryBrand ? <BrandIcon brand={secondaryBrand} size={18} /> : (notice.secondaryCta?.kind === 'link' && <Coffee size={17} aria-hidden="true" />)}
|
||||
{secondaryCtaLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (notice.dismissible || isLastPage) && (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
@@ -289,14 +338,6 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
)}
|
||||
{notice.dismissible && isLastPage && ctaLabel && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -510,21 +551,22 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
||||
notices.forEach(n => dismiss(n.id));
|
||||
}
|
||||
|
||||
function handleCTA() {
|
||||
if (!notice) return;
|
||||
if (!notice.cta) {
|
||||
handleDismissAll();
|
||||
return;
|
||||
}
|
||||
if (notice.cta.kind === 'nav') {
|
||||
navigate(notice.cta.href);
|
||||
if (notice.dismissible !== false) handleDismissAll();
|
||||
function runCta(cta: SystemNoticeDTO['cta']) {
|
||||
if (!cta) { handleDismissAll(); return; }
|
||||
if (cta.kind === 'nav') {
|
||||
navigate(cta.href);
|
||||
if (notice?.dismissible !== false) handleDismissAll();
|
||||
} else if (cta.kind === 'link') {
|
||||
// External link (e.g. Buy Me a Coffee / Ko-fi): open in a new tab and leave the
|
||||
// notice open so the user can use the other button too.
|
||||
window.open(cta.href, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
runNoticeAction(notice.cta.actionId, { navigate });
|
||||
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
if (actionCta.dismissOnAction !== false) handleDismissAll();
|
||||
runNoticeAction(cta.actionId, { navigate });
|
||||
if (cta.dismissOnAction !== false) handleDismissAll();
|
||||
}
|
||||
}
|
||||
function handleCTA() { runCta(notice?.cta); }
|
||||
function handleSecondaryCTA() { runCta(notice?.secondaryCta); }
|
||||
|
||||
function animatedDismissAll() {
|
||||
const sheet = sheetRef.current;
|
||||
@@ -584,7 +626,7 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
||||
notice, canPage, isLastPage, language, t, dur, ease,
|
||||
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef,
|
||||
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
|
||||
announceIndex, handleDismiss, handleDismissAll, handleCTA, animatedDismissAll,
|
||||
announceIndex, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, animatedDismissAll,
|
||||
handlePrev, handleNext, handleGoto,
|
||||
};
|
||||
}
|
||||
@@ -593,7 +635,7 @@ type NoticeState = ReturnType<typeof useSystemNoticeModal>;
|
||||
|
||||
// Build the NoticeContent props for a given notice + pager slot index.
|
||||
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
|
||||
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handlePrev, handleNext, handleGoto } = S;
|
||||
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S;
|
||||
const rawBody = t(n.bodyKey);
|
||||
const body = n.bodyParams
|
||||
? Object.entries(n.bodyParams).reduce(
|
||||
@@ -606,12 +648,14 @@ function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number):
|
||||
title: t(n.titleKey),
|
||||
body,
|
||||
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
|
||||
secondaryCtaLabel: n.secondaryCta ? t(n.secondaryCta.labelKey) : null,
|
||||
titleId: `notice-title-${n.id}`,
|
||||
bodyId: `notice-body-${n.id}`,
|
||||
isDark,
|
||||
onDismiss: handleDismiss,
|
||||
onDismissAll: handleDismissAll,
|
||||
onCTA: handleCTA,
|
||||
onSecondaryCTA: handleSecondaryCTA,
|
||||
total: notices.length,
|
||||
currentPage: slotIdx,
|
||||
canPage,
|
||||
|
||||
Reference in New Issue
Block a user