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:
@@ -8,6 +8,7 @@ import { Page } from './_util';
|
||||
import { useLocale } from '../i18n/LocaleContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { getHelpArticles, type HelpArticleSummary } from '../services/helpApi';
|
||||
import PullToRefresh from '../components/PullToRefresh';
|
||||
|
||||
export default function HelpCenterPage() {
|
||||
const params = useParams<{ token?: string }>();
|
||||
@@ -48,94 +49,101 @@ export default function HelpCenterPage() {
|
||||
|
||||
return (
|
||||
<Page title={t('help.center.title')}>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t('help.center.subtitle')}</p>
|
||||
<PullToRefresh
|
||||
onRefresh={() => loadArticles(true)}
|
||||
pullLabel={t('common.pullToRefresh')}
|
||||
releaseLabel={t('common.releaseToRefresh')}
|
||||
refreshingLabel={t('common.refreshing')}
|
||||
>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t('help.center.subtitle')}</p>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-border/60 bg-background/50 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
placeholder={t('help.center.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="flex-1"
|
||||
aria-label={t('help.center.searchPlaceholder')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="sm:w-auto"
|
||||
onClick={() => loadArticles(true)}
|
||||
disabled={state === 'loading'}
|
||||
>
|
||||
{state === 'loading' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('common.actions.loading')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{t('help.center.retry')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{servedFromCache && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200">
|
||||
<Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100">
|
||||
{t('help.center.offlineBadge')}
|
||||
</Badge>
|
||||
<span>{t('help.center.offlineDescription')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-foreground">{t('help.center.listTitle')}</h2>
|
||||
{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="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p>{t('help.center.error')}</p>
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-border/60 bg-background/50 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
placeholder={t('help.center.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="flex-1"
|
||||
aria-label={t('help.center.searchPlaceholder')}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => loadArticles(false)}
|
||||
variant="outline"
|
||||
className="sm:w-auto"
|
||||
onClick={() => loadArticles(true)}
|
||||
disabled={state === 'loading'}
|
||||
>
|
||||
{t('help.center.retry')}
|
||||
{state === 'loading' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('common.actions.loading')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{t('help.center.retry')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && filteredArticles.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
|
||||
{t('help.center.empty')}
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && filteredArticles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{filteredArticles.map((article) => (
|
||||
<Link
|
||||
key={article.slug}
|
||||
to={`${basePath}/${encodeURIComponent(article.slug)}`}
|
||||
className="block rounded-2xl border border-border/60 bg-card/70 p-4 transition-colors hover:border-primary/60"
|
||||
{servedFromCache && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200">
|
||||
<Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100">
|
||||
{t('help.center.offlineBadge')}
|
||||
</Badge>
|
||||
<span>{t('help.center.offlineDescription')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-foreground">{t('help.center.listTitle')}</h2>
|
||||
{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="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p>{t('help.center.error')}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => loadArticles(false)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">{article.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-3">{article.summary}</p>
|
||||
{t('help.center.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && filteredArticles.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
|
||||
{t('help.center.empty')}
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && filteredArticles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{filteredArticles.map((article) => (
|
||||
<Link
|
||||
key={article.slug}
|
||||
to={`${basePath}/${encodeURIComponent(article.slug)}`}
|
||||
className="block rounded-2xl border border-border/60 bg-card/70 p-4 transition-colors hover:border-primary/60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">{article.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-3">{article.summary}</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{article.updated_at ? formatDate(article.updated_at, locale) : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{article.updated_at ? formatDate(article.updated_at, locale) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</PullToRefresh>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user