VehiclePageLayoutSwitch.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. 'use client'
  2. import { useEffect, useState } from 'react'
  3. import { useRouter } from 'next/navigation'
  4. import { useTranslations } from 'next-intl'
  5. import { Sparkles, X } from 'lucide-react'
  6. import { Button } from '@/components/ui/button'
  7. import { track } from '@/lib/analytics'
  8. import { rememberVehiclePageLayout, type VehiclePageLayout } from '@/lib/vehicle-page-layout'
  9. const DISMISSED_KEY = 'torqvoice:vehiclePageInviteDismissed'
  10. /** Set when somebody moves to the new page, until they have been shown the way back. */
  11. const BACK_HINT_KEY = 'torqvoice:vehiclePageBackHint'
  12. /**
  13. * Moves this browser to the other vehicle page: the choice goes in a cookie
  14. * the server reads, and the page renders again in that layout.
  15. */
  16. export function useSwitchVehiclePageLayout() {
  17. const router = useRouter()
  18. return (layout: VehiclePageLayout, source: 'invite' | 'menu') => {
  19. rememberVehiclePageLayout(layout)
  20. // Whoever tries the new page is shown where the way back is, once.
  21. try {
  22. if (layout === 'modern') localStorage.setItem(BACK_HINT_KEY, 'true')
  23. else localStorage.removeItem(BACK_HINT_KEY)
  24. } catch {
  25. // Private mode: no pointer, and the menu item is still there.
  26. }
  27. track('vehicle_page:layout_switch', { to_layout: layout, source })
  28. router.refresh()
  29. }
  30. }
  31. /**
  32. * Counts a vehicle page opened, in the layout it was drawn in. Draws nothing.
  33. *
  34. * This is what says when the classic page can go: once the views with
  35. * `layout: classic` have dried up, nobody is left on it. `layout_chosen`
  36. * tells a workshop that went back to classic on purpose from one that is
  37. * there only because it predates the new page and never tried it.
  38. *
  39. * One event per vehicle and layout: moving between the page's lists renders
  40. * the page again without counting another visit, and switching layout counts
  41. * the view of the page it switched to.
  42. */
  43. export function VehiclePageViewed({
  44. vehicleId,
  45. layout,
  46. chosen,
  47. }: {
  48. vehicleId: string
  49. layout: VehiclePageLayout
  50. chosen: boolean
  51. }) {
  52. // The vehicle is why the effect runs again, not something to send.
  53. useEffect(() => {
  54. track('vehicle_page:page_view', { layout, layout_chosen: chosen })
  55. }, [vehicleId, layout, chosen])
  56. return null
  57. }
  58. /**
  59. * The invitation on the classic vehicle page. Hiding it is remembered in this
  60. * browser; the way in stays in the page's menu for whoever changes their mind.
  61. *
  62. * Drawn only after mount: whether it was hidden is in localStorage, which the
  63. * server cannot see, and a banner that rendered and then vanished on
  64. * hydration would push the page down and back.
  65. */
  66. export function TryNewVehiclePageBanner() {
  67. const t = useTranslations('vehicles.detail.layout')
  68. const switchLayout = useSwitchVehiclePageLayout()
  69. const [visible, setVisible] = useState(false)
  70. useEffect(() => {
  71. try {
  72. setVisible(localStorage.getItem(DISMISSED_KEY) !== 'true')
  73. } catch {
  74. setVisible(true)
  75. }
  76. }, [])
  77. if (!visible) return null
  78. const hide = () => {
  79. setVisible(false)
  80. track('vehicle_page:layout_invite_dismiss')
  81. try {
  82. localStorage.setItem(DISMISSED_KEY, 'true')
  83. } catch {
  84. // Private mode: it comes back next visit, which is no harm.
  85. }
  86. }
  87. return (
  88. <div
  89. data-testid="try-new-vehicle-page"
  90. className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-primary/25 bg-primary/10 px-4 py-2.5"
  91. >
  92. <Sparkles className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
  93. <p className="min-w-0 flex-1 text-sm">
  94. <span className="font-medium">{t('inviteTitle')}</span>{' '}
  95. <span className="text-muted-foreground">{t('inviteBody')}</span>
  96. </p>
  97. <Button
  98. type="button"
  99. size="sm"
  100. className="h-8"
  101. onClick={() => switchLayout('modern', 'invite')}
  102. >
  103. {t('tryIt')}
  104. </Button>
  105. <button
  106. type="button"
  107. onClick={hide}
  108. aria-label={t('hideInvite')}
  109. title={t('hideInvite')}
  110. className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground"
  111. >
  112. <X className="h-4 w-4" />
  113. </button>
  114. </div>
  115. )
  116. }
  117. /**
  118. * Whether to point at the way back to the classic page: only on the first
  119. * visit after somebody chose the new one, never for a workshop that started
  120. * on it. Read after mount, as the invitation's flag is.
  121. */
  122. export function useVehiclePageBackHint() {
  123. const [show, setShow] = useState(false)
  124. useEffect(() => {
  125. try {
  126. setShow(localStorage.getItem(BACK_HINT_KEY) === 'true')
  127. } catch {
  128. setShow(false)
  129. }
  130. }, [])
  131. const dismiss = () => {
  132. setShow(false)
  133. try {
  134. localStorage.removeItem(BACK_HINT_KEY)
  135. } catch {
  136. // Nothing to clear.
  137. }
  138. }
  139. return { show, dismiss }
  140. }