Skip to content
Merged
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
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"common": {
"sealFeedbackButtonLabel": "Got feedback?",
"sealFeedbackButtonAriaLabel": "Share feedback on the Seal of Reliability (opens in a new tab)",
"copyToClipboard": "Copy to clipboard",
"copied": "Copied!",
"name": "Name",
Expand Down
2 changes: 2 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"common": {
"sealFeedbackButtonLabel": "Des commentaires ?",
"sealFeedbackButtonAriaLabel": "Donnez votre avis sur le Sceau de fiabilité (ouvre un nouvel onglet)",
"copyToClipboard": "Copier dans le presse-papiers",
"copied": "Copié!",
"name": "Nom",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { getTranslations } from 'next-intl/server';
import CardSectionTitle from '../../../components/CardSectionTitle';
import SectionContainer from '../../../components/SectionContainer';
import SealOfReliability from '../../../components/SealOfReliability';
import SealFeedbackButton from '../../../components/SealFeedbackButton';
import { accordionStyle } from '../../../components/accordionStyle';
import { Link as LocaleLink } from '../../../../i18n/navigation';
import {
Expand All @@ -49,6 +50,8 @@ export default async function SealOfReliabilityDescriptionPage(): Promise<ReactE
}}
maxWidth='lg'
>
<SealFeedbackButton />

<SectionContainer sx={{ mt: 0 }} maxWidth='lg'>
<Container
maxWidth='lg'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import SectionContainer from '../../../../components/SectionContainer';
import CardSectionTitle from '../../../../components/CardSectionTitle';
import BreadcrumbNavigation from '../../../../components/BreadcrumbNavigation';
import SealOfReliability from '../../../../components/SealOfReliability';
import SealFeedbackButton from '../../../../components/SealFeedbackButton';
import {
GTFS_VALIDATOR_URL,
clockStartEntries,
Expand Down Expand Up @@ -109,6 +110,8 @@ export default async function HowItIsCalculatedPage(): Promise<ReactElement> {

return (
<Container component='main' sx={{ width: '100%', m: 'auto' }} maxWidth='lg'>
<SealFeedbackButton />

<BreadcrumbNavigation
crumbs={[
{ label: t('eyebrow'), href: '/seal-of-reliability' },
Expand Down
104 changes: 104 additions & 0 deletions src/app/components/SealFeedbackButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use client';

import { useEffect, useMemo, useState, type ReactElement } from 'react';
import Button from '@mui/material/Button';
import Fab from '@mui/material/Fab';
import FeedbackOutlinedIcon from '@mui/icons-material/FeedbackOutlined';
import { useTranslations } from 'next-intl';
import { useAppSelector } from '../hooks';
import { selectUserProfile } from '../store/profile-selectors';
import { useAuthSession } from './AuthSessionProvider';
import {
buildSealFeedbackUrl,
SEAL_FEEDBACK_URL,
} from '../utils/seal-feedback-url';

/**
*
* The user's name and email are prefilled into the form when the app knows
* them. Both sources are read because they settle at different times: the
* Redux profile is the richer one (it carries `fullName` from registration)
* but only after redux-persist rehydrates, while the Firebase session
* resolves on its own schedule. Signed-out and anonymous visitors simply get
* an unprefilled form - the link is never gated on identity.
*
*/
export default function SealFeedbackButton(): ReactElement {
const t = useTranslations('common');
const user = useAppSelector(selectUserProfile);
const {
email: sessionEmail,
displayName,
isAuthenticated,
} = useAuthSession();

const name = isAuthenticated ? user?.fullName?.trim() || displayName : null;
const email = isAuthenticated ? user?.email?.trim() || sessionEmail : null;

/**
* Who the visitor is exists only on the client, so prefilling during the
* first render would make it disagree with the server HTML. React 19 does
* not patch a mismatched attribute - it keeps the server's value and logs a
* hydration error - which meant the prefilled href never reached the DOM at
* all. Holding the prefill until after mount turns it into an ordinary
* update, which does reach the DOM.
*/
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);

// Before mount the link still points at the form, just without the
// prefill, so it works for anyone who clicks during hydration.
const feedbackUrl = useMemo(
() =>
isMounted ? buildSealFeedbackUrl({ name, email }) : SEAL_FEEDBACK_URL,
[isMounted, name, email],
Comment on lines +53 to +56
);

return (
<>
<Button
variant='contained'
disableElevation
href={feedbackUrl}
target='_blank'
rel='noreferrer'
data-testid='seal-feedback-button'
aria-label={t('sealFeedbackButtonAriaLabel')}
sx={{
display: { xs: 'none', lg: 'inline-flex' },
position: 'fixed',
right: '-64px',
top: 0,
bottom: 0,
height: 'fit-content',
marginTop: 'auto',
marginBottom: 'auto',
zIndex: (theme) => theme.zIndex.appBar - 1,
transform: 'rotate(270deg)',
}}
startIcon={<FeedbackOutlinedIcon fontSize='small' aria-hidden />}
>
{t('sealFeedbackButtonLabel')}
</Button>
<Fab
color='primary'
href={feedbackUrl}
target='_blank'
rel='noreferrer'
data-testid='seal-feedback-fab'
aria-label={t('sealFeedbackButtonAriaLabel')}
sx={{
display: { xs: 'flex', lg: 'none' },
position: 'fixed',
bottom: 16,
right: 16,
zIndex: (theme) => theme.zIndex.appBar - 1,
}}
>
<FeedbackOutlinedIcon />
</Fab>
</>
);
}
2 changes: 2 additions & 0 deletions src/app/screens/Feed/components/FeedReliabilityView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { getCoverageWindowLength } from '../lib/continuous-coverage';
import { getLatestCoverageWindow } from '../lib/fresh-coverage';
import { displayFormattedDate } from '../../../utils/date';
import SectionContainer from '../../../components/SectionContainer';
import SealFeedbackButton from '../../../components/SealFeedbackButton';

interface Props {
feed: AllFeedType;
Expand Down Expand Up @@ -102,6 +103,7 @@ export default async function FeedReliabilityView({
>
<ScrollToTop />
<CssBaseline />
<SealFeedbackButton />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<SectionContainer maxWidth='xl'>
<Box sx={{ position: 'relative' }}>
Expand Down
48 changes: 48 additions & 0 deletions src/app/utils/seal-feedback-url.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { buildSealFeedbackUrl, SEAL_FEEDBACK_URL } from './seal-feedback-url';

describe('buildSealFeedbackUrl', () => {
it('returns the bare form URL when nothing is known about the user', () => {
const url = buildSealFeedbackUrl();

expect(url).toBe('https://share.mobilitydata.org/sealfeedback');
expect(url).not.toContain('usp=pp_url');
expect(url).not.toContain('entry.');
});

it('exposes the same bare URL the server renders before hydration', () => {
expect(SEAL_FEEDBACK_URL).toBe(buildSealFeedbackUrl());
});

it('prefills both name and email when both are available', () => {
const url = new URL(
buildSealFeedbackUrl({
name: 'Ada Lovelace',
email: 'ada@example.org',
}),
);

expect(url.searchParams.get('usp')).toBe('pp_url');
expect(url.searchParams.get('entry.1481076408')).toBe('Ada Lovelace');
expect(url.searchParams.get('entry.1610387831')).toBe('ada@example.org');
});

it('omits the fields it does not have', () => {
const url = new URL(buildSealFeedbackUrl({ email: 'ada@example.org' }));

expect(url.searchParams.has('entry.1481076408')).toBe(false);
expect(url.searchParams.get('entry.1610387831')).toBe('ada@example.org');
});

it('ignores blank and null values rather than prefilling empty fields', () => {
const url = buildSealFeedbackUrl({ name: ' ', email: null });

expect(url).not.toContain('usp=pp_url');
expect(url).not.toContain('entry.');
});

it('trims surrounding whitespace before prefilling', () => {
const url = new URL(buildSealFeedbackUrl({ name: ' Ada Lovelace ' }));

expect(url.searchParams.get('entry.1481076408')).toBe('Ada Lovelace');
});
});
51 changes: 51 additions & 0 deletions src/app/utils/seal-feedback-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* It's a redirect to a google form
*/
const SEAL_FEEDBACK_FORM_URL = 'https://share.mobilitydata.org/sealfeedback';

/**
* Field ids taken from the form's own "Get pre-filled link" output. They are
* stable for the life of a question, but deleting and re-adding the "Name" or
* "Email" question in the form mints a new id and silently stops the prefill -
* regenerate them from the form if prefilled values stop showing up.
*/
const NAME_ENTRY_ID = 'entry.1481076408';
const EMAIL_ENTRY_ID = 'entry.1610387831';

/** The form with nothing prefilled - what the server renders. */
export const SEAL_FEEDBACK_URL = SEAL_FEEDBACK_FORM_URL;

export interface SealFeedbackPrefill {
name?: string | null;
email?: string | null;
}

/**
* Builds the feedback form URL, prefilling the respondent's name and email
* when the app knows them. Blank or missing values are left out entirely so
* the form renders an empty field rather than an empty prefill.
*/
export function buildSealFeedbackUrl(
prefill: SealFeedbackPrefill = {},
): string {
const url = new URL(SEAL_FEEDBACK_FORM_URL);
const name = prefill.name?.trim() ?? '';
const email = prefill.email?.trim() ?? '';

if (name === '' && email === '') {
return url.toString();
}

// Google's own marker for a prefilled link; without it the form ignores the
// entry parameters.
url.searchParams.set('usp', 'pp_url');

if (name !== '') {
url.searchParams.set(NAME_ENTRY_ID, name);
}
if (email !== '') {
url.searchParams.set(EMAIL_ENTRY_ID, email);
}

return url.toString();
}
Loading