Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions assets/js/dashboard/email-reports-cta-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import React, { useEffect, useRef, useState } from 'react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { useSiteContext } from './site-context'
import { useCurrentVisitorsContext } from './current-visitors-context'

type CTAStorageState = 'pending' | 'visible'

function getStorageKey(domain: string) {
return `email_reports_cta_${domain}`
}

// CTA for configuring weekly email reports
//
// Renders only once, as soon as the first pageview lands. This can happen:
//
// 1. Automatically, when the dashboard stays open -- relying on the value
// of current-visitors changing to something other than 0.
//
// 2. Dashboard is refreshed and showing data for the very first time.
//
// Case 2 is the tricky one. By the time of the refresh, `site.statsBegin`
// is already set, so that value alone can't distinguish "stats just
// started" from "this site has always had stats".
//
// The sessionStorage entry closes that gap -- it's stamped 'pending' the
// moment stats are still absent, so a later reload can still recognize the
// transition. It is only ever stamped while stats are absent, so established
// sites never pick it up and can't retrigger the CTA.
//
// Once shown, the same entry is stamped 'visible', so a refresh mid-display
// resumes the CTA instead of re-deciding from scratch -- but only for three
// seconds -- past that, the sessionStorage entry clears itself out and a
// refresh won't bring the CTA back.
export function EmailReportsCTABanner() {
const site = useSiteContext()
const currentVisitors = useCurrentVisitorsContext()
const hasStats = !!site.statsBegin
const storageKey = getStorageKey(site.domain)

const hasTriggeredRef = useRef(false)
const [visible, setVisible] = useState(false)

useEffect(() => {
if (!hasStats && sessionStorage.getItem(storageKey) !== 'visible') {
const state: CTAStorageState = 'pending'
sessionStorage.setItem(storageKey, state)
}
}, [hasStats, storageKey])

useEffect(() => {
if (hasTriggeredRef.current) {
return
}

const storedState = sessionStorage.getItem(storageKey)

if (storedState === 'visible') {
hasTriggeredRef.current = true
setVisible(true)
return
}

const firstPageviewJustLanded = hasStats
? storedState === 'pending'
: !!currentVisitors

if (!firstPageviewJustLanded) {
return
}

hasTriggeredRef.current = true
const state: CTAStorageState = 'visible'
sessionStorage.setItem(storageKey, state)
setVisible(true)
}, [hasStats, currentVisitors, storageKey])

useEffect(() => {
if (!visible) {
return
}

const timeout = setTimeout(() => {
sessionStorage.removeItem(storageKey)
}, 3000)

return () => clearTimeout(timeout)
}, [visible, storageKey])

if (!visible) {
return null
}

function dismiss() {
sessionStorage.removeItem(storageKey)
setVisible(false)
}

return (
<div
role="alert"
className="text-md relative mb-4 rounded-md bg-indigo-100/60 p-4 text-center font-medium dark:bg-indigo-900/40"
>
<button
type="button"
aria-label="Dismiss"
className="absolute right-2 top-2 z-10 rounded p-1 text-gray-800 hover:text-gray-600 dark:text-gray-100/60 dark:hover:text-gray-100/70"
onClick={dismiss}
>
<XMarkIcon className="size-4" />
</button>
<span className="mr-1 text-base">🎉</span>
<span className="text-gray-900 dark:text-gray-100">
Your first pageview has landed!
</span>{' '}
<a
className="plausible-event-name=Weekly+Email+Note+Click text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-400 transition-colors duration-150"
href={`/${encodeURIComponent(site.domain)}/settings/email-reports`}
onClick={dismiss}
>
Get weekly traffic reports by email →
</a>
</div>
)
}
8 changes: 7 additions & 1 deletion assets/js/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { isRealTimeDashboard } from './util/filters'
import { GraphIntervalProvider } from './stats/graph/graph-interval-context'
import { ImportsIncludedProvider } from './stats/graph/imports-included-context'
import { CurrentVisitorsProvider } from './current-visitors-context'
import { VerificationLiveViewPortal } from './verification/portal'
import { EmailReportsCTABanner } from './email-reports-cta-banner'

function DashboardStats({
importedDataInView,
Expand All @@ -21,7 +23,11 @@ function DashboardStats({
}) {
return (
<>
<VisitorGraph updateImportedDataInView={updateImportedDataInView} />
<div className="col-span-full">
<EmailReportsCTABanner />
<VerificationLiveViewPortal />
<VisitorGraph updateImportedDataInView={updateImportedDataInView} />
</div>
<Sources />
<Pages />
<Locations />
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/graph/visitor-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export default function VisitorGraph({
!showFullLoader

return (
<div className="col-span-full relative w-full bg-white rounded-md shadow-sm dark:bg-gray-900">
<div className="relative w-full bg-white rounded-md shadow-sm dark:bg-gray-900">
<>
<div
id="top-stats-container"
Expand Down
75 changes: 75 additions & 0 deletions assets/js/dashboard/verification/portal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React from 'react'
import { act, render, screen } from '@testing-library/react'
import { useLocation } from 'react-router-dom'
import { TestContextProviders } from '../../../test-utils/app-context-providers'
import {
VERIFICATION_FINISHED_EVENT,
VerificationLiveViewPortal
} from './portal'

function LocationDisplay() {
const location = useLocation()
return <div data-testid="location">{location.pathname + location.search}</div>
}

function renderWithInitialEntry(initialEntry: string) {
render(
<>
<VerificationLiveViewPortal />
<LocationDisplay />
</>,
{
wrapper: (props) => (
<TestContextProviders
siteOptions={{ domain: 'some-domain' }}
routerProps={{ initialEntries: [initialEntry] }}
{...props}
/>
)
}
)
}

function dispatchVerificationFinished(queryParams: string[]) {
act(() => {
window.dispatchEvent(
new CustomEvent(VERIFICATION_FINISHED_EVENT, {
detail: { queryParams }
})
)
})
}

test('drops exactly the query params named in the event detail, leaving every other param untouched', () => {
renderWithInitialEntry(
'/some-domain?f=contains,os,a&f=contains,page,/&verify_installation=true&flow=provisioning&comparison=year_over_year'
)

dispatchVerificationFinished(['verify_installation', 'flow'])

expect(screen.getByTestId('location').textContent).toBe(
'/?f=contains,os,a&f=contains,page,/&comparison=year_over_year'
)
})

test('does nothing when none of the named params are present', () => {
renderWithInitialEntry('/some-domain?comparison=year_over_year')

dispatchVerificationFinished(['verify_installation', 'flow'])

expect(screen.getByTestId('location').textContent).toBe(
'/?comparison=year_over_year'
)
})

test('drops only verify_installation, keeping a real param that happens to be a prefix of it', () => {
renderWithInitialEntry(
'/some-domain?verify_installation=true&verify_installation_extra=keep-me'
)

dispatchVerificationFinished(['verify_installation'])

expect(screen.getByTestId('location').textContent).toBe(
'/?verify_installation_extra=keep-me'
)
})
50 changes: 50 additions & 0 deletions assets/js/dashboard/verification/portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { useEffect } from 'react'
import { useAppNavigate } from '../navigation/use-app-navigate'

type VerificationFinishedDetail = {
/**
* Exact query param names to drop from the URL when verification banner
* disappears. See: PlausibleWeb.Live.Components.VerificationBanner.query_params/0
*/
queryParams: string[]
}

export const VERIFICATION_FINISHED_EVENT = 'verification-finished'

/**
* Renders the portal target into which the verification LiveView (see
* lib/plausible_web/live/components/verification.ex) gets teleported.
* Also helps that LiveView out with cleaning up after itself: clearing
* its one-time query params through React Router.
*/
export const VerificationLiveViewPortal = React.memo(() => {
const navigate = useAppNavigate()

useEffect(() => {
function handleVerificationFinished(event: Event) {
const { queryParams } = (event as CustomEvent<VerificationFinishedDetail>)
.detail

navigate({
search: (search) => {
const nextSearch = { ...search }
queryParams.forEach((param) => delete nextSearch[param])
return nextSearch
}
})
}

window.addEventListener(
VERIFICATION_FINISHED_EVENT,
handleVerificationFinished
)

return () =>
window.removeEventListener(
VERIFICATION_FINISHED_EVENT,
handleVerificationFinished
)
}, [navigate])

return <div id="verification-portal-target"></div>
})
11 changes: 11 additions & 0 deletions assets/js/liveview/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ let csrfToken = document.querySelector("meta[name='csrf-token']")
let websocketUrl = document.querySelector("meta[name='websocket-url']")
if (csrfToken && websocketUrl) {
let Hooks = { Modal, Dropdown }

// Lets a LiveView tell the client to tear down the websocket connection
// once it's done with it (e.g. PlausibleWeb.Live.Verification, once its
// banner has been dismissed) - the server-side process then terminates
// gracefully.
Hooks.DisconnectSocket = {
mounted() {
this.handleEvent('disconnect-liveview', () => liveSocket.disconnect())
}
}

let Uploaders = {}
Uploaders.S3 = function (entries, onViewError) {
entries.forEach((entry) => {
Expand Down
48 changes: 48 additions & 0 deletions e2e/tests/dashboard/verification.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test'
import { setupSite, setVerificationScenario } from '../fixtures'
import { expectLiveViewConnected } from '../test-utils'

const VERIFICATION_BANNER_SELECTOR = '#verification-ui'
const PROGRESS_MSG_SELECTOR = '#progress'

const SUCCESS_MESSAGE = 'Tracking is active on your site'
const LOADING_STATE_TITLE = 'Verifying your installation'
const LOADING_STATE_CYCLED_MESSAGES = [
/We're visiting your site to ensure that everything is working/,
/We're trying to reach your website/,
/We're verifying that your visitors are being counted correctly/
]

test('verification success', async ({ page, request }) => {
const { domain } = await setupSite({ page, request })

await setVerificationScenario({
request,
domain,
scenario: 'success',
options: { slowdown: 500 }
})

await page.goto(`/${domain}?verify_installation=true`, { waitUntil: 'commit' })
await expectLiveViewConnected(page)

const banner = page.locator(VERIFICATION_BANNER_SELECTOR)
const progress = banner.locator(PROGRESS_MSG_SELECTOR)

await expect(banner).toContainText(LOADING_STATE_TITLE)

for (const msg of LOADING_STATE_CYCLED_MESSAGES) {
await expect(progress).toHaveText(msg)
}

await expect(banner).toContainText(SUCCESS_MESSAGE)

await banner.getByRole('button', { name: 'Dismiss' }).click()

await expect(banner).toBeHidden()
await expect(page).not.toHaveURL(/verify_installation/)

await page.reload({ waitUntil: 'commit' })

await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toBeHidden()
})
22 changes: 22 additions & 0 deletions e2e/tests/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,28 @@ export async function populateStats({
expect(response.ok()).toBeTruthy()
}

export async function setVerificationScenario({
request,
domain,
scenario,
options
}: {
request: APIRequestContext
domain: string
scenario: string
options?: { slowdown?: number }
}) {
const response = await request.put('/e2e-tests/verification', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
data: { domain: domain, scenario: scenario, options: options }
})

expect(response.ok()).toBeTruthy()
}

export async function addGoal({
request,
domain,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ defmodule Plausible.InstallationSupport do
def user_agent() do
"Plausible Verification Agent - if abused, contact support@plausible.io"
end

def verification_checks_mod do
if Mix.env() in [:dev, :e2e_test] do
Plausible.InstallationSupport.Verification.ChecksMock
else
Plausible.InstallationSupport.Verification.Checks
end
end
else
def user_agent() do
"Plausible Community Edition"
Expand Down
Loading
Loading