From 67c9651f978dabc0f0197ddb8171f7aa8bfab530 Mon Sep 17 00:00:00 2001 From: jinidev Date: Tue, 11 Aug 2026 18:05:04 +0300 Subject: [PATCH 1/3] First draft- on temporary siginedin preview for guest users --- POC-GUEST-PREVIEW-README.md | 137 +++++++++++++++++ .../SaveArticleConfirmation.tsx | 112 ++++++++++++++ .../SaveButtonTooltip.tsx | 67 +++++++++ .../index.tsx | 103 +++++++++++++ .../components/SaveArticleButton/index.tsx | 17 ++- .../hooks/useTemporarySavedArticles/index.ts | 140 ++++++++++++++++++ src/app/pages/ArticlePage/ArticlePage.tsx | 1 + .../MyNewsPage/MyNewsPageTemporary/index.tsx | 124 ++++++++++++++++ .../[service]/my-news/MyNewsPage/index.tsx | 19 ++- 9 files changed, 712 insertions(+), 8 deletions(-) create mode 100644 POC-GUEST-PREVIEW-README.md create mode 100644 src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveArticleConfirmation.tsx create mode 100644 src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveButtonTooltip.tsx create mode 100644 src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/index.tsx create mode 100644 src/app/hooks/useTemporarySavedArticles/index.ts create mode 100644 ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/index.tsx 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 && }
From afd1db2b2cdd8f41e3fdad9553f08cff968e3190 Mon Sep 17 00:00:00 2001 From: jinidev Date: Wed, 12 Aug 2026 16:19:10 +0300 Subject: [PATCH 2/3] After signing in -behaviour --- POC-GUEST-PREVIEW-README.md | 49 ++++- .../hooks/useTemporarySavesMigration/index.ts | 169 ++++++++++++++++++ .../MigrationSuccessBanner/index.tsx | 36 ++++ .../my-news/MyNewsPage/MyNewsPageContent.tsx | 27 ++- .../MyNewsPage/MyNewsPageTemporary/index.tsx | 10 +- .../[service]/my-news/MyNewsPage/index.tsx | 24 +++ 6 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 src/app/hooks/useTemporarySavesMigration/index.ts create mode 100644 ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MigrationSuccessBanner/index.tsx diff --git a/POC-GUEST-PREVIEW-README.md b/POC-GUEST-PREVIEW-README.md index 0fc0b79ea9c..c3d8d987d9d 100644 --- a/POC-GUEST-PREVIEW-README.md +++ b/POC-GUEST-PREVIEW-README.md @@ -115,16 +115,61 @@ The POC is enabled by default on article pages. The `SaveArticleButton` componen ## Future Enhancements -1. **Migration on Sign-in**: Transfer temporary saves to permanent UAS storage when user creates account +1. **Migration on Sign-in**: - Automatic transfer of temporary saves to permanent UAS storage 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 +## ✨ Automatic Migration to Permanent Storage + +### Overview +When a guest user with temporary saved articles signs in or registers, their articles are **automatically migrated** to permanent UAS storage with zero user action required! + +### New Components for Migration + +**Migration Hook** (`useTemporarySavesMigration`): +- `src/app/hooks/useTemporarySavesMigration/index.ts` +- Automatically triggers on sign-in +- Migrates all temporary articles to UAS +- Clears localStorage after success + +**Success Banner** (`MigrationSuccessBanner`): +- `ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MigrationSuccessBanner/index.tsx` +- Green success banner +- Auto-hides after 10 seconds + +### Migration Flow + +``` +1. Guest saves articles → localStorage +2. Signs in/registers → Account created +3. Returns to My News → Migration triggers automatically +4. Loading state (1-2 seconds) → Migrating articles to UAS +5. Success banner appears → "Your articles have been saved!" +6. Permanent My News → All articles now in UAS +``` + +### What Gets Migrated + +- Article ID, title, and link +- Promo image and alt text +- Service context +- All metadata needed for display + +### Error Handling + +- Individual failures don't stop migration +- Successful articles still saved +- Errors logged to console +- User sees successfully migrated content + +[copilot] + ## Testing -This POC does not include test suites as requested. For manual testing: +This POC does not include test suites . For manual testing: 1. Open any article page as a guest user 2. Hover over the save button to see tooltip diff --git a/src/app/hooks/useTemporarySavesMigration/index.ts b/src/app/hooks/useTemporarySavesMigration/index.ts new file mode 100644 index 00000000000..c86a4724e12 --- /dev/null +++ b/src/app/hooks/useTemporarySavesMigration/index.ts @@ -0,0 +1,169 @@ +import { use, useEffect, useState } from 'react'; +import { AccountContext } from '#app/contexts/AccountContext'; +import { ServiceContext } from '#app/contexts/ServiceContext'; +import type { Services } from '#app/models/types/global'; +import uasApiRequest from '#app/lib/uasApi'; +import { FAVOURITES_CONFIG } from '#app/lib/uasApi/uasUtility'; + +/* eslint-disable no-console */ + +interface TemporarySavedArticle { + id: string; + title: string; + link: string; + imageUrl?: string; + imageAlt?: string; + promoImage?: string; + type: string; + description: string; + savedAt: number; +} + +const STORAGE_KEY = 'bbc_temp_saved_articles'; +const EXPIRY_KEY = 'bbc_temp_saved_articles_expiry'; +const MIGRATION_FLAG_KEY = 'bbc_temp_saves_migrated'; +const MIGRATION_BANNER_KEY = 'bbc_show_migration_banner'; + +/** + * Hook to automatically migrate temporary saved articles to UAS when user signs in. + * This ensures articles saved during guest preview are persisted after registration/sign-in. + * + * NOTE: This hook must be used within a dynamic import boundary where TanStack Query is available. + */ +const useTemporarySavesMigration = () => { + const { isPersonalizationEnabled, hashedUserId, isRefreshAvailable } = + use(AccountContext); + const { service } = use(ServiceContext); + const [isMigrating, setIsMigrating] = useState(false); + const [migrationComplete, setMigrationComplete] = useState(false); + + useEffect(() => { + const migrateTemporarySaves = async () => { + // Only migrate if: + // 1. User is now signed in (personalization enabled) + // 2. Not already migrating + // 3. Migration hasn't been completed in this session + // 4. We have a hashed user ID + if ( + !isPersonalizationEnabled || + isMigrating || + migrationComplete || + !hashedUserId + ) { + return; + } + + // Check if migration already happened for this user + const migrationFlag = sessionStorage.getItem(MIGRATION_FLAG_KEY); + if (migrationFlag === hashedUserId) { + setMigrationComplete(true); + return; + } + + // Get temporary saved articles from localStorage + const storedArticles = localStorage.getItem(STORAGE_KEY); + if (!storedArticles) { + return; // No temporary saves to migrate + } + + try { + const articles: TemporarySavedArticle[] = JSON.parse(storedArticles); + + if (articles.length === 0) { + return; // No articles to migrate + } + + setIsMigrating(true); + + // Migrate each article to UAS using direct API calls + const migrationPromises = articles.map(async article => { + try { + // Build metadata for the article + const metadata = { + articleId: article.id, + service: (article.description as Services) || service, + title: article.title, + promoImage: article.promoImage || article.imageUrl, + promoImageAltText: article.imageAlt, + locatorUrl: article.link, + }; + + // Create the UAS payload + const body = { + activityType: FAVOURITES_CONFIG.activityType, + resourceDomain: FAVOURITES_CONFIG.resourceDomain, + resourceType: FAVOURITES_CONFIG.resourceType, + resourceId: article.id, + action: FAVOURITES_CONFIG.action, + resourceTitle: (article.description as Services) || service, + metaData: metadata, + }; + + // POST to UAS + await uasApiRequest('POST', FAVOURITES_CONFIG.activityType, { + body, + isRefreshAvailable, + }); + + return { success: true, articleId: article.id }; + } catch (error) { + // Log error but continue with other articles + console.error( + `Failed to migrate article ${article.id}:`, + error instanceof Error ? error.message : error, + ); + return { success: false, articleId: article.id }; + } + }); + + const results = await Promise.allSettled(migrationPromises); + + // Count successful migrations + const successCount = results.filter( + result => result.status === 'fulfilled' && result.value.success, + ).length; + + if (successCount > 0) { + // Set flag to show migration success banner + sessionStorage.setItem(MIGRATION_BANNER_KEY, 'true'); + } + + // Clear temporary storage after migration + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(EXPIRY_KEY); + + // Mark migration as complete for this session + sessionStorage.setItem(MIGRATION_FLAG_KEY, hashedUserId); + setMigrationComplete(true); + + // Force a page reload to refresh the My News content from UAS + if (successCount > 0 && typeof window !== 'undefined') { + window.location.reload(); + } + } catch (error) { + console.error( + 'Failed to migrate temporary saves:', + error instanceof Error ? error.message : error, + ); + } finally { + setIsMigrating(false); + } + }; + + migrateTemporarySaves(); + }, [ + isPersonalizationEnabled, + hashedUserId, + service, + isRefreshAvailable, + isMigrating, + migrationComplete, + ]); + + return { + isMigrating, + migrationComplete, + }; +}; + +export default useTemporarySavesMigration; diff --git a/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MigrationSuccessBanner/index.tsx b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MigrationSuccessBanner/index.tsx new file mode 100644 index 00000000000..8da97588ef8 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MigrationSuccessBanner/index.tsx @@ -0,0 +1,36 @@ +import Heading from '#app/components/Heading'; +import Text from '#app/components/Text'; +import { css } from '@emotion/react'; + +const styles = { + banner: css({ + backgroundColor: '#e6f7e6', + border: '1px solid #4caf50', + borderRadius: '4px', + padding: '1rem', + marginBottom: '2rem', + }), + bannerHeading: css({ + marginBottom: '0.5rem', + color: '#2e7d32', + }), + bannerText: css({ + color: '#1b5e20', + }), +}; + +const MigrationSuccessBanner = () => { + return ( +
+ + Welcome! Your articles have been saved + + + Your temporarily saved articles are now permanently stored in your + account. You can access them anytime across all your devices. + +
+ ); +}; + +export default MigrationSuccessBanner; diff --git a/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageContent.tsx b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageContent.tsx index 5785f0bb391..92c1803a46f 100644 --- a/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageContent.tsx +++ b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageContent.tsx @@ -3,13 +3,15 @@ import Heading from '#app/components/Heading'; import Pagination from '#app/components/Pagination'; import MetadataContainer from '#app/components/Metadata'; import { ServiceContext } from '#app/contexts/ServiceContext'; -import { use } from 'react'; +import { use, useEffect, useState } from 'react'; import useUASRecentActivity from '#app/hooks/useUASRecentActivity'; import Text from '#app/components/Text'; import styles from './styles'; import MyNewsPageLoading from './MyNewsPageLoading'; +import MigrationSuccessBanner from './MigrationSuccessBanner'; const ITEMS_PER_PAGE = 24; +const MIGRATION_BANNER_KEY = 'bbc_show_migration_banner'; interface MyNewsPageContentProps { page?: string; @@ -17,6 +19,7 @@ interface MyNewsPageContentProps { const MyNewsPageContent = ({ page }: MyNewsPageContentProps) => { const { translations, lang } = use(ServiceContext); + const [showMigrationBanner, setShowMigrationBanner] = useState(false); const activePage = Math.max(1, Number(page) || 1); const startIndex = (activePage - 1) * ITEMS_PER_PAGE; @@ -26,6 +29,25 @@ const MyNewsPageContent = ({ page }: MyNewsPageContentProps) => { startIndex, }); + // Check if we should show the migration success banner + useEffect(() => { + if (typeof window === 'undefined') return; + + const showBanner = sessionStorage.getItem(MIGRATION_BANNER_KEY); + if (showBanner === 'true') { + setShowMigrationBanner(true); + // Clear the flag after showing + sessionStorage.removeItem(MIGRATION_BANNER_KEY); + + // Auto-hide banner after 10 seconds + const timer = setTimeout(() => { + setShowMigrationBanner(false); + }, 30000); + + return () => clearTimeout(timer); + } + }, []); + const pageCount = Math.max(1, Math.ceil(total / ITEMS_PER_PAGE)); const { @@ -109,6 +131,9 @@ const MyNewsPageContent = ({ page }: MyNewsPageContentProps) => { {title} + + {showMigrationBanner && } + {renderContent()} ); 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 index 1f6a1fe2190..035dbdcf10d 100644 --- a/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/index.tsx +++ b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/index.tsx @@ -55,6 +55,10 @@ const MyNewsPageTemporary = () => { return `${hours} hour${hours > 1 ? 's' : ''}`; }; + const articlesCount = savedArticles.length; + const articlesText = + articlesCount === 1 ? 'article' : `${articlesCount} articles`; + return (
{ 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. + {articlesCount > 0 + ? `You have ${articlesText} saved temporarily. This page is available for 2 days. Sign in or register to keep your saved articles permanently.` + : '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 && ( 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 a06cc9cd65b..5e9be83d900 100644 --- a/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/index.tsx +++ b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/index.tsx @@ -3,6 +3,7 @@ import { use } from 'react'; import { AccountContext } from '#app/contexts/AccountContext'; import { ServiceContext } from '#app/contexts/ServiceContext'; import useTemporarySavedArticles from '#app/hooks/useTemporarySavedArticles'; +import useTemporarySavesMigration from '#app/hooks/useTemporarySavesMigration'; import ATIAnalytics from '#app/components/ATIAnalytics'; import MetadataContainer from '#app/components/Metadata'; @@ -26,6 +27,9 @@ const MyNewsPage = ({ page }: MyNewsPageProps) => { const { savedArticles: tempSavedArticles } = useTemporarySavedArticles(); const hasTemporarySavedArticles = tempSavedArticles.length > 0; + // Automatically migrate temporary saves when user signs in + const { isMigrating } = useTemporarySavesMigration(); + const noJsHeading = translations?.myNews?.title || fallbackTranslations.noJsHeading; const noJsDescription = @@ -40,6 +44,26 @@ const MyNewsPage = ({ page }: MyNewsPageProps) => { const shouldShowGuest = !isPersonalizationEnabled && !hasTemporarySavedArticles; + // Show loading state during migration + if (isMigrating) { + return ( +
+ + +
+
+ +
+
+
+ ); + } + return (
Date: Thu, 13 Aug 2026 10:26:30 +0300 Subject: [PATCH 3/3] Copy updates --- .../SaveArticleConfirmation.tsx | 38 +++++++++++-------- .../SaveButtonTooltip.tsx | 32 +++++++++++++--- .../MyNewsPage/MyNewsPageTemporary/index.tsx | 14 +++---- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveArticleConfirmation.tsx b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveArticleConfirmation.tsx index f732ff4fed4..e4e938b2aa7 100644 --- a/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveArticleConfirmation.tsx +++ b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveArticleConfirmation.tsx @@ -52,8 +52,7 @@ const styles = { marginTop: '0.5rem', }), link: css({ - marginTop: '0.75rem', - display: 'inline-block', + display: 'inline', }), }; @@ -64,15 +63,18 @@ interface SaveArticleConfirmationProps { const SaveArticleConfirmation = ({ onClose }: SaveArticleConfirmationProps) => { const { service } = use(ServiceContext); - const confirmationText = 'Article saved to My News'; - const linkText = 'View My News'; + const confirmationText = 'Saved to your temporary personal page'; + const linkTextBefore = 'Find it in'; + const linkTextMyNews = 'My News'; + const linkTextAfter = + 'during this temporary preview. Sign in to keep it for future visits'; const myNewsPath = `/${service}/my-news`; useEffect(() => { const timer = setTimeout(() => { onClose(); - }, 5000); + }, 10000); return () => clearTimeout(timer); }, [onClose]); @@ -93,17 +95,21 @@ const SaveArticleConfirmation = ({ onClose }: SaveArticleConfirmationProps) => {
- - - {linkText} - - + + {linkTextBefore}{' '} + + + {linkTextMyNews} + + {' '} + {linkTextAfter} +
); diff --git a/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveButtonTooltip.tsx b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveButtonTooltip.tsx index 8c694b916f6..3befc3012de 100644 --- a/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveButtonTooltip.tsx +++ b/src/app/components/SaveArticleButton/SaveArticleButtonGuestWithPreview/SaveButtonTooltip.tsx @@ -1,5 +1,8 @@ import { css } from '@emotion/react'; +import { use } from 'react'; import Text from '#app/components/Text'; +import CallToActionLink from '#app/components/CallToActionLink'; +import { ServiceContext } from '#app/contexts/ServiceContext'; interface SaveButtonTooltipProps { isSaved?: boolean; @@ -46,19 +49,38 @@ const styles = { zIndex: -1, }, }), + link: css({ + display: 'inline', + }), }; 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 { service } = use(ServiceContext); + const myNewsPath = `/${service}/my-news`; - const tooltipText = isSaved ? savedText : unsavedText; + const unsavedText = + 'Want to read this later? Save this article and find it in your temporary My News page.'; + + const savedTextBefore = 'This article is saved temporarily in your'; + const savedTextLink = 'My News page'; + const savedTextAfter = '.'; return (
- {tooltipText} + {isSaved ? ( + <> + {savedTextBefore}{' '} + + + {savedTextLink} + + + {savedTextAfter} + + ) : ( + unsavedText + )}
); 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 index 035dbdcf10d..9f605bf05b1 100644 --- a/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/index.tsx +++ b/ws-nextjs-app/pages/[service]/my-news/MyNewsPage/MyNewsPageTemporary/index.tsx @@ -47,12 +47,8 @@ const MyNewsPageTemporary = () => { 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 `${hours} hour${hours !== 1 ? 's' : ''}`; }; const articlesCount = savedArticles.length; @@ -73,13 +69,15 @@ const MyNewsPageTemporary = () => {
- Temporary My News + Your Temporary My News Page {articlesCount > 0 - ? `You have ${articlesText} saved temporarily. This page is available for 2 days. Sign in or register to keep your saved articles permanently.` - : '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.'} + ? `You have ${articlesText} saved temporarily. This page is available for 48 hours. Sign in or register to keep them for future visits.` + : 'This is a temporary page available for 48 hours. Please sign in or register to keep them for future visits.'} +
+
{expiryDate && ( Expires in {formatExpiryDate(expiryDate)}