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>
})
9 changes: 9 additions & 0 deletions assets/js/liveview/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ if (csrfToken && websocketUrl) {
})
}
}
// 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
2 changes: 1 addition & 1 deletion extra/lib/plausible/installation_support/result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule Plausible.InstallationSupport.Result do
ok?: false,
data: nil,
errors: [error.message],
recommendations: [%{text: error.recommendation, url: error.url}]
recommendations: [%{text: error.recommendation, inline_links: error.inline_links}]

ok?: true,
data: %{},
Expand Down
43 changes: 43 additions & 0 deletions extra/lib/plausible/installation_support/verification/checks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ defmodule Plausible.InstallationSupport.Verification.Checks do

@verify_installation_check_timeout 20_000

# Local UI debugging only - set to one of the keys below to make every
# verification run return that canned interpretation, regardless of what
# the real check pipeline actually found. Handy for iterating on
# PlausibleWeb.Live.Verification's banner UI states. Must be `nil` on commit.
@debug_scenario nil

@debug_scenarios %{
0 => :success,
1 => %Verification.Diagnostics{},
2 => %Verification.Diagnostics{selected_installation_type: "wordpress"},
3 => %Verification.Diagnostics{
plausible_is_on_window: false,
plausible_is_initialized: false,
service_error: %{code: :domain_not_found}
},
4 => %Verification.Diagnostics{disallowed_by_csp: true}
}

@spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t()
def run(url, data_domain, installation_type, opts \\ []) do
# Timeout option for testing purposes
Expand Down Expand Up @@ -59,6 +77,7 @@ defmodule Plausible.InstallationSupport.Verification.Checks do
opts \\ []
) do
telemetry? = Keyword.get(opts, :telemetry?, true)
{diagnostics, url} = debug_override(diagnostics, data_domain, url)

result =
Verification.Diagnostics.interpret(
Expand Down Expand Up @@ -97,4 +116,28 @@ defmodule Plausible.InstallationSupport.Verification.Checks do

result
end

# Also overrides `url` to a clean, query-string-free one - otherwise the
# real check pipeline's cache-busting query param (?plausible_verification=...)
# leaks into canned error messages like "We couldn't find your website at ...".
defp debug_override(diagnostics, data_domain, url) do
case Map.get(@debug_scenarios, @debug_scenario) do
nil ->
{diagnostics, url}

:success ->
{
%Verification.Diagnostics{
test_event: %{
"normalizedBody" => %{"domain" => data_domain},
"responseStatus" => 200
}
},
"https://#{data_domain}"
}

%Verification.Diagnostics{} = debug ->
{debug, "https://#{data_domain}"}
end
end
end
Loading