Просмотр исходного кода

Show an example of Backups by Torqvoice on plans without them (#512)

* Show an example of Backups by Torqvoice on plans without them

The locked card was one line of text. It now shows the real card filled with example restore points, labelled Example, with a PRO pill and a See plans button in its header.

* Keep the example restore points in view on phones

Below sm the three figures stacked and pushed the rows out of the frame; show only the first there and give the frame more height.
Bernt Christian Egeland 2 дней назад
Родитель
Сommit
8cca06cd32

+ 1 - 0
messages/de/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Ihr Tarif enthält keine Sicherungen von Torqvoice mehr. Die Läufe wurden gestoppt; die Wiederherstellungspunkte werden bis {date} aufbewahrt und danach gelöscht.",
       "upsell": "Verfügbar mit Pro: stündliche Sicherungen durch Torqvoice mit Wiederherstellungspunkten, zu denen Sie zurückkehren können.",
       "planLink": "Tarife ansehen",
+      "example": "Beispiel",
       "pointsEmpty": "Die erste Sicherung ist noch nicht gelaufen.",
       "needsOwner": "Nur der Inhaber der Werkstatt kann wiederherstellen.",
       "listTitle": "Sicherungen",

+ 1 - 0
messages/en/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Your plan no longer includes backups by Torqvoice. Runs have stopped; the restore points are kept until {date} and then deleted.",
       "upsell": "Available on Pro: hourly backups run by Torqvoice with restore points you can go back to.",
       "planLink": "See plans",
+      "example": "Example",
       "pointsEmpty": "The first backup has not run yet.",
       "needsOwner": "Only the workshop owner can restore.",
       "listTitle": "Backups",

+ 1 - 0
messages/es/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Su plan ya no incluye las copias de seguridad de Torqvoice. Las ejecuciones se han detenido; los puntos de restauración se conservan hasta el {date} y después se eliminan.",
       "upsell": "Disponible en Pro: copias de seguridad cada hora hechas por Torqvoice, con puntos de restauración a los que puede volver.",
       "planLink": "Ver planes",
+      "example": "Ejemplo",
       "pointsEmpty": "La primera copia de seguridad aún no se ha hecho.",
       "needsOwner": "Solo el propietario del taller puede restaurar.",
       "listTitle": "Copias de seguridad",

+ 1 - 0
messages/fr/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Votre offre n'inclut plus les sauvegardes par Torqvoice. Les exécutions sont arrêtées ; les points de restauration sont conservés jusqu'au {date}, puis supprimés.",
       "upsell": "Disponible avec Pro : des sauvegardes horaires effectuées par Torqvoice, avec des points de restauration auxquels revenir.",
       "planLink": "Voir les offres",
+      "example": "Exemple",
       "pointsEmpty": "La première sauvegarde n'a pas encore eu lieu.",
       "needsOwner": "Seul le propriétaire de l'atelier peut restaurer.",
       "listTitle": "Sauvegardes",

+ 1 - 0
messages/it/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Il tuo piano non include più i backup di Torqvoice. Le esecuzioni si sono fermate; i punti di ripristino vengono conservati fino al {date} e poi eliminati.",
       "upsell": "Disponibile con Pro: backup orari eseguiti da Torqvoice, con punti di ripristino a cui puoi tornare.",
       "planLink": "Vedi i piani",
+      "example": "Esempio",
       "pointsEmpty": "Il primo backup non è ancora stato eseguito.",
       "needsOwner": "Solo il proprietario dell'officina può eseguire il ripristino.",
       "listTitle": "Backup",

+ 1 - 0
messages/lt/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Jūsų plane nebėra Torqvoice atsarginių kopijų. Vykdymai sustabdyti; atkūrimo taškai saugomi iki {date}, o tada ištrinami.",
       "upsell": "Prieinama su Pro planu: Torqvoice kas valandą kuriamos atsarginės kopijos su atkūrimo taškais, į kuriuos galite grįžti.",
       "planLink": "Peržiūrėti planus",
+      "example": "Pavyzdys",
       "pointsEmpty": "Pirmoji atsarginė kopija dar nesukurta.",
       "needsOwner": "Atkurti gali tik dirbtuvės savininkas.",
       "listTitle": "Atsarginės kopijos",

+ 1 - 0
messages/nb/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Planen din inkluderer ikke lenger sikkerhetskopier fra Torqvoice. Kjøringene er stoppet; gjenopprettingspunktene beholdes til {date} og slettes deretter.",
       "upsell": "Tilgjengelig med Pro: sikkerhetskopier hver time fra Torqvoice, med gjenopprettingspunkter du kan gå tilbake til.",
       "planLink": "Se planer",
+      "example": "Eksempel",
       "pointsEmpty": "Den første sikkerhetskopien har ikke kjørt ennå.",
       "needsOwner": "Bare verkstedets eier kan gjenopprette.",
       "listTitle": "Sikkerhetskopier",

+ 1 - 0
messages/nl/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Uw abonnement bevat geen back-ups door Torqvoice meer. De runs zijn gestopt; de herstelpunten worden bewaard tot {date} en daarna verwijderd.",
       "upsell": "Beschikbaar met Pro: back-ups elk uur door Torqvoice, met herstelpunten waarnaar u terug kunt gaan.",
       "planLink": "Abonnementen bekijken",
+      "example": "Voorbeeld",
       "pointsEmpty": "De eerste back-up is nog niet gemaakt.",
       "needsOwner": "Alleen de eigenaar van de werkplaats kan terugzetten.",
       "listTitle": "Back-ups",

+ 1 - 0
messages/pl/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Twój plan nie obejmuje już kopii zapasowych Torqvoice. Uruchomienia zostały wstrzymane; punkty przywracania są przechowywane do {date}, a potem zostaną usunięte.",
       "upsell": "Dostępne w planie Pro: cogodzinne kopie zapasowe wykonywane przez Torqvoice z punktami przywracania, do których możesz wrócić.",
       "planLink": "Zobacz plany",
+      "example": "Przykład",
       "pointsEmpty": "Pierwsza kopia zapasowa nie została jeszcze utworzona.",
       "needsOwner": "Tylko właściciel warsztatu może przywracać.",
       "listTitle": "Kopie zapasowe",

+ 1 - 0
messages/pt-BR/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Seu plano não inclui mais os backups do Torqvoice. As execuções pararam; os pontos de restauração ficam guardados até {date} e depois são excluídos.",
       "upsell": "Disponível no Pro: backups de hora em hora feitos pelo Torqvoice, com pontos de restauração para os quais você pode voltar.",
       "planLink": "Ver planos",
+      "example": "Exemplo",
       "pointsEmpty": "O primeiro backup ainda não foi feito.",
       "needsOwner": "Só o proprietário da oficina pode restaurar.",
       "listTitle": "Backups",

+ 1 - 0
messages/pt-PT/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "O seu plano já não inclui as cópias de segurança do Torqvoice. As execuções pararam; os pontos de restauro são guardados até {date} e depois eliminados.",
       "upsell": "Disponível no Pro: cópias de segurança de hora a hora feitas pelo Torqvoice, com pontos de restauro aos quais pode voltar.",
       "planLink": "Ver planos",
+      "example": "Exemplo",
       "pointsEmpty": "A primeira cópia de segurança ainda não foi feita.",
       "needsOwner": "Só o proprietário da oficina pode repor.",
       "listTitle": "Cópias de segurança",

+ 1 - 0
messages/ru/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Ваш тариф больше не включает резервные копии от Torqvoice. Запуски остановлены; точки восстановления хранятся до {date}, а затем удаляются.",
       "upsell": "Доступно в тарифе Pro: ежечасные резервные копии от Torqvoice с точками восстановления, к которым можно вернуться.",
       "planLink": "Посмотреть тарифы",
+      "example": "Пример",
       "pointsEmpty": "Первая резервная копия ещё не создавалась.",
       "needsOwner": "Восстановить может только владелец мастерской.",
       "listTitle": "Резервные копии",

+ 1 - 0
messages/tr/settings.json

@@ -1478,6 +1478,7 @@
       "paused": "Planınız artık Torqvoice yedeklerini içermiyor. Çalıştırmalar durduruldu; geri yükleme noktaları {date} tarihine kadar saklanır ve ardından silinir.",
       "upsell": "Pro'da mevcut: Torqvoice tarafından alınan saatlik yedekler ve geri dönebileceğiniz geri yükleme noktaları.",
       "planLink": "Planları görün",
+      "example": "Örnek",
       "pointsEmpty": "İlk yedekleme henüz çalışmadı.",
       "needsOwner": "Yalnızca atölyenin sahibi geri yükleyebilir.",
       "listTitle": "Yedekler",

+ 95 - 0
src/__tests__/features/integrations/platform-backups-locked.test.tsx

@@ -0,0 +1,95 @@
+/**
+ * Backups by Torqvoice on a plan without them. The card used to be one line
+ * of text, so a workshop deciding whether to pay could not see what it would
+ * get. It now shows an example of the real card, labelled as one so nobody
+ * takes it for their own backups, with the way to the plans in its header.
+ */
+import { render, screen, within } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { PlatformBackupsCard } from '@/features/integrations/Components/PlatformBackupsCard'
+import type { PlatformBackupOverview } from '@/features/integrations/Lib/backup-types'
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) }))
+vi.mock('@/features/integrations/Actions/getPlatformBackupStatus', () => ({
+  getPlatformBackupStatus: vi.fn(),
+}))
+vi.mock('@/features/integrations/Actions/runPlatformBackupNow', () => ({
+  runPlatformBackupNow: vi.fn(),
+}))
+vi.mock('@/features/integrations/Components/PlatformRestoreDialog', () => ({
+  PlatformRestoreDialog: () => null,
+}))
+vi.mock('@/features/integrations/Hooks/useBackupSnapshots', () => ({
+  useActionErrorText: () => (error?: string) => error ?? '',
+}))
+
+const NOW = new Date(Date.UTC(2026, 10, 7, 12, 0))
+
+const locked: PlatformBackupOverview = {
+  active: false,
+  paused: false,
+  purgeDueAt: null,
+  lastGoodAt: null,
+  nextRunAt: null,
+  repositorySize: null,
+  snapshots: [],
+  recent: [],
+  running: false,
+  restoring: false,
+  queuedAhead: 0,
+  canRestore: true,
+  workshopName: 'Test workshop',
+}
+
+function renderCard(overview: PlatformBackupOverview = locked) {
+  return render(<PlatformBackupsCard overview={overview} reauth="none" now={NOW} />)
+}
+
+describe('Backups by Torqvoice without a plan that includes them', () => {
+  it('says what Pro adds and links to the plans', () => {
+    renderCard()
+
+    expect(screen.getByText('PRO')).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: /See plans/ })).toHaveAttribute(
+      'href',
+      '/settings/subscription'
+    )
+    expect(
+      screen.getByText(
+        'Available on Pro: hourly backups run by Torqvoice with restore points you can go back to.'
+      )
+    ).toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: /Back up now/ })).not.toBeInTheDocument()
+  })
+
+  it('shows an example of the card, labelled as one', () => {
+    renderCard()
+
+    expect(screen.getByText('Example')).toBeInTheDocument()
+    const example = screen.getByTestId('platform-backups-example')
+    expect(within(example).getByText('32 minutes ago')).toBeInTheDocument()
+    expect(within(example).getByText('191.4 MB')).toBeInTheDocument()
+    expect(within(example).getAllByText('101.2 MB').length).toBeGreaterThan(1)
+    expect(within(example).getByText('Latest')).toBeInTheDocument()
+  })
+
+  it('keeps the example out of reach: nothing in it can be used or read aloud', () => {
+    renderCard()
+
+    const example = screen.getByTestId('platform-backups-example')
+    expect(example).toHaveAttribute('aria-hidden', 'true')
+    expect(example).toHaveAttribute('inert')
+    // Made-up restore points have no page to open.
+    expect(within(example).queryAllByRole('link', { hidden: true })).toHaveLength(0)
+  })
+
+  it('says until when the kept restore points stay once the plan has lapsed', () => {
+    renderCard({ ...locked, paused: true, purgeDueAt: '2026-12-07T12:00:00.000Z' })
+
+    expect(
+      screen.getByText(/the restore points are kept until December 7, 2026/)
+    ).toBeInTheDocument()
+    expect(screen.getByText('Example')).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: /See plans/ })).toBeInTheDocument()
+  })
+})

