4
0
Эх сурвалжийг харах

Add shared link info card with view tracking for quotes and invoices (#22)

* added view counter for digital shared pages

* lint

* tests
Bernt Christian Egeland 7 сар өмнө
parent
commit
83990f9b8c
33 өөрчлөгдсөн 492 нэмэгдсэн , 24 устгасан
  1. 11 0
      messages/de/quotes.json
  2. 10 1
      messages/de/service.json
  3. 11 0
      messages/en/quotes.json
  4. 10 1
      messages/en/service.json
  5. 11 0
      messages/es/quotes.json
  6. 10 1
      messages/es/service.json
  7. 11 0
      messages/fr/quotes.json
  8. 10 1
      messages/fr/service.json
  9. 11 0
      messages/it/quotes.json
  10. 10 1
      messages/it/service.json
  11. 11 0
      messages/nb/quotes.json
  12. 10 1
      messages/nb/service.json
  13. 11 0
      messages/nl/quotes.json
  14. 10 1
      messages/nl/service.json
  15. 11 0
      messages/pl/quotes.json
  16. 10 1
      messages/pl/service.json
  17. 11 0
      messages/pt-BR/quotes.json
  18. 10 1
      messages/pt-BR/service.json
  19. 11 0
      messages/tr/quotes.json
  20. 10 1
      messages/tr/service.json
  21. 9 0
      prisma/migrations/20260227191610_shared_pages_viewcounter/migration.sql
  22. 9 3
      prisma/schema.prisma
  23. 6 3
      src/__tests__/features/public/invoice-view.test.tsx
  24. 6 3
      src/__tests__/features/public/quote-view.test.tsx
  25. 9 0
      src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx
  26. 9 0
      src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx
  27. 49 0
      src/app/api/public/track-view/route.ts
  28. 141 0
      src/components/shared-link-card.tsx
  29. 2 2
      src/features/quotes/Actions/quoteShareActions.ts
  30. 21 0
      src/features/quotes/Components/QuotePageClient.tsx
  31. 2 2
      src/features/vehicles/Actions/serviceActions.ts
  32. 3 0
      src/features/vehicles/Components/service-detail/types.ts
  33. 16 1
      src/features/vehicles/Components/service-page/ServicePageClient.tsx

+ 11 - 0
messages/de/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Wird auf dem Angebotsdokument angezeigt",
     "internalHelper": "Nur für Ihr Team sichtbar"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Geteilter Link",
+      "sharedOn": "Geteilt am {date}",
+      "notViewed": "Noch nicht angesehen",
+      "viewedTimes": "{count, plural, one {# Mal} other {# Mal}} angesehen",
+      "lastViewed": "Zuletzt {date}",
+      "copyLink": "Link kopieren",
+      "linkCopied": "Link kopiert"
+    }
+  },
   "details": {
     "title": "Angebotsdetails",
     "titleLabel": "Titel *",

+ 10 - 1
messages/de/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Fahrzeug",
     "customer": "Kunde",
     "vin": "FIN: {vin}",
-    "mileageAtService": "Kilometerstand bei Service: {mileage} {unit}"
+    "mileageAtService": "Kilometerstand bei Service: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Geteilter Link",
+      "sharedOn": "Geteilt am {date}",
+      "notViewed": "Noch nicht angesehen",
+      "viewedTimes": "{count, plural, one {# Mal} other {# Mal}} angesehen",
+      "lastViewed": "Zuletzt {date}",
+      "copyLink": "Link kopieren",
+      "linkCopied": "Link kopiert"
+    }
   },
   "images": {
     "title": "Service-Bilder",

+ 11 - 0
messages/en/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Shown on the quote document",
     "internalHelper": "Only visible to your team"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Shared Link",
+      "sharedOn": "Shared on {date}",
+      "notViewed": "Not viewed yet",
+      "viewedTimes": "Viewed {count, plural, one {# time} other {# times}}",
+      "lastViewed": "Last {date}",
+      "copyLink": "Copy link",
+      "linkCopied": "Link copied"
+    }
+  },
   "details": {
     "title": "Quote Details",
     "titleLabel": "Title *",

+ 10 - 1
messages/en/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Vehicle",
     "customer": "Customer",
     "vin": "VIN: {vin}",
-    "mileageAtService": "Mileage at service: {mileage} {unit}"
+    "mileageAtService": "Mileage at service: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Shared Link",
+      "sharedOn": "Shared on {date}",
+      "notViewed": "Not viewed yet",
+      "viewedTimes": "Viewed {count, plural, one {# time} other {# times}}",
+      "lastViewed": "Last {date}",
+      "copyLink": "Copy link",
+      "linkCopied": "Link copied"
+    }
   },
   "images": {
     "title": "Service Images",

+ 11 - 0
messages/es/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Se muestra en el documento del presupuesto",
     "internalHelper": "Solo visible para su equipo"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Enlace compartido",
+      "sharedOn": "Compartido el {date}",
+      "notViewed": "Aún no visto",
+      "viewedTimes": "Visto {count, plural, one {# vez} other {# veces}}",
+      "lastViewed": "Último {date}",
+      "copyLink": "Copiar enlace",
+      "linkCopied": "Enlace copiado"
+    }
+  },
   "details": {
     "title": "Detalles del presupuesto",
     "titleLabel": "Título *",

+ 10 - 1
messages/es/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Vehículo",
     "customer": "Cliente",
     "vin": "VIN: {vin}",
-    "mileageAtService": "Millaje en el servicio: {mileage} {unit}"
+    "mileageAtService": "Millaje en el servicio: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Enlace compartido",
+      "sharedOn": "Compartido el {date}",
+      "notViewed": "Aún no visto",
+      "viewedTimes": "Visto {count, plural, one {# vez} other {# veces}}",
+      "lastViewed": "Último {date}",
+      "copyLink": "Copiar enlace",
+      "linkCopied": "Enlace copiado"
+    }
   },
   "images": {
     "title": "Imágenes del Servicio",

+ 11 - 0
messages/fr/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Affiché sur le document du devis",
     "internalHelper": "Visible uniquement par votre équipe"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Lien partagé",
+      "sharedOn": "Partagé le {date}",
+      "notViewed": "Pas encore consulté",
+      "viewedTimes": "Consulté {count, plural, one {# fois} other {# fois}}",
+      "lastViewed": "Dernier {date}",
+      "copyLink": "Copier le lien",
+      "linkCopied": "Lien copié"
+    }
+  },
   "details": {
     "title": "Détails du devis",
     "titleLabel": "Titre *",

+ 10 - 1
messages/fr/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Véhicule",
     "customer": "Client",
     "vin": "VIN: {vin}",
-    "mileageAtService": "Kilométrage au service: {mileage} {unit}"
+    "mileageAtService": "Kilométrage au service: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Lien partagé",
+      "sharedOn": "Partagé le {date}",
+      "notViewed": "Pas encore consulté",
+      "viewedTimes": "Consulté {count, plural, one {# fois} other {# fois}}",
+      "lastViewed": "Dernier {date}",
+      "copyLink": "Copier le lien",
+      "linkCopied": "Lien copié"
+    }
   },
   "images": {
     "title": "Images du Service",

+ 11 - 0
messages/it/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Mostrato sul documento del preventivo",
     "internalHelper": "Visibile solo al tuo team"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Link condiviso",
+      "sharedOn": "Condiviso il {date}",
+      "notViewed": "Non ancora visualizzato",
+      "viewedTimes": "Visualizzato {count, plural, one {# volta} other {# volte}}",
+      "lastViewed": "Ultimo {date}",
+      "copyLink": "Copia link",
+      "linkCopied": "Link copiato"
+    }
+  },
   "details": {
     "title": "Dettagli del preventivo",
     "titleLabel": "Titolo *",

+ 10 - 1
messages/it/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Veicolo",
     "customer": "Cliente",
     "vin": "VIN: {vin}",
-    "mileageAtService": "Chilometraggio al servizio: {mileage} {unit}"
+    "mileageAtService": "Chilometraggio al servizio: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Link condiviso",
+      "sharedOn": "Condiviso il {date}",
+      "notViewed": "Non ancora visualizzato",
+      "viewedTimes": "Visualizzato {count, plural, one {# volta} other {# volte}}",
+      "lastViewed": "Ultimo {date}",
+      "copyLink": "Copia link",
+      "linkCopied": "Link copiato"
+    }
   },
   "images": {
     "title": "Immagini del Servizio",

+ 11 - 0
messages/nb/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Vises på tilbudsdokumentet",
     "internalHelper": "Kun synlig for teamet ditt"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Delt lenke",
+      "sharedOn": "Delt {date}",
+      "notViewed": "Ikke sett ennå",
+      "viewedTimes": "Sett {count, plural, one {# gang} other {# ganger}}",
+      "lastViewed": "Sist {date}",
+      "copyLink": "Kopier lenke",
+      "linkCopied": "Lenke kopiert"
+    }
+  },
   "details": {
     "title": "Tilbudsdetaljer",
     "titleLabel": "Tittel *",

+ 10 - 1
messages/nb/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Kjøretøy",
     "customer": "Kunde",
     "vin": "VIN: {vin}",
-    "mileageAtService": "Kilometerstand ved service: {mileage} {unit}"
+    "mileageAtService": "Kilometerstand ved service: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Delt lenke",
+      "sharedOn": "Delt {date}",
+      "notViewed": "Ikke sett ennå",
+      "viewedTimes": "Sett {count, plural, one {# gang} other {# ganger}}",
+      "lastViewed": "Sist {date}",
+      "copyLink": "Kopier lenke",
+      "linkCopied": "Lenke kopiert"
+    }
   },
   "images": {
     "title": "Servicebilder",

+ 11 - 0
messages/nl/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Wordt getoond op het offertedocument",
     "internalHelper": "Alleen zichtbaar voor uw team"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Gedeelde link",
+      "sharedOn": "Gedeeld op {date}",
+      "notViewed": "Nog niet bekeken",
+      "viewedTimes": "{count, plural, one {# keer} other {# keer}} bekeken",
+      "lastViewed": "Laatst {date}",
+      "copyLink": "Link kopiëren",
+      "linkCopied": "Link gekopieerd"
+    }
+  },
   "details": {
     "title": "Offertedetails",
     "titleLabel": "Titel *",

+ 10 - 1
messages/nl/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Voertuig",
     "customer": "Klant",
     "vin": "VIN: {vin}",
-    "mileageAtService": "Kilometerstand bij service: {mileage} {unit}"
+    "mileageAtService": "Kilometerstand bij service: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Gedeelde link",
+      "sharedOn": "Gedeeld op {date}",
+      "notViewed": "Nog niet bekeken",
+      "viewedTimes": "{count, plural, one {# keer} other {# keer}} bekeken",
+      "lastViewed": "Laatst {date}",
+      "copyLink": "Link kopiëren",
+      "linkCopied": "Link gekopieerd"
+    }
   },
   "images": {
     "title": "Service-afbeeldingen",

+ 11 - 0
messages/pl/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Wyświetlane na dokumencie wyceny",
     "internalHelper": "Widoczne tylko dla Twojego zespołu"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Udostępniony link",
+      "sharedOn": "Udostępniono {date}",
+      "notViewed": "Jeszcze nie wyświetlono",
+      "viewedTimes": "Wyświetlono {count, plural, one {# raz} other {# razy}}",
+      "lastViewed": "Ostatnio {date}",
+      "copyLink": "Kopiuj link",
+      "linkCopied": "Link skopiowany"
+    }
+  },
   "details": {
     "title": "Szczegóły wyceny",
     "titleLabel": "Tytuł *",

+ 10 - 1
messages/pl/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Pojazd",
     "customer": "Klient",
     "vin": "VIN: {vin}",
-    "mileageAtService": "Przebieg w chwili serwisu: {mileage} {unit}"
+    "mileageAtService": "Przebieg w chwili serwisu: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Udostępniony link",
+      "sharedOn": "Udostępniono {date}",
+      "notViewed": "Jeszcze nie wyświetlono",
+      "viewedTimes": "Wyświetlono {count, plural, one {# raz} other {# razy}}",
+      "lastViewed": "Ostatnio {date}",
+      "copyLink": "Kopiuj link",
+      "linkCopied": "Link skopiowany"
+    }
   },
   "images": {
     "title": "Zdjęcia Serwisu",

+ 11 - 0
messages/pt-BR/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Exibido no documento do orçamento",
     "internalHelper": "Visível apenas para sua equipe"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Link compartilhado",
+      "sharedOn": "Compartilhado em {date}",
+      "notViewed": "Ainda não visualizado",
+      "viewedTimes": "Visualizado {count, plural, one {# vez} other {# vezes}}",
+      "lastViewed": "Último {date}",
+      "copyLink": "Copiar link",
+      "linkCopied": "Link copiado"
+    }
+  },
   "details": {
     "title": "Detalhes do orçamento",
     "titleLabel": "Título *",

+ 10 - 1
messages/pt-BR/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Veículo",
     "customer": "Cliente",
     "vin": "VIN: {vin}",
-    "mileageAtService": "Quilometragem no serviço: {mileage} {unit}"
+    "mileageAtService": "Quilometragem no serviço: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Link compartilhado",
+      "sharedOn": "Compartilhado em {date}",
+      "notViewed": "Ainda não visualizado",
+      "viewedTimes": "Visualizado {count, plural, one {# vez} other {# vezes}}",
+      "lastViewed": "Último {date}",
+      "copyLink": "Copiar link",
+      "linkCopied": "Link copiado"
+    }
   },
   "images": {
     "title": "Imagens do Serviço",

+ 11 - 0
messages/tr/quotes.json

@@ -89,6 +89,17 @@
     "publicHelper": "Teklif belgesinde gösterilir",
     "internalHelper": "Yalnızca ekibiniz tarafından görülebilir"
   },
