Implemented guest-only PWA using vite-plugin-pwa (the actual published package; @vite-pwa/plugin isn’t on npm) with

injectManifest, a new typed SW source, runtime caching, and a non‑blocking update toast with an action button. The
  guest shell now links a dedicated manifest and theme color, and background upload sync is managed in a single
  PwaManager component.

  Key changes (where/why)

  - vite.config.ts: added VitePWA injectManifest config, guest manifest, and output to /public so the SW can control /
    scope.
  - resources/js/guest/guest-sw.ts: new Workbox SW (precache + runtime caching for guest navigation, GET /api/v1/*,
    images, fonts) and preserves push/sync/notification logic.
  - resources/js/guest/components/PwaManager.tsx: registers SW, shows update/offline toasts, and processes the upload
    queue on sync/online.
  - resources/js/guest/components/ToastHost.tsx: action-capable toasts so update prompts can include a CTA.
  - resources/js/guest/i18n/messages.ts: added common.updateAvailable, common.updateAction, common.offlineReady.
  - resources/views/guest.blade.php: manifest + theme color + apple touch icon.
  - .gitignore: ignore generated public/guest-sw.js and public/guest.webmanifest; public/guest-sw.js removed since it’s
    now build output.
This commit is contained in:
Codex Agent
2025-12-27 10:59:44 +01:00
parent efc173cf5d
commit 3e3a2c49d6
30 changed files with 3862 additions and 812 deletions

View File

@@ -6,6 +6,7 @@ import { Page } from './_util';
import { useLocale } from '../i18n/LocaleContext';
import { useTranslation } from '../i18n/useTranslation';
import { getHelpArticle, type HelpArticleDetail } from '../services/helpApi';
import PullToRefresh from '../components/PullToRefresh';
export default function HelpArticlePage() {
const params = useParams<{ token?: string; slug: string }>();
@@ -40,69 +41,76 @@ export default function HelpArticlePage() {
return (
<Page title={title}>
<div className="mb-4">
<Button variant="ghost" size="sm" asChild>
<Link to={basePath}>
{t('help.article.back')}
</Link>
</Button>
</div>
{state === 'loading' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('common.actions.loading')}
</div>
)}
{state === 'error' && (
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
<p>{t('help.article.unavailable')}</p>
<Button variant="secondary" size="sm" onClick={loadArticle}>
{t('help.article.reload')}
<PullToRefresh
onRefresh={loadArticle}
pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')}
>
<div className="mb-4">
<Button variant="ghost" size="sm" asChild>
<Link to={basePath}>
{t('help.article.back')}
</Link>
</Button>
</div>
)}
{state === 'ready' && article && (
<article className="space-y-6">
<div className="space-y-2 text-sm text-muted-foreground">
{article.updated_at && (
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
)}
<Button variant="ghost" size="sm" asChild>
<Link to={basePath}>
{t('help.article.back')}
</Link>
{state === 'loading' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('common.actions.loading')}
</div>
)}
{state === 'error' && (
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
<p>{t('help.article.unavailable')}</p>
<Button variant="secondary" size="sm" onClick={loadArticle}>
{t('help.article.reload')}
</Button>
</div>
<div className="overflow-x-auto">
<div
className="prose prose-sm max-w-none dark:prose-invert [&_table]:w-full [&_table]:text-sm [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
/>
</div>
{article.related && article.related.length > 0 && (
<section className="space-y-3">
<h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3>
<div className="flex flex-wrap gap-2">
{article.related.map((rel) => (
<Button
key={rel.slug}
variant="outline"
size="sm"
asChild
>
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
{rel.title ?? rel.slug}
</Link>
</Button>
))}
</div>
</section>
)}
</article>
)}
)}
{state === 'ready' && article && (
<article className="space-y-6">
<div className="space-y-2 text-sm text-muted-foreground">
{article.updated_at && (
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
)}
<Button variant="ghost" size="sm" asChild>
<Link to={basePath}>
{t('help.article.back')}
</Link>
</Button>
</div>
<div className="overflow-x-auto">
<div
className="prose prose-sm max-w-none dark:prose-invert [&_table]:w-full [&_table]:text-sm [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
/>
</div>
{article.related && article.related.length > 0 && (
<section className="space-y-3">
<h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3>
<div className="flex flex-wrap gap-2">
{article.related.map((rel) => (
<Button
key={rel.slug}
variant="outline"
size="sm"
asChild
>
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
{rel.title ?? rel.slug}
</Link>
</Button>
))}
</div>
</section>
)}
</article>
)}
</PullToRefresh>
</Page>
);
}