+ 14 - 0
src/__tests__/setup.ts

@@ -86,6 +86,20 @@ vi.mock('next-intl', () => ({
       new Intl.NumberFormat('en', options).format(value),
     dateTime: (value: Date | number, options?: Intl.DateTimeFormatOptions) =>
       new Intl.DateTimeFormat('en', options).format(value),
+    // The largest whole unit, the way next-intl picks one: "32 minutes ago".
+    relativeTime: (value: Date | number, now: Date | number = Date.now()) => {
+      const seconds = Math.round((Number(value) - Number(now)) / 1000)
+      const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
+      const units: [Intl.RelativeTimeFormatUnit, number][] = [
+        ['day', 86_400],
+        ['hour', 3_600],
+        ['minute', 60],
+      ]
+      for (const [unit, size] of units) {
+        if (Math.abs(seconds) >= size) return rtf.format(Math.round(seconds / size), unit)
+      }
+      return rtf.format(seconds, 'second')
+    },
   }),
   useMessages: () => ({}),
   NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,

+ 139 - 23
src/features/integrations/Components/PlatformBackupsCard.tsx

@@ -9,6 +9,7 @@ import {
   CheckCircle2,
   Clock,
   DatabaseBackup,
+  Eye,
   HardDrive,
   History,
   Loader2,
@@ -16,6 +17,7 @@ import {
   MinusCircle,
   RotateCcw,
   ShieldCheck,
+  Sparkles,
   XCircle,
 } from 'lucide-react'
 import { toast } from 'sonner'
@@ -41,6 +43,27 @@ const PLAN_HREF = '/settings/subscription'
 /** Backups the list shows at first, and adds each time it is scrolled to its end. */
 const PAGE_SIZE = 20
 
+/** The example a workshop without backups sees: hourly points of about 101 MB. */
+const EXAMPLE_POINTS = 8
+const EXAMPLE_POINT_BYTES = 106_115_891
+const EXAMPLE_REPOSITORY_BYTES = 200_697_446
+/** How long before the page was opened the example's newest backup ran. */
+const EXAMPLE_LAST_RUN_MS = 32 * 60_000
+const HOUR_MS = 60 * 60_000
+
+/** Hourly example restore points, newest first, ending a little before `now`. */
+function examplePoints(now: Date): PlatformSnapshotView[] {
+  const newest = now.getTime() - EXAMPLE_LAST_RUN_MS
+  return Array.from({ length: EXAMPLE_POINTS }, (_, i) => ({
+    id: `example-${i}`,
+    shortId: `example${i}`,
+    time: new Date(newest - i * HOUR_MS).toISOString(),
+    bytes: EXAMPLE_POINT_BYTES,
+    trigger: 'scheduled',
+    preRestore: false,
+  }))
+}
+
 /**
  * One line of the list: a restore point, with the run that took it when that
  * run is among the last ten, or a run that left no restore point behind (one
@@ -87,8 +110,9 @@ function backupEntries(overview: PlatformBackupOverview): BackupEntry[] {
  * backups by day, each restore point with its Explore and Restore actions
  * and the runs that left none (in progress, failed, or a restore) in their
  * place. The list shows twenty and adds twenty more each time it is scrolled
- * to its end. On a plan without them it says so, and while a paused
- * repository waits out its grace period it says until when. While a backup
+ * to its end. On a plan without them it shows a labelled example of the
+ * card with a way to the plans, and while a paused repository waits out its
+ * grace period it says until when. While a backup
  * or a restore is in progress the card asks for its own data every ten
  * seconds, which works during a restore when the rest of the page's data is
  * refused, and refreshes the page once the run is done.
@@ -104,6 +128,7 @@ export function PlatformBackupsCard({
   now: Date
 }) {
   const t = useTranslations('settings.data.platformBackups')
+  const tNav = useTranslations('settings.nav')
   const format = useFormatter()
   const router = useRouter()
   const errorText = useActionErrorText()
@@ -145,30 +170,39 @@ export function PlatformBackupsCard({
   const when = (iso: string) =>
     format.dateTime(new Date(iso), { dateStyle: 'medium', timeStyle: 'short' })
 
-  const planLink = (
-    <Link href={PLAN_HREF} className="font-medium text-primary underline-offset-2 hover:underline">
-      {t('planLink')}
-    </Link>
-  )
-
   if (!overview.active) {
     return (
       <AppCard
         id="platform-backups"
         icon={DatabaseBackup}
         title={t('title')}
+        badge={tNav('pro')}
         description={t('description')}
+        action={
+          <Button size="sm" asChild>
+            <Link href={PLAN_HREF}>
+              <Sparkles className="mr-1 h-3.5 w-3.5" />
+              {t('planLink')}
+            </Link>
+          </Button>
+        }
+        contentClassName="pb-0"
+        footer={
+          <p className="flex items-start gap-2 text-sm">
+            <Lock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
+            <span>
+              {overview.paused
+                ? t('paused', {
+                    date: overview.purgeDueAt
+                      ? format.dateTime(new Date(overview.purgeDueAt), { dateStyle: 'long' })
+                      : '-',
+                  })
+                : t('upsell')}
+            </span>
+          </p>
+        }
       >
-        <p className="text-sm text-muted-foreground">
-          {overview.paused
-            ? t('paused', {
-                date: overview.purgeDueAt
-                  ? format.dateTime(new Date(overview.purgeDueAt), { dateStyle: 'long' })
-                  : '-',
-              })
-            : t('upsell')}{' '}
-          {planLink}
-        </p>
+        <ExamplePreview now={now} />
       </AppCard>
     )
   }
@@ -266,15 +300,22 @@ function Stat({
   icon: Icon,
   label,
   tone = 'muted',
+  className,
   children,
 }: {
   icon: ComponentType<{ className?: string }>
   label: string
   tone?: Tone
+  className?: string
   children: ReactNode
 }) {
   return (
-    <div className="flex items-start gap-3 rounded-lg border border-card-edge/70 bg-muted/30 px-3.5 py-3">
+    <div
+      className={cn(
+        'flex items-start gap-3 rounded-lg border border-card-edge/70 bg-muted/30 px-3.5 py-3',
+        className
+      )}
+    >
       <div
         className={cn(
           'flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset',
@@ -295,6 +336,68 @@ function Stat({
   )
 }
 
+/**
+ * What the card looks like on a plan with backups, filled with made-up
+ * hourly restore points, so a workshop deciding whether to pay sees what it
+ * would get. Sharp and readable, labelled as an example so nobody takes it
+ * for the workshop's own backups, dimmed a little and faded out at the
+ * bottom. Inert: nothing in it can be clicked, tabbed to or read aloud.
+ * On a phone the three figures would stack and push the restore points out
+ * of the frame, so only the first is shown there.
+ */
+function ExamplePreview({ now }: { now: Date }) {
+  const t = useTranslations('settings.data.platformBackups')
+  const format = useFormatter()
+  const points = examplePoints(now)
+  const entries: BackupEntry[] = points.map((point) => ({
+    type: 'point',
+    key: `point:${point.id}`,
+    time: point.time,
+    point,
+  }))
+  const lastRun = new Date(points[0].time)
+
+  return (
+    <div className="relative">
+      <span className="absolute top-2 right-2 z-10 inline-flex items-center gap-1.5 rounded-full bg-card px-2.5 py-1 text-xs font-semibold text-primary shadow-sm ring-1 ring-inset ring-primary/30">
+        <Eye className="h-3.5 w-3.5" />
+        {t('example')}
+      </span>
+      <div
+        aria-hidden
+        inert
+        data-testid="platform-backups-example"
+        className="pointer-events-none max-h-96 select-none space-y-4 overflow-hidden opacity-75 [mask-image:linear-gradient(to_bottom,black_40%,transparent)] sm:max-h-80"
+      >
+        <dl className="grid gap-3 sm:grid-cols-3">
+          <Stat icon={CheckCircle2} tone="good" label={t('lastGood')}>
+            <span suppressHydrationWarning>{format.relativeTime(lastRun, now)}</span>
+          </Stat>
+          <Stat icon={CalendarClock} label={t('nextRun')} className="hidden sm:flex">
+            {format.dateTime(new Date(lastRun.getTime() + HOUR_MS), {
+              dateStyle: 'medium',
+              timeStyle: 'short',
+            })}
+          </Stat>
+          <Stat icon={HardDrive} label={t('repositorySize')} className="hidden sm:flex">
+            {formatBytes(EXAMPLE_REPOSITORY_BYTES)}
+          </Stat>
+        </dl>
+        <BackupList
+          entries={entries}
+          snapshots={points}
+          pointCount={points.length}
+          canRestore
+          mayRestore
+          now={now}
+          onRestore={() => undefined}
+          example
+        />
+      </div>
+    </div>
+  )
+}
+
 /**
  * The backups, grouped by day under a header that stays in view while the
  * day scrolls past. Scrolls inside its own frame so the rest of Settings,
@@ -309,6 +412,7 @@ function BackupList({
   mayRestore,
   now,
   onRestore,
+  example = false,
 }: {
   entries: BackupEntry[]
   /** Every restore point, to name the one a restore went back to. */
@@ -318,6 +422,8 @@ function BackupList({
   mayRestore: boolean
   now: Date
   onRestore: (point: PlatformSnapshotView) => void
+  /** Made-up points for the preview: nothing links to a restore point page. */
+  example?: boolean
 }) {
   const t = useTranslations('settings.data.platformBackups')
   const format = useFormatter()
@@ -432,6 +538,7 @@ function BackupList({
                         latest={entry.point.id === latestId}
                         mayRestore={mayRestore}
                         onRestore={onRestore}
+                        example={example}
                       />
                     ) : (
                       <RunRow key={entry.key} run={entry.run} snapshots={snapshots} now={now} />
@@ -529,11 +636,14 @@ function PointRow({
   latest,
   mayRestore,
   onRestore,
+  example = false,
 }: {
   point: PlatformSnapshotView
   latest: boolean
   mayRestore: boolean
   onRestore: (point: PlatformSnapshotView) => void
+  /** A made-up point: Explore has no page to open, so it is a plain button. */
+  example?: boolean
 }) {
   const t = useTranslations('settings.data.platformBackups')
   const format = useFormatter()
@@ -576,11 +686,17 @@ function PointRow({
       )}
       <div className="flex w-full shrink-0 gap-1.5 pl-10 sm:w-auto sm:pl-0">
         {/* Open to look inside; the page refuses anyone but an admin. */}
-        <Button variant="ghost" size="sm" className="h-7 px-2.5 text-xs" asChild>
-          <Link href={`/settings/data/restore-points/${point.id}?source=platform`}>
+        {example ? (
+          <Button variant="ghost" size="sm" className="h-7 px-2.5 text-xs" tabIndex={-1}>
             {t('explore')}
-          </Link>
-        </Button>
+          </Button>
+        ) : (
+          <Button variant="ghost" size="sm" className="h-7 px-2.5 text-xs" asChild>
+            <Link href={`/settings/data/restore-points/${point.id}?source=platform`}>
+              {t('explore')}
+            </Link>
+          </Button>
+        )}
         <Button
           variant="outline"
           size="sm"