diff --git a/POC-GUEST-PREVIEW-README.md b/POC-GUEST-PREVIEW-README.md new file mode 100644 index 00000000000..0fc0b79ea9c --- /dev/null +++ b/POC-GUEST-PREVIEW-README.md @@ -0,0 +1,137 @@ +# Temporary Guest My News POC + +This POC implements a temporary signed-in preview experience for guest users to test the hypothesis that allowing non-registered users to build a temporary My News experience will increase BBC account registrations. + +## Features Implemented + +### 1. Enhanced Save Button for Guests +- **Location**: `src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/` +- **Features**: + - Hover tooltip on save button with info: "Save this article for later. It will appear in your My News page." + - Save/unsave functionality using localStorage + - Confirmation notification after saving with link to My News + - Visual feedback showing saved state + +### 2. Temporary Article Storage +- **Location**: `src/app/hooks/useTemporarySavedArticles/` +- **Features**: + - Stores articles in browser localStorage + - 2-day expiry period + - Auto-cleanup on expiry + - Tracks save timestamp and article metadata + +### 3. Temporary My News Page +- **Location**: `ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/` +- **Features**: + - Displays saved articles in a grid + - Prominent banner explaining temporary nature + - Shows countdown to expiry + - Prompts for registration/sign-in with call-to-action buttons + - Integrates with existing AccountActionButtons component + +### 4. Integration Points +- **Article Pages**: Save button now uses `enableGuestPreview` prop to activate POC +- **My News Page**: Automatically shows temporary view when user has saved articles but isn't signed in +- **Smooth transition**: When user signs in, they see their temporary saves migrate to permanent storage + +## Usage + +### Enable on Article Pages +The POC is enabled by default on article pages. The `SaveArticleButton` component accepts an `enableGuestPreview` prop: + +```tsx + +``` + +### User Flow + +1. **Article with Save Button** + - Guest user sees "Save for later" button + - Hovering shows tooltip explaining the feature + +2. **Clicking Save** + - Article is saved to localStorage + - Confirmation notification appears with "View My News" link + +3. **Visiting My News Page** + - Shows temporary My News page with: + - Banner: "This is a temporary page available for 2 days" + - Expiry countdown + - Sign in / Register buttons + - Grid of saved articles + +4. **Registration/Sign-in** + - User clicks register or sign in + - Completes account creation + - Returns to My News with personalized experience + - (Future enhancement: migrate temporary saves to permanent account) + +## Technical Details + +### Data Storage +- **Key**: `bbc_temp_saved_articles` +- **Expiry Key**: `bbc_temp_saved_articles_expiry` +- **Format**: JSON array of article objects +- **Lifecycle**: 2 days from first save + +### Article Data Structure +```typescript +{ + id: string; + title: string; + link: string; + imageUrl?: string; + imageAlt?: string; + promoImage?: string; + type: string; + description: string; + savedAt: number; +} +``` + +### Components Reused +- `SaveButton` - Base save button component +- `CurationGrid` - Displays article grid +- `AccountActionButtons` - Sign in/register buttons +- `AccountSignInModal` - Modal for guest save (original flow) +- `Heading`, `Text`, `CallToActionLink` - UI primitives + +## Files Created/Modified + +### New Files +- `src/app/hooks/useTemporarySavedArticles/index.ts` +- `src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/index.tsx` +- `src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveButtonTooltip.tsx` +- `src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveArticleConfirmation.tsx` +- `ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/index.tsx` + +### Modified Files +- `src/app/components/SaveArticleButton/index.tsx` - Added `enableGuestPreview` prop +- `ws-nextjs-app/pages/[service]/my-news/MyNewsPage/index.tsx` - Added conditional rendering for temporary view +- `src/app/pages/ArticlePage/ArticlePage.tsx` - Enabled guest preview on article pages + +## Future Enhancements + +1. **Migration on Sign-in**: Transfer temporary saves to permanent UAS storage when user creates account +2. **Analytics**: Track conversion rates from temporary saves to registrations +3. **Recommendations**: Show personalized recommendations based on temporary saves +4. **Persistence Warning**: Show warning before expiry (e.g., "1 hour left") +5. **Cross-device**: Sync temporary saves using anonymous token +6. **Topic/Place Following**: Extend to topics and places, not just articles + +## Testing + +This POC does not include test suites as requested. For manual testing: + +1. Open any article page as a guest user +2. Hover over the save button to see tooltip +3. Click save and verify confirmation appears +4. Navigate to My News page +5. Verify temporary banner and articles display +6. Wait 2 days or manually clear localStorage to test expiry +7. Sign in to test transition (saves will not migrate in POC) + +[copilot] diff --git a/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveArticleConfirmation.tsx b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveArticleConfirmation.tsx new file mode 100644 index 00000000000..f732ff4fed4 --- /dev/null +++ b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveArticleConfirmation.tsx @@ -0,0 +1,112 @@ +import { use, useEffect } from 'react'; +import { ServiceContext } from '#contexts/ServiceContext'; +import Text from '#app/components/Text'; +import CallToActionLink from '#app/components/CallToActionLink'; +import { Close } from '#app/components/icons'; +import { css } from '@emotion/react'; + +const styles = { + overlay: css({ + position: 'absolute', + top: '100%', + left: '0', + marginTop: '0.75rem', + backgroundColor: '#fff', + border: '1px solid #ccc', + borderRadius: '4px', + padding: '1rem', + minWidth: '280px', + maxWidth: '350px', + boxShadow: '0 4px 12px rgba(0,0,0,0.15)', + zIndex: 1000, + animation: 'fadeIn 0.3s ease-out', + '@keyframes fadeIn': { + from: { + opacity: 0, + transform: 'translateY(-10px)', + }, + to: { + opacity: 1, + transform: 'translateY(0)', + }, + }, + }), + header: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: '0.5rem', + }), + closeButton: css({ + background: 'none', + border: 'none', + padding: '0.25rem', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + '&:hover': { + opacity: 0.7, + }, + }), + content: css({ + marginTop: '0.5rem', + }), + link: css({ + marginTop: '0.75rem', + display: 'inline-block', + }), +}; + +interface SaveArticleConfirmationProps { + onClose: () => void; +} + +const SaveArticleConfirmation = ({ onClose }: SaveArticleConfirmationProps) => { + const { service } = use(ServiceContext); + + const confirmationText = 'Article saved to My News'; + const linkText = 'View My News'; + + const myNewsPath = `/${service}/my-news`; + + useEffect(() => { + const timer = setTimeout(() => { + onClose(); + }, 5000); + + return () => clearTimeout(timer); + }, [onClose]); + + return ( +
+
+ + {confirmationText} + + +
+
+ + + {linkText} + + +
+
+ ); +}; + +export default SaveArticleConfirmation; diff --git a/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveButtonTooltip.tsx b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveButtonTooltip.tsx new file mode 100644 index 00000000000..8c694b916f6 --- /dev/null +++ b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveButtonTooltip.tsx @@ -0,0 +1,67 @@ +import { css } from '@emotion/react'; +import Text from '#app/components/Text'; + +interface SaveButtonTooltipProps { + isSaved?: boolean; +} + +const styles = { + tooltip: css({ + position: 'absolute', + bottom: '100%', + left: '50%', + transform: 'translateX(-50%)', + marginBottom: '0.5rem', + padding: '0.75rem 1rem', + backgroundColor: '#fff', + color: '#222', + border: '1px solid #ccc', + borderRadius: '4px', + whiteSpace: 'normal', + maxWidth: '280px', + width: 'max-content', + minWidth: '200px', + zIndex: 1000, + boxShadow: '0 2px 12px rgba(0,0,0,0.15)', + textAlign: 'center', + '&::after': { + content: '""', + position: 'absolute', + top: '100%', + left: '50%', + marginLeft: '-8px', + borderWidth: '8px', + borderStyle: 'solid', + borderColor: '#fff transparent transparent transparent', + }, + '&::before': { + content: '""', + position: 'absolute', + top: '100%', + left: '50%', + marginLeft: '-9px', + borderWidth: '9px', + borderStyle: 'solid', + borderColor: '#ccc transparent transparent transparent', + zIndex: -1, + }, + }), +}; + +const SaveButtonTooltip = ({ isSaved = false }: SaveButtonTooltipProps) => { + const unsavedText = + 'Save this article for later. It will appear in your My News page.'; + const savedText = 'This article is saved temporarily in your My News page.'; + + const tooltipText = isSaved ? savedText : unsavedText; + + return ( +
+ + {tooltipText} + +
+ ); +}; + +export default SaveButtonTooltip; diff --git a/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/index.tsx b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/index.tsx new file mode 100644 index 00000000000..f161d58d049 --- /dev/null +++ b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/index.tsx @@ -0,0 +1,103 @@ +import { ServiceContext } from '#contexts/ServiceContext'; +import SaveButton from '#app/components/SaveButton'; +import { use, useState } from 'react'; +import useHydrationDetection from '#app/hooks/useHydrationDetection'; +import { RequestContext } from '#app/contexts/RequestContext'; +import parseRoute from '#app/routes/utils/parseRoute'; +import useClickTracker from '#app/hooks/useClickTrackerHandler'; +import useViewTracker from '#app/hooks/useViewTracker'; +import useTemporarySavedArticles from '#app/hooks/useTemporarySavedArticles'; +import SaveArticleConfirmation from './SaveArticleConfirmation'; +import SaveButtonTooltip from './SaveButtonTooltip'; + +interface SaveArticleButtonGuestWithPreviewProps { + saveArticlePageData?: { + headline?: string | null; + canonicalUrl?: string; + promoImage?: string; + promoImageAltText?: string; + }; +} + +const SaveArticleButtonGuestWithPreview = ({ + saveArticlePageData, +}: SaveArticleButtonGuestWithPreviewProps) => { + const { translations, service } = use(ServiceContext); + const { pathname } = use(RequestContext); + const { assetId: articleId } = parseRoute(pathname); + const isHydrated = useHydrationDetection(); + const [showConfirmation, setShowConfirmation] = useState(false); + const [showTooltip, setShowTooltip] = useState(false); + + const { saveArticle, removeArticle, isArticleSaved } = + useTemporarySavedArticles(); + + const { saveArticleButton } = translations || {}; + const isSaved = isArticleSaved(articleId); + + let label = saveArticleButton?.loading; + if (isHydrated) { + label = isSaved ? saveArticleButton?.saved : saveArticleButton?.save; + } + + const hoverLabel = isSaved ? saveArticleButton?.remove : undefined; + + const viewTracker = useViewTracker({ + componentName: 'save-article-button-guest-preview-view', + }); + + const { onClick: onClickTrack } = useClickTracker({ + componentName: 'save-article-button-guest-preview-click', + itemTracker: { + resourceId: articleId, + }, + }); + + if (!saveArticleButton) return null; + + const handleClick = (e: React.MouseEvent) => { + onClickTrack?.(e); + + if (isSaved) { + removeArticle(articleId); + } else { + const article = { + id: articleId, + title: saveArticlePageData?.headline || 'Untitled', + link: pathname, + imageUrl: saveArticlePageData?.promoImage || '', + imageAlt: saveArticlePageData?.promoImageAltText || '', + promoImage: saveArticlePageData?.promoImage, + type: 'article', + description: service, + }; + saveArticle(article); + setShowConfirmation(true); + } + }; + + return ( +
+ setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + onFocus={() => setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} + {...viewTracker} + /> + {showTooltip && } + {showConfirmation && ( + setShowConfirmation(false)} /> + )} +
+ ); +}; + +export default SaveArticleButtonGuestWithPreview; diff --git a/src/app/components/SaveArticleButton/index.tsx b/src/app/components/SaveArticleButton/index.tsx index f8945db7863..ae890b3a7bb 100644 --- a/src/app/components/SaveArticleButton/index.tsx +++ b/src/app/components/SaveArticleButton/index.tsx @@ -3,20 +3,29 @@ import { AccountContext } from '#contexts/AccountContext'; import type { SaveArticlePageData } from '#app/lib/utilities/extractSaveArticleProps'; import SaveArticleButtonAuthenticated from './SaveArticleButtonAuthenticated/lazy'; import SaveArticleButtonGuest from './SaveArticleButtonGuest'; +import SaveArticleButtonGuestWithPreview from './SaveArticleButtonGuestWithPreview'; import styles from './index.styles'; export interface SaveArticleButtonProps { saveArticlePageData: SaveArticlePageData; + enableGuestPreview?: boolean; } const SAVE_ARTICLE_BUTTON_ID = 'save-article-button'; -const SaveArticleButton = (props: SaveArticleButtonProps) => { +const SaveArticleButton = ({ + saveArticlePageData, + enableGuestPreview = false, +}: SaveArticleButtonProps) => { const { isPersonalizationAvailable, isPersonalizationEnabled } = use(AccountContext); if (!isPersonalizationAvailable) return null; + const GuestButton = enableGuestPreview + ? SaveArticleButtonGuestWithPreview + : SaveArticleButtonGuest; + return ( <>
{isPersonalizationEnabled ? ( - + ) : ( - + )}
diff --git a/src/app/hooks/useTemporarySavedArticles/index.ts b/src/app/hooks/useTemporarySavedArticles/index.ts new file mode 100644 index 00000000000..e648fa36eaa --- /dev/null +++ b/src/app/hooks/useTemporarySavedArticles/index.ts @@ -0,0 +1,140 @@ +import { useState, useEffect, useCallback } from 'react'; + +const STORAGE_KEY = 'bbc_temp_saved_articles'; +const EXPIRY_KEY = 'bbc_temp_saved_articles_expiry'; +const EXPIRY_DAYS = 2; + +export interface TemporarySavedArticle { + id: string; + title: string; + link: string; + imageUrl?: string; + imageAlt?: string; + promoImage?: string; + type: string; + description: string; + savedAt: number; +} + +interface UseTemporarySavedArticlesReturn { + savedArticles: TemporarySavedArticle[]; + saveArticle: (article: Omit) => void; + removeArticle: (articleId: string) => void; + isArticleSaved: (articleId: string) => boolean; + expiryDate: Date | null; + clearAll: () => void; + hasExpired: boolean; +} + +const useTemporarySavedArticles = (): UseTemporarySavedArticlesReturn => { + const [savedArticles, setSavedArticles] = useState( + [], + ); + const [expiryDate, setExpiryDate] = useState(null); + const [hasExpired, setHasExpired] = useState(false); + + // Initialize from localStorage + useEffect(() => { + if (typeof window === 'undefined') return; + + const storedArticles = localStorage.getItem(STORAGE_KEY); + const storedExpiry = localStorage.getItem(EXPIRY_KEY); + + if (storedExpiry) { + const expiry = new Date(parseInt(storedExpiry, 10)); + setExpiryDate(expiry); + + // Check if expired + if (new Date() > expiry) { + setHasExpired(true); + // Clear expired data + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(EXPIRY_KEY); + setSavedArticles([]); + setExpiryDate(null); + return; + } + } + + if (storedArticles) { + try { + const articles = JSON.parse(storedArticles); + setSavedArticles(articles); + } catch { + // Invalid data, clear it + localStorage.removeItem(STORAGE_KEY); + } + } + }, []); + + const saveArticle = useCallback( + (article: Omit) => { + if (typeof window === 'undefined') return; + + const newArticle: TemporarySavedArticle = { + ...article, + savedAt: Date.now(), + }; + + setSavedArticles(prev => { + // Check if already saved + if (prev.some(a => a.id === article.id)) { + return prev; + } + + const updated = [newArticle, ...prev]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + + // Set expiry if not already set + const storedExpiry = localStorage.getItem(EXPIRY_KEY); + if (!storedExpiry) { + const expiry = new Date(); + expiry.setDate(expiry.getDate() + EXPIRY_DAYS); + localStorage.setItem(EXPIRY_KEY, expiry.getTime().toString()); + setExpiryDate(expiry); + } + + return updated; + }); + }, + [], + ); + + const removeArticle = useCallback((articleId: string) => { + if (typeof window === 'undefined') return; + + setSavedArticles(prev => { + const updated = prev.filter(a => a.id !== articleId); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + + const isArticleSaved = useCallback( + (articleId: string) => { + return savedArticles.some(a => a.id === articleId); + }, + [savedArticles], + ); + + const clearAll = useCallback(() => { + if (typeof window === 'undefined') return; + + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(EXPIRY_KEY); + setSavedArticles([]); + setExpiryDate(null); + }, []); + + return { + savedArticles, + saveArticle, + removeArticle, + isArticleSaved, + expiryDate, + clearAll, + hasExpired, + }; +}; + +export default useTemporarySavedArticles; diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx index e0a5966227c..cc384857346 100644 --- a/src/app/pages/ArticlePage/ArticlePage.tsx +++ b/src/app/pages/ArticlePage/ArticlePage.tsx @@ -263,6 +263,7 @@ const getTimestampComponent = )} ); diff --git a/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/index.tsx b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/index.tsx new file mode 100644 index 00000000000..1f6a1fe2190 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/index.tsx @@ -0,0 +1,124 @@ +import { use } from 'react'; +import Heading from '#app/components/Heading'; +import Text from '#app/components/Text'; +import { ServiceContext } from '#app/contexts/ServiceContext'; +import { AccountContext } from '#app/contexts/AccountContext'; +import AccountActionButtons from '#app/components/Account/AccountActionButtons'; +import CurationGrid from '#app/components/Curation/CurationGrid'; +import useTemporarySavedArticles from '#app/hooks/useTemporarySavedArticles'; +import { css } from '@emotion/react'; + +const styles = { + container: css({ + marginBottom: '2rem', + }), + heading: css({ + marginBottom: '1rem', + }), + banner: css({ + backgroundColor: '#ffe5e5', + border: '1px solid #ffcccc', + borderRadius: '4px', + padding: '1rem', + marginBottom: '2rem', + }), + bannerHeading: css({ + marginBottom: '0.5rem', + }), + actionButtons: css({ + marginTop: '1rem', + }), + expiryText: css({ + marginTop: '0.5rem', + fontStyle: 'italic', + }), + noArticles: css({ + padding: '2rem', + textAlign: 'center', + }), +}; + +const MyNewsPageTemporary = () => { + const { translations } = use(ServiceContext); + const { signInUrl, registerUrl } = use(AccountContext); + const { savedArticles, expiryDate } = useTemporarySavedArticles(); + + const formatExpiryDate = (date: Date) => { + const now = new Date(); + const diff = date.getTime() - now.getTime(); + const hours = Math.floor(diff / (1000 * 60 * 60)); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days} day${days > 1 ? 's' : ''}`; + } + return `${hours} hour${hours > 1 ? 's' : ''}`; + }; + + return ( +
+ + {translations?.myNews?.title || 'My News'} + + +
+ + Temporary My News + + + This is a temporary page available for 2 days. If you want to keep + your saved articles, please register or sign in to create a permanent + account. + + {expiryDate && ( + + Expires in {formatExpiryDate(expiryDate)} + + )} + {signInUrl && registerUrl && ( +
+ +
+ )} +
+ + {savedArticles.length > 0 ? ( + <> + + {translations?.myNews?.description || 'Your saved articles'} + + ({ + ...article, + imageUrl: article.imageUrl || '', + imageAlt: article.imageAlt || '', + }))} + headingLevel={3} + eventTrackingData={{ + componentName: 'my-news-temporary-curation-grid', + }} + /> + + ) : ( +
+ + {translations?.myNews?.noArticles || + 'You have no saved articles yet'} + +
+ )} +
+ ); +}; + +export default MyNewsPageTemporary; diff --git a/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/index.tsx b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/index.tsx index f6b327b9523..a06cc9cd65b 100644 --- a/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/index.tsx +++ b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/index.tsx @@ -2,6 +2,7 @@ import dynamic from 'next/dynamic'; import { use } from 'react'; import { AccountContext } from '#app/contexts/AccountContext'; import { ServiceContext } from '#app/contexts/ServiceContext'; +import useTemporarySavedArticles from '#app/hooks/useTemporarySavedArticles'; import ATIAnalytics from '#app/components/ATIAnalytics'; import MetadataContainer from '#app/components/Metadata'; @@ -9,6 +10,7 @@ import styles from './styles'; import { MyNewsPageProps } from '../types'; import MyNewsPageGuest from './MyNewsPageGuest'; import MyNewsPageLoading from './MyNewsPageLoading'; +import MyNewsPageTemporary from './MyNewsPageTemporary'; import GenericMessage from '../../send/[id]/GenericMessage'; import fallbackTranslations from '../../send/[id]/fallbackTranslations'; @@ -21,6 +23,9 @@ const MyNewsPage = ({ page }: MyNewsPageProps) => { const { isPersonalizationAvailable, isPersonalizationEnabled } = use(AccountContext); const { lang, translations } = use(ServiceContext); + const { savedArticles: tempSavedArticles } = useTemporarySavedArticles(); + const hasTemporarySavedArticles = tempSavedArticles.length > 0; + const noJsHeading = translations?.myNews?.title || fallbackTranslations.noJsHeading; const noJsDescription = @@ -29,6 +34,12 @@ const MyNewsPage = ({ page }: MyNewsPageProps) => { if (!isPersonalizationAvailable || !translations?.myNews) return null; + // Determine which view to show + const shouldShowTemporary = + !isPersonalizationEnabled && hasTemporarySavedArticles; + const shouldShowGuest = + !isPersonalizationEnabled && !hasTemporarySavedArticles; + return (
{
- {isPersonalizationEnabled ? ( - - ) : ( - - )} + {isPersonalizationEnabled && } + {shouldShowTemporary && } + {shouldShowGuest && }