diff --git a/projects/packages/premium-analytics/changelog/uni-757-readiness-question b/projects/packages/premium-analytics/changelog/uni-757-readiness-question new file mode 100644 index 00000000000..d2ca96aeae3 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/uni-757-readiness-question @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Dashboard: ask whether the new Traffic tab is ready to replace the old one, instead of how it compares with the old one. diff --git a/projects/packages/premium-analytics/routes/dashboard/components/feedback/feedback-fields.tsx b/projects/packages/premium-analytics/routes/dashboard/components/feedback/feedback-fields.tsx index ff5b52ad2e3..6b897370fba 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/feedback/feedback-fields.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/feedback/feedback-fields.tsx @@ -7,8 +7,11 @@ import { RadioControl } from '@wordpress/components'; import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +/** How ready the reader thinks the new tab is. The value is what reaches Tracks. */ +export type StatsFeedbackReadiness = 'ready' | 'almost' | 'not_yet'; + // Tracks drops an event whose properties are oversized, so a pasted essay would -// cost us the rating too. +// cost us the answer too. const COMMENT_MAX_LENGTH = 1000; /** @@ -26,13 +29,51 @@ function ratingOptions() { ]; } +/** + * The readiness answers, readiest first. `value` is what reaches Tracks. + * + * @return The options, in the order they are offered. + */ +function readinessOptions(): { value: StatsFeedbackReadiness; label: string }[] { + return [ + { + value: 'ready', + label: __( "Yes, I'd be happy to switch now", 'jetpack-premium-analytics-pkg' ), + }, + { + value: 'almost', + label: __( 'Almost — there are a few things missing', 'jetpack-premium-analytics-pkg' ), + }, + { value: 'not_yet', label: __( 'Not yet', 'jetpack-premium-analytics-pkg' ) }, + ]; +} + +/** + * The reader's readiness answer as Happiness reads it in the ticket body. + * + * Untranslated on purpose: this is triage copy, like the product name, not reader copy. + * + * Bracketed, not newline separated: the endpoint runs the message through + * `sanitize_text_field`, which collapses every run of whitespace to one space. + * + * @param readiness - The answer the reader picked. + * @return The question and its answer as one line. + */ +export function readinessSummary( readiness: StatsFeedbackReadiness ) { + const answers: Record< StatsFeedbackReadiness, string > = { + ready: 'Yes, ready to switch now', + almost: 'Almost, a few things missing', + not_yet: 'Not yet', + }; + + return `[Ready to replace the old Traffic tab? ${ answers[ readiness ] }]`; +} + /** * The question above a control whose own label is hidden. * - * The control keeps the question as its accessible name, so this copy is for the - * eye only and stays out of the accessibility tree rather than being read twice. - * The label slot itself is an 11px uppercase caption, which a sentence-long - * question reads badly as. + * Kept out of the accessibility tree so the control's accessible name is not read twice; the + * label slot itself is an 11px uppercase caption, which a sentence-long question reads badly as. * * @param {object} props - Component props. * @param {string} props.children - The question. @@ -42,7 +83,37 @@ function Question( { children }: { children: string } ) { return ; } -type FeedbackFieldsProps = { +type CommentFieldProps = { + question: string; + comment: string; + onCommentChange: ( comment: string ) => void; +}; + +/** + * The open question every feedback surface ends on. + * + * @param {CommentFieldProps} props - Component props. + * @param {string} props.question - The question above the box. + * @param {string} props.comment - The comment as typed. + * @param {Function} props.onCommentChange - Called with the comment as typed. + * @return The field. + */ +function CommentField( { question, comment, onCommentChange }: CommentFieldProps ) { + return ( + + { question } + + + ); +} + +type ComparisonFieldsProps = { rating: StatsFeedbackRating | undefined; onRatingChange: ( rating: StatsFeedbackRating ) => void; comment: string; @@ -51,24 +122,23 @@ type FeedbackFieldsProps = { }; /** - * The comparison scale and the comment box every feedback surface shares; each - * surface asks its own open question under the scale. + * The comparison scale, above an open question the surface chooses. * - * @param {FeedbackFieldsProps} props - Component props. - * @param {number|undefined} props.rating - The score picked, if any. - * @param {Function} props.onRatingChange - Called with the score picked. - * @param {string} props.comment - The comment as typed. - * @param {Function} props.onCommentChange - Called with the comment as typed. - * @param {string} props.commentQuestion - The question above the comment box. + * @param {ComparisonFieldsProps} props - Component props. + * @param {number|undefined} props.rating - The score picked, if any. + * @param {Function} props.onRatingChange - Called with the score picked. + * @param {string} props.comment - The comment as typed. + * @param {Function} props.onCommentChange - Called with the comment as typed. + * @param {string} props.commentQuestion - The question above the comment box. * @return The two fields. */ -export function FeedbackFields( { +export function ComparisonFields( { rating, onRatingChange, comment, onCommentChange, commentQuestion, -}: FeedbackFieldsProps ) { +}: ComparisonFieldsProps ) { const comparisonQuestion = __( 'Compared with the existing Traffic tab in Stats, the new Traffic tab is:', 'jetpack-premium-analytics-pkg' @@ -92,16 +162,72 @@ export function FeedbackFields( { /> + + + ); +} + +type ReadinessFieldsProps = { + readiness: StatsFeedbackReadiness | undefined; + onReadinessChange: ( readiness: StatsFeedbackReadiness ) => void; + comment: string; + onCommentChange: ( comment: string ) => void; +}; + +/** + * The readiness question and the open question that follows it. + * + * @param {ReadinessFieldsProps} props - Component props. + * @param {string|undefined} props.readiness - The answer picked, if any. + * @param {Function} props.onReadinessChange - Called with the answer picked. + * @param {string} props.comment - The comment as typed. + * @param {Function} props.onCommentChange - Called with the comment as typed. + * @return The two fields. + */ +export function ReadinessFields( { + readiness, + onReadinessChange, + comment, + onCommentChange, +}: ReadinessFieldsProps ) { + const readinessQuestion = __( + 'Is the new Traffic tab ready to replace the old one?', + 'jetpack-premium-analytics-pkg' + ); + + // "What's missing?" reads oddly after "Yes", where nothing is missing by definition. + const commentQuestion = + readiness === 'ready' + ? __( "Any other feedback you'd like to share?", 'jetpack-premium-analytics-pkg' ) + : __( "What's missing?", 'jetpack-premium-analytics-pkg' ); + + const selectReadiness = useCallback( + ( value: string ) => onReadinessChange( value as StatsFeedbackReadiness ), + [ onReadinessChange ] + ); + + return ( + <> - { commentQuestion } - { readinessQuestion } + + + ); } diff --git a/projects/packages/premium-analytics/routes/dashboard/components/feedback/feedback-modal.tsx b/projects/packages/premium-analytics/routes/dashboard/components/feedback/feedback-modal.tsx index 498c0de4fb1..0427febc580 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/feedback/feedback-modal.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/feedback/feedback-modal.tsx @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { submitStatsUserFeedback, type StatsFeedbackRating } from '@jetpack-premium-analytics/data'; +import { submitStatsUserFeedback } from '@jetpack-premium-analytics/data'; import { Button, Dialog, Notice, Stack } from '@jetpack-premium-analytics/externals'; import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -9,7 +9,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { useTrackEvent } from '../../hooks/use-track-event'; -import { FeedbackFields } from './feedback-fields'; +import { ReadinessFields, readinessSummary, type StatsFeedbackReadiness } from './feedback-fields'; // Reaches Happiness as the subject line of the feedback email ("Feedback received // from …"), so it has to name the surface without any further context. @@ -20,7 +20,7 @@ type FeedbackModalProps = { }; /** - * Comparison rating with an optional comment. Both reach Tracks as one event; a non-empty + * Readiness answer with an optional comment. Both reach Tracks as one event; a non-empty * comment also goes to the Stats feedback endpoint. * * @param {FeedbackModalProps} props - Component props. @@ -29,15 +29,10 @@ type FeedbackModalProps = { */ export function FeedbackModal( { onClose }: FeedbackModalProps ) { const trackEvent = useTrackEvent(); - const [ rating, setRating ] = useState< StatsFeedbackRating >(); + const [ readiness, setReadiness ] = useState< StatsFeedbackReadiness >(); const [ comment, setComment ] = useState( '' ); const [ hasSubmitted, setHasSubmitted ] = useState( false ); - const blockerQuestion = __( - "What's the one thing we'd need to fix before this replaces the old Stats?", - 'jetpack-premium-analytics-pkg' - ); - const handleOpenChange = useCallback( ( isOpen: boolean ) => { if ( ! isOpen ) { @@ -48,28 +43,30 @@ export function FeedbackModal( { onClose }: FeedbackModalProps ) { ); const submit = useCallback( () => { - if ( rating === undefined ) { + if ( readiness === undefined ) { return; } const message = comment.trim(); - trackEvent( 'jetpack_premium_analytics_feedback_submit', { rating, comment: message } ); + trackEvent( 'jetpack_premium_analytics_feedback_submit', { readiness, comment: message } ); // Second channel, deliberately not awaited: Tracks is a pixel and ad blockers drop it // silently, so the message also goes to Happiness where delivery is not the reader's - // browser's decision. A rating alone would only open an empty ticket. + // browser's decision. An answer alone would only open an empty ticket. if ( message ) { - submitStatsUserFeedback( { rating, comment: message, productName: PRODUCT_NAME } ).catch( - () => { - // The reader has already been thanked and Tracks may well have the submission; - // a second, contradictory message would cost more than the lost email. - } - ); + // The endpoint has no readiness field, so the answer rides along in the message. + submitStatsUserFeedback( { + comment: `${ readinessSummary( readiness ) } ${ message }`, + productName: PRODUCT_NAME, + } ).catch( () => { + // The reader has already been thanked and Tracks may well have the submission; + // a second, contradictory message would cost more than the lost email. + } ); } setHasSubmitted( true ); - }, [ comment, rating, trackEvent ] ); + }, [ comment, readiness, trackEvent ] ); return ( @@ -110,12 +107,11 @@ export function FeedbackModal( { onClose }: FeedbackModalProps ) { <> - @@ -127,7 +123,7 @@ export function FeedbackModal( { onClose }: FeedbackModalProps ) {