+  "sidebar": {
+    "sharedLink": {
+      "title": "Paylaşılan bağlantı",
+      "sharedOn": "{date} tarihinde paylaşıldı",
+      "notViewed": "Henüz görüntülenmedi",
+      "viewedTimes": "{count, plural, one {# kez} other {# kez}} görüntülendi",
+      "lastViewed": "Son {date}",
+      "copyLink": "Bağlantıyı kopyala",
+      "linkCopied": "Bağlantı kopyalandı"
+    }
+  },
   "details": {
     "title": "Teklif Detayları",
     "titleLabel": "Başlık *",

+ 10 - 1
messages/tr/service.json

@@ -178,7 +178,16 @@
     "vehicle": "Araç",
     "customer": "Müşteri",
     "vin": "VIN: {vin}",
-    "mileageAtService": "Servis sırasındaki kilometre: {mileage} {unit}"
+    "mileageAtService": "Servis sırasındaki kilometre: {mileage} {unit}",
+    "sharedLink": {
+      "title": "Paylaşılan bağlantı",
+      "sharedOn": "{date} tarihinde paylaşıldı",
+      "notViewed": "Henüz görüntülenmedi",
+      "viewedTimes": "{count, plural, one {# kez} other {# kez}} görüntülendi",
+      "lastViewed": "Son {date}",
+      "copyLink": "Bağlantıyı kopyala",
+      "linkCopied": "Bağlantı kopyalandı"
+    }
   },
   "images": {
     "title": "Servis Resimleri",

+ 9 - 0
prisma/migrations/20260227191610_shared_pages_viewcounter/migration.sql

@@ -0,0 +1,9 @@
+-- AlterTable
+ALTER TABLE "quotes" ADD COLUMN     "lastViewedAt" TIMESTAMP(3),
+ADD COLUMN     "sharedAt" TIMESTAMP(3),
+ADD COLUMN     "viewCount" INTEGER NOT NULL DEFAULT 0;
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "lastViewedAt" TIMESTAMP(3),
+ADD COLUMN     "sharedAt" TIMESTAMP(3),
+ADD COLUMN     "viewCount" INTEGER NOT NULL DEFAULT 0;

+ 9 - 3
prisma/schema.prisma

@@ -196,9 +196,12 @@ model ServiceRecord {
   discountValue   Float    @default(0)
   discountAmount  Float    @default(0)
   manuallyPaid    Boolean  @default(false)
-  publicToken     String?  @unique
-  createdAt       DateTime @default(now())
-  updatedAt       DateTime @updatedAt
+  publicToken     String?   @unique
+  sharedAt        DateTime?
+  viewCount       Int       @default(0)
+  lastViewedAt    DateTime?
+  createdAt       DateTime  @default(now())
+  updatedAt       DateTime  @updatedAt
 
   vehicleId String
   vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
@@ -403,6 +406,9 @@ model Quote {
   totalAmount     Float     @default(0)
   notes           String?
   publicToken     String?
+  sharedAt        DateTime?
+  viewCount       Int       @default(0)
+  lastViewedAt    DateTime?
   customerMessage String?
   convertedToId   String?
   createdAt       DateTime  @default(now())

+ 6 - 3
src/__tests__/features/public/invoice-view.test.tsx

@@ -89,7 +89,7 @@ beforeEach(() => {
   vi.resetAllMocks();
   URL.createObjectURL = vi.fn().mockReturnValue("blob:mock-url");
   URL.revokeObjectURL = vi.fn();
-  mockFetch = vi.fn();
+  mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }) });
   vi.stubGlobal("fetch", mockFetch);
   // Default: no payment params in URL
   vi.stubGlobal("location", {
@@ -268,7 +268,8 @@ describe("InvoiceView", () => {
           "/api/public/share/invoice/org-1/tok-xyz/checkout",
           expect.objectContaining({ method: "POST" })
         );
-        const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+        const call = mockFetch.mock.calls.find((c: unknown[]) => c[0] === "/api/public/share/invoice/org-1/tok-xyz/checkout");
+        const body = JSON.parse(call[1].body);
         expect(body.provider).toBe("stripe");
         expect(body.amount).toBe(200);
       });
@@ -326,7 +327,9 @@ describe("InvoiceView", () => {
       render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={["vipps"]} />);
 
       await waitFor(() => {
-        const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+        const call = mockFetch.mock.calls.find((c: unknown[]) => c[0] === "/api/public/share/invoice/org-1/tok-xyz/verify");
+        expect(call).toBeTruthy();
+        const body = JSON.parse(call[1].body);
         expect(body.provider).toBe("vipps");
         expect(body.externalId).toBe("vipps-ref-456");
       });

+ 6 - 3
src/__tests__/features/public/quote-view.test.tsx

@@ -64,7 +64,7 @@ beforeEach(() => {
   vi.resetAllMocks();
   URL.createObjectURL = vi.fn().mockReturnValue("blob:mock-url");
   URL.revokeObjectURL = vi.fn();
-  mockFetch = vi.fn();
+  mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }) });
   vi.stubGlobal("fetch", mockFetch);
 });
 
