MAI CALL - step 12
Passo 12 completo. Build limpo, AC server-side totalmente verificado. O que foi implementado: lib/queue/ — camada de persistência offline: db.ts — Dexie 4 com tabelas pending e deadLetters broadcast.ts — BroadcastChannel helper (mai-call-sync) para comunicar entre tabs sync.ts — loop de sync com retry/backoff: signPhotoUpload → PUT MinIO → create; 409 = sucesso; 4xx = dead-letter; erros de rede = paragem + retry na próxima volta SyncProvider — React Context que: Arranca sync ao reconectar (online event + visibilitychange) Polling de 10s como fallback Regista Background Sync API quando disponível Expõe pendingCount / deadLetterCount via useSyncState() Formulário (/maintenance/new) — refatorado: ao submeter, escreve em IndexedDB e navega imediatamente para /sent sem esperar pelo servidor. O SyncProvider processa a fila em background. Feedback visual: SyncChip na home: "Tudo sincronizado" / "N pedidos por enviar" / erro dead-letter /maintenance/sent: mostra "Em fila" (Clock) ou "Enviado" (CheckCircle2) reactivamente via BroadcastChannel Workbox (@ducanh2912/next-pwa) — app shell precaching ativo, para que o app carregue mesmo sem rede depois da primeira visita.
This commit is contained in:
@@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Camera, X } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { db } from '@/lib/queue/db';
|
||||
import { runSync } from '@/lib/queue/sync';
|
||||
|
||||
// Resize to max 1600px on longest side and compress to JPEG q=0.8.
|
||||
function compressImage(file: File): Promise<Blob> {
|
||||
@@ -52,9 +54,10 @@ export default function NewRequestPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery();
|
||||
const signUpload = trpc.storage.signPhotoUpload.useMutation();
|
||||
const createRequest = trpc.maintenanceRequest.create.useMutation();
|
||||
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
|
||||
undefined,
|
||||
{ staleTime: 60 * 60 * 1000 }, // 1h — serves from cache when offline
|
||||
);
|
||||
|
||||
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -62,9 +65,8 @@ export default function NewRequestPage() {
|
||||
try {
|
||||
const compressed = await compressImage(file);
|
||||
if (photoPreview) URL.revokeObjectURL(photoPreview);
|
||||
const preview = URL.createObjectURL(compressed);
|
||||
setPhotoBlob(compressed);
|
||||
setPhotoPreview(preview);
|
||||
setPhotoPreview(URL.createObjectURL(compressed));
|
||||
} catch {
|
||||
setError('Não foi possível processar a foto. Tenta de novo.');
|
||||
}
|
||||
@@ -86,35 +88,24 @@ export default function NewRequestPage() {
|
||||
|
||||
try {
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
let photoKey: string | undefined;
|
||||
|
||||
// 1. Upload photo if present
|
||||
if (photoBlob) {
|
||||
const { uploadUrl, photoKey: key } = await signUpload.mutateAsync({
|
||||
contentType: 'image/jpeg',
|
||||
byteSize: photoBlob.size,
|
||||
});
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: photoBlob,
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
if (!res.ok) throw new Error('Falha no upload da foto');
|
||||
photoKey = key;
|
||||
}
|
||||
|
||||
// 2. Create request
|
||||
await createRequest.mutateAsync({
|
||||
// Enqueue in IndexedDB immediately — returns control to the user
|
||||
// regardless of network state. The SyncProvider will drain the queue.
|
||||
await db.pending.add({
|
||||
clientRequestId,
|
||||
workstationId,
|
||||
description: description.trim(),
|
||||
photoKey,
|
||||
clientRequestId,
|
||||
photoBlob: photoBlob ?? undefined,
|
||||
queuedAt: Date.now(),
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
// 3. Confirm
|
||||
// Attempt immediate sync if online (fire-and-forget)
|
||||
if (navigator.onLine) runSync().catch(() => {});
|
||||
|
||||
router.push(`/maintenance/sent?cid=${clientRequestId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro ao submeter pedido. Tenta de novo.');
|
||||
setError(err instanceof Error ? err.message : 'Erro ao guardar pedido. Tenta de novo.');
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
@@ -124,7 +115,6 @@ export default function NewRequestPage() {
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-3 border-b border-border bg-card px-4 py-3">
|
||||
<Link href="/" className="rounded-md p-1 hover:bg-accent">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
@@ -163,11 +153,7 @@ export default function NewRequestPage() {
|
||||
{photoPreview ? (
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photoPreview}
|
||||
alt="Pré-visualização"
|
||||
className="h-48 w-full rounded-lg object-cover"
|
||||
/>
|
||||
<img src={photoPreview} alt="Pré-visualização" className="h-48 w-full rounded-lg object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={removePhoto}
|
||||
@@ -199,9 +185,7 @@ export default function NewRequestPage() {
|
||||
{/* Descrição */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
|
||||
<span>
|
||||
Descrição <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<span>Descrição <span className="text-destructive">*</span></span>
|
||||
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{descLen}/1000
|
||||
</span>
|
||||
@@ -229,7 +213,7 @@ export default function NewRequestPage() {
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded-xl bg-primary px-6 py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? 'A enviar…' : 'Enviar pedido'}
|
||||
{submitting ? 'A guardar…' : 'Enviar pedido'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { SentStatus } from './sent-status';
|
||||
|
||||
export default async function SentPage({
|
||||
searchParams,
|
||||
@@ -7,27 +6,5 @@ export default async function SentPage({
|
||||
searchParams: Promise<{ cid?: string }>;
|
||||
}) {
|
||||
const { cid } = await searchParams;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col items-center justify-center gap-6 p-6 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||
<h1 className="text-2xl font-bold">Pedido enviado</h1>
|
||||
{cid && (
|
||||
<p className="font-mono text-xs text-muted-foreground" data-testid="request-cid">
|
||||
{cid}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A equipa de manutenção foi notificada e irá tratar do problema.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Voltar ao início
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
return <SentStatus cid={cid ?? ''} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { CheckCircle2, Clock } from 'lucide-react';
|
||||
import { db } from '@/lib/queue/db';
|
||||
import { subscribeBroadcast } from '@/lib/queue/broadcast';
|
||||
|
||||
export function SentStatus({ cid }: { cid: string }) {
|
||||
const [inQueue, setInQueue] = useState<boolean | null>(null); // null = loading
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
const item = await db.pending.get(cid);
|
||||
setInQueue(!!item);
|
||||
}
|
||||
check();
|
||||
|
||||
const unsub = subscribeBroadcast((msg) => {
|
||||
if (msg.type === 'synced' && msg.clientRequestId === cid) setInQueue(false);
|
||||
if (msg.type === 'dead-letter' && msg.clientRequestId === cid) setInQueue(false);
|
||||
});
|
||||
|
||||
return unsub;
|
||||
}, [cid]);
|
||||
|
||||
const pending = inQueue === true;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col items-center justify-center gap-6 p-6 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{pending ? (
|
||||
<Clock className="h-16 w-16 text-orange-400" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">
|
||||
{pending ? 'Pedido em fila' : 'Pedido enviado'}
|
||||
</h1>
|
||||
{cid && (
|
||||
<p className="font-mono text-xs text-muted-foreground" data-testid="request-cid">
|
||||
{cid}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pending
|
||||
? 'Será enviado assim que a ligação for restabelecida.'
|
||||
: 'A equipa de manutenção foi notificada e irá tratar do problema.'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Voltar ao início
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user