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.
129 lines
4.4 KiB
TypeScript
129 lines
4.4 KiB
TypeScript
import React from 'react';
|
|
import { Link, useParams } from 'react-router-dom';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Loader2 } from 'lucide-react';
|
|
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 }>();
|
|
const slug = params.slug;
|
|
const { locale } = useLocale();
|
|
const { t } = useTranslation();
|
|
const [article, setArticle] = React.useState<HelpArticleDetail | null>(null);
|
|
const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading');
|
|
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
|
|
|
const loadArticle = React.useCallback(async () => {
|
|
if (!slug) {
|
|
setState('error');
|
|
return;
|
|
}
|
|
setState('loading');
|
|
try {
|
|
const result = await getHelpArticle(slug, locale);
|
|
setArticle(result.article);
|
|
setState('ready');
|
|
} catch (error) {
|
|
console.error('[HelpArticle] Failed to load article', error);
|
|
setState('error');
|
|
}
|
|
}, [slug, locale]);
|
|
|
|
React.useEffect(() => {
|
|
loadArticle();
|
|
}, [loadArticle]);
|
|
|
|
const title = article?.title ?? t('help.article.unavailable');
|
|
|
|
return (
|
|
<Page title={title}>
|
|
<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 === '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>
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|
|
|
|
function formatDate(value: string, locale: string): string {
|
|
try {
|
|
return new Date(value).toLocaleDateString(locale, {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
});
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|