@@ -243,7 +243,8 @@ describe("QuoteView", () => {
           "/api/public/forms/quote-response",
           expect.objectContaining({ method: "POST" })
         );
-        const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+        const call = mockFetch.mock.calls.find((c: unknown[]) => c[0] === "/api/public/forms/quote-response");
+        const body = JSON.parse(call[1].body);
         expect(body.action).toBe("accepted");
         expect(body.quoteId).toBe("quote-1");
         expect(body.publicToken).toBe("tok-abc");
@@ -308,7 +309,9 @@ describe("QuoteView", () => {
       await userEvent.click(screen.getByRole("button", { name: /submit request/i }));
 
       await waitFor(() => {
-        const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+        const call = mockFetch.mock.calls.find((c: unknown[]) => c[0] === "/api/public/forms/quote-response");
+        expect(call).toBeTruthy();
+        const body = JSON.parse(call[1].body);
         expect(body.action).toBe("changes_requested");
         expect(body.message).toBe("Need a discount");
       });

+ 9 - 0
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx

@@ -145,6 +145,15 @@ export function InvoiceView({
   const [verifying, setVerifying] = useState(false)
   const [customAmount, setCustomAmount] = useState(false)
 
+  // Track view on mount
+  useEffect(() => {
+    fetch('/api/public/track-view', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ type: 'invoice', token }),
+    }).catch(() => { /* fire-and-forget */ })
+  }, [token])
+
   const vehicleName = `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
   const displayTotal = record.totalAmount > 0 ? record.totalAmount : record.cost
   const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`

+ 9 - 0
src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx

@@ -113,6 +113,15 @@ export function QuoteView({
   const [changeMessage, setChangeMessage] = useState("");
   const [carouselIndex, setCarouselIndex] = useState<number | null>(null);
 
+  // Track view on mount
+  useEffect(() => {
+    fetch('/api/public/track-view', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ type: 'quote', token }),
+    }).catch(() => { /* fire-and-forget */ });
+  }, [token]);
+
   const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`;
   const df = dateFormat || DEFAULT_DATE_FORMAT;
   const tz = timezone || undefined;

+ 49 - 0
src/app/api/public/track-view/route.ts

@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db";
+
+export async function POST(request: NextRequest) {
+  try {
+    const body = await request.json();
+    const { type, token } = body;
+
+    if (!type || !token || !["quote", "invoice"].includes(type)) {
+      return NextResponse.json({ error: "Invalid request" }, { status: 400 });
+    }
+
+    if (type === "quote") {
+      const quote = await db.quote.findFirst({
+        where: { publicToken: token },
+        select: { id: true },
+      });
+      if (!quote) {
+        return NextResponse.json({ error: "Not found" }, { status: 404 });
+      }
+      await db.quote.update({
+        where: { id: quote.id },
+        data: {
+          viewCount: { increment: 1 },
+          lastViewedAt: new Date(),
+        },
+      });
+    } else {
+      const record = await db.serviceRecord.findFirst({
+        where: { publicToken: token },
+        select: { id: true },
+      });
+      if (!record) {
+        return NextResponse.json({ error: "Not found" }, { status: 404 });
+      }
+      await db.serviceRecord.update({
+        where: { id: record.id },
+        data: {
+          viewCount: { increment: 1 },
+          lastViewedAt: new Date(),
+        },
+      });
+    }
+
+    return NextResponse.json({ success: true });
+  } catch {
+    return NextResponse.json({ error: "Internal error" }, { status: 500 });
+  }
+}

+ 141 - 0
src/components/shared-link-card.tsx

@@ -0,0 +1,141 @@
+"use client";
+
+import { useState } from "react";
+import { Link2, Copy, Check, Trash2, Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+interface SharedLinkCardProps {
+  publicToken: string;
+  organizationId: string;
+  type: "quote" | "invoice";
+  sharedAt: Date | null;
+  viewCount: number;
+  lastViewedAt: Date | null;
+  onRevoke?: () => Promise<void>;
+}
+
+export function SharedLinkCard({
+  publicToken,
+  organizationId,
+  type,
+  sharedAt,
+  viewCount,
+  lastViewedAt,
+  onRevoke,
+}: SharedLinkCardProps) {
+  const t = useTranslations(type === "quote" ? "quotes" : "service");
+  const [copied, setCopied] = useState(false);
+  const [revoking, setRevoking] = useState(false);
+
+  const publicUrl =
+    typeof window !== "undefined"
+      ? `${window.location.origin}/share/${type === "quote" ? "quote" : "invoice"}/${organizationId}/${publicToken}`
+      : "";
+
+  const handleCopy = async () => {
+    try {
+      await navigator.clipboard.writeText(publicUrl);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } catch {
+      // silent
+    }
+  };
+
+  const handleRevoke = async () => {
+    if (!onRevoke) return;
+    setRevoking(true);
+    try {
+      await onRevoke();
+    } finally {
+      setRevoking(false);
+    }
+  };
+
+  const hasViews = viewCount > 0;
+
+  return (
+    <div className="rounded-lg border p-3 space-y-2">
+      <div className="flex items-center gap-2">
+        <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
+        <h3 className="flex-1 text-sm font-semibold">{t("sidebar.sharedLink.title")}</h3>
+        {onRevoke && (
+          <button
+            type="button"
+            onClick={handleRevoke}
+            disabled={revoking}
+            className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
+          >
+            {revoking ? (
+              <Loader2 className="h-3.5 w-3.5 animate-spin" />
+            ) : (
+              <Trash2 className="h-3.5 w-3.5" />
+            )}
+          </button>
+        )}
+      </div>
+
+      {/* Public URL with copy */}
+      <div className="flex items-center gap-1.5">
+        <div className="min-w-0 flex-1 truncate rounded-md bg-muted/50 px-2.5 py-1.5 font-mono text-xs text-muted-foreground">
+          {publicUrl}
+        </div>
+        <button
+          type="button"
+          onClick={handleCopy}
+          className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+        >
+          {copied ? (
+            <Check className="h-3.5 w-3.5 text-emerald-500" />
+          ) : (
+            <Copy className="h-3.5 w-3.5" />
+          )}
+        </button>
+      </div>
+
+      {/* Shared date */}
+      {sharedAt && (
+        <p className="text-xs text-muted-foreground">
+          {t("sidebar.sharedLink.sharedOn", {
+            date: new Date(sharedAt).toLocaleDateString(undefined, {
+              month: "short",
+              day: "numeric",
+              year: "numeric",
+            }),
+          })}
+        </p>
+      )}
+
+      {/* View status */}
+      <div className="flex items-center gap-1.5">
+        <span
+          className={`inline-block h-2 w-2 rounded-full ${
+            hasViews ? "bg-emerald-500" : "bg-gray-300 dark:bg-gray-600"
+          }`}
+        />
+        {hasViews ? (
+          <span className="text-xs text-muted-foreground">
+            {t("sidebar.sharedLink.viewedTimes", { count: viewCount })}
+            {lastViewedAt && (
+              <>
+                {" · "}
+                {t("sidebar.sharedLink.lastViewed", {
+                  date: new Date(lastViewedAt).toLocaleDateString(undefined, {
+                    month: "short",
+                    day: "numeric",
+                    hour: "2-digit",
+                    minute: "2-digit",
+                  }),
+                })}
+              </>
+            )}
+          </span>
+        ) : (
+          <span className="text-xs text-muted-foreground">
+            {t("sidebar.sharedLink.notViewed")}
+          </span>
+        )}
+      </div>
+    </div>
+  );
+}

+ 2 - 2
src/features/quotes/Actions/quoteShareActions.ts

@@ -16,7 +16,7 @@ export async function generateQuotePublicLink(quoteId: string) {
     const token = randomUUID();
     await db.quote.update({
       where: { id: quoteId },
-      data: { publicToken: token },
+      data: { publicToken: token, sharedAt: new Date() },
     });
 
     revalidatePath(`/quotes/${quoteId}`);
@@ -33,7 +33,7 @@ export async function revokeQuotePublicLink(quoteId: string) {
 
     await db.quote.update({
       where: { id: quoteId },
-      data: { publicToken: null },
+      data: { publicToken: null, sharedAt: null, viewCount: 0, lastViewedAt: null },
     });
 
     revalidatePath(`/quotes/${quoteId}`);

+ 21 - 0
src/features/quotes/Components/QuotePageClient.tsx

@@ -32,6 +32,7 @@ import { useGlassModal } from "@/components/glass-modal";
 import { useConfirm } from "@/components/confirm-dialog";
 import { updateQuote, deleteQuote, convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
 import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
+import { revokeQuotePublicLink } from "@/features/quotes/Actions/quoteShareActions";
 import { sendQuoteEmail } from "@/features/email/Actions/emailActions";
 import { SendEmailDialog } from "@/features/email/Components/SendEmailDialog";
 import { QuoteShareDialog } from "@/features/quotes/Components/QuoteShareDialog";
@@ -59,6 +60,7 @@ import {
   Users,
   X,
 } from "lucide-react";
+import { SharedLinkCard } from "@/components/shared-link-card";
 import { formatCurrency, getCurrencySymbol } from "@/lib/format";
 
 const statusColors: Record<string, string> = {
@@ -118,6 +120,9 @@ interface QuoteRecord {
   notes: string | null;
   customerMessage: string | null;
   publicToken: string | null;
+  sharedAt: Date | null;
+  viewCount: number;
+  lastViewedAt: Date | null;
   convertedToId: string | null;
   inspectionId: string | null;
   createdAt: Date;
@@ -471,6 +476,22 @@ export function QuotePageClient({
         </div>
       )}
 
+      {/* Shared Link */}
+      {quote.publicToken && (
+        <SharedLinkCard
+          publicToken={quote.publicToken}
+          organizationId={organizationId}
+          type="quote"
+          sharedAt={quote.sharedAt}
+          viewCount={quote.viewCount}
+          lastViewedAt={quote.lastViewedAt}
+          onRevoke={async () => {
+            await revokeQuotePublicLink(quote.id);
+            router.refresh();
+          }}
+        />
+      )}
+
       {/* Vehicle & Customer */}
       <div className="rounded-lg border p-3 space-y-3">
         <div className="space-y-1">

+ 2 - 2
src/features/vehicles/Actions/serviceActions.ts

@@ -725,7 +725,7 @@ export async function generatePublicLink(serviceRecordId: string) {
     const token = randomUUID();
     await db.serviceRecord.update({
       where: { id: serviceRecordId },
-      data: { publicToken: token },
+      data: { publicToken: token, sharedAt: new Date() },
     });
 
     revalidatePath(`/vehicles/${record.vehicleId}/service/${serviceRecordId}`);
@@ -742,7 +742,7 @@ export async function revokePublicLink(serviceRecordId: string) {
 
     await db.serviceRecord.update({
       where: { id: serviceRecordId },
-      data: { publicToken: null },
+      data: { publicToken: null, sharedAt: null, viewCount: 0, lastViewedAt: null },
     });
 
     revalidatePath(`/vehicles/${record.vehicleId}/service/${serviceRecordId}`);

+ 3 - 0
src/features/vehicles/Components/service-detail/types.ts

@@ -41,6 +41,9 @@ export interface ServiceDetail {
   discountAmount: number;
   manuallyPaid: boolean;
   publicToken: string | null;
+  sharedAt: Date | null;
+  viewCount: number;
+  lastViewedAt: Date | null;
   partItems: PartItem[];
   laborItems: LaborItem[];
   attachments: Attachment[];

+ 16 - 1
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'
 import { toast } from 'sonner'
 import { useGlassModal } from '@/components/glass-modal'
 import { useConfirm } from '@/components/confirm-dialog'
-import { updateServiceRecord, deleteServiceRecord, deleteServiceAttachment, toggleManuallyPaid } from '@/features/vehicles/Actions/serviceActions'
+import { updateServiceRecord, deleteServiceRecord, deleteServiceAttachment, toggleManuallyPaid, revokePublicLink } from '@/features/vehicles/Actions/serviceActions'
 import { createPayment, deletePayment } from '@/features/payments/Actions/paymentActions'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
 import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
@@ -38,6 +38,7 @@ import { ServiceVideoManager } from '../service-video-manager'
 import { ServiceDocumentsManager } from '../service-documents-manager'
 
 import { UnifiedServiceHeader, type ServiceTab } from './UnifiedServiceHeader'
+import { SharedLinkCard } from '@/components/shared-link-card'
 import { useTranslations } from 'next-intl'
 
 interface Attachment {
@@ -452,6 +453,20 @@ export function ServicePageClient({
 
   const detailsRightColumn = (
     <div className="space-y-3">
+      {record.publicToken && (
+        <SharedLinkCard
+          publicToken={record.publicToken}
+          organizationId={organizationId}
+          type="invoice"
+          sharedAt={record.sharedAt}
+          viewCount={record.viewCount}
+          lastViewedAt={record.lastViewedAt}
+          onRevoke={async () => {
+            await revokePublicLink(record.id)
+            router.refresh()
+          }}
+        />
+      )}
       <BasicInfoSection
         initialData={initialData}
         vehicleId={vehicleId}