diff --git a/.gitignore b/.gitignore index 7aa1b0a..c1fbb57 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ next-env.d.ts # Notion sync cache .notion-db-id + +# Local design-system extracts +/Polycord Design System/ +/polycord-design-system/ diff --git a/src/features/Inbox/Inbox.stories.tsx b/src/features/Inbox/Inbox.stories.tsx index 117f43d..f20ea79 100644 --- a/src/features/Inbox/Inbox.stories.tsx +++ b/src/features/Inbox/Inbox.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { expect, screen, userEvent, within } from '@storybook/test'; +import { expect, userEvent, within } from '@storybook/test'; import { useTranslations } from 'next-intl'; import React, { useState } from 'react'; import { Button } from '@/components/Button'; @@ -24,25 +24,29 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const NotificationsStory = () => { +const FreeNotificationsStory = () => { const t = useTranslations('Inbox'); return ( { ); }; -export const WithNotifications: Story = { - render: () => , +export const FreeNotifications: Story = { + render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); const trigger = await canvas.findByRole('button', { name: 'Notifications', }); - await expect(canvas.getByText('3')).toBeInTheDocument(); + await expect(canvas.getByText('2')).toBeInTheDocument(); await userEvent.click(trigger); + const portal = within(document.body); + await expect( - await screen.findByRole('heading', { name: 'Notifications' }), + await portal.findByRole('heading', { name: 'Notifications' }), ).toBeInTheDocument(); + await expect( + portal.getAllByText('A user copied your username'), + ).toHaveLength(2); + await expect( + portal.getByText('See who it was with Premium'), + ).toHaveAttribute('href', '/en/settings#premium'); + await expect( + portal.queryByText('Sophie Laurent viewed your profile'), + ).not.toBeInTheDocument(); + }, +}; + +const PremiumNotificationsStory = () => { + const t = useTranslations('Inbox'); + return ( + + ); +}; + +export const PremiumNotifications: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByRole('button', { + name: 'Notifications', + }); + + await expect(canvas.getByText('4')).toBeInTheDocument(); + + await userEvent.click(trigger); + + const portal = within(document.body); + + await expect( + await portal.findByText('Mina Park copied your username'), + ).toBeInTheDocument(); + await expect( + portal.getByText('Sophie Laurent viewed your profile'), + ).toBeInTheDocument(); + await expect( + portal.getByText('A guest viewed your profile'), + ).toBeInTheDocument(); + await expect( + portal.getByText('An anonymous user copied your username'), + ).toBeInTheDocument(); + await expect( + portal.queryByText('See who it was with Premium'), + ).not.toBeInTheDocument(); }, }; @@ -72,6 +156,25 @@ export const Empty: Story = { args: { notifications: [], }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByRole('button', { + name: 'Notifications', + }); + + await userEvent.click(trigger); + + const portal = within(document.body); + + await expect( + await portal.findByText('No notifications yet'), + ).toBeInTheDocument(); + await expect( + portal.getByText( + "You'll see new notifications here when something happens!", + ), + ).toBeInTheDocument(); + }, }; const ToastComponent = React.memo( @@ -108,26 +211,24 @@ const LiveUpdateStory = () => { const { toasts, addToast, dismissToast } = useToastStack(); const createNotificationAndToast = ( - messageKey: 'anonymousUserCopied' | 'userCopied', - iconUrl?: string, + actorName?: string, + actorAvatarUrl?: string, ) => { - const message = - messageKey === 'userCopied' - ? t('userCopied', { user: 'xhev' }) - : t('anonymousUserCopied'); - const newNotification: Notification = { id: Date.now().toString(), - message: message, + kind: 'copy', + actorName, timestamp: t('minutesAgo', { count: 0 }), - iconUrl: iconUrl, + actorAvatarUrl, }; const newToast: Omit = { title: t('newNotification'), - description: newNotification.message, + description: actorName + ? t('userCopied', { user: actorName }) + : t('anonymousCopyAlert'), duration: 5000, - iconUrl: iconUrl, + iconUrl: actorAvatarUrl, }; setNotifications((prev) => [newNotification, ...prev]); @@ -135,11 +236,11 @@ const LiveUpdateStory = () => { }; const simulateAnonNotification = () => { - createNotificationAndToast('anonymousUserCopied'); + createNotificationAndToast(); }; const simulateUserNotification = () => { - createNotificationAndToast('userCopied', MOCK_USER_AVATAR_URL); + createNotificationAndToast('xhev', MOCK_USER_AVATAR_URL); }; return ( diff --git a/src/features/Inbox/Inbox.tsx b/src/features/Inbox/Inbox.tsx index 22ff906..8e7f7ed 100644 --- a/src/features/Inbox/Inbox.tsx +++ b/src/features/Inbox/Inbox.tsx @@ -1,5 +1,6 @@ import * as Popover from '@radix-ui/react-popover'; -import { useTranslations } from 'next-intl'; +import Link from 'next/link'; +import { useLocale, useTranslations } from 'next-intl'; import { useEffect, useState } from 'react'; import { MdOutlineInbox, @@ -10,18 +11,23 @@ import { Button } from '@/components/Button'; import type { Notifications } from '@/types'; import { NotificationEntry } from './NotificationEntry'; -const initializeNotifications = (initial: Notifications) => - initial.map((n) => ({ ...n, read: false, isDeleting: false })); +const initializeNotifications = (initial: Notifications, premium: boolean) => + initial + .filter((notification) => premium || notification.kind === 'copy') + .map((n) => ({ ...n, read: false, isDeleting: false })); export const Inbox = ({ notifications: initialNotifications, + premium = false, }: { notifications: Notifications; + premium?: boolean; }) => { const t = useTranslations('Inbox'); + const locale = useLocale(); const [notifications, setNotifications] = useState(() => - initializeNotifications(initialNotifications), + initializeNotifications(initialNotifications, premium), ); const [currentPage, setCurrentPage] = useState(1); const [isMounted, setIsMounted] = useState(false); @@ -32,9 +38,9 @@ export const Inbox = ({ }, []); useEffect(() => { - setNotifications(initializeNotifications(initialNotifications)); + setNotifications(initializeNotifications(initialNotifications, premium)); setCurrentPage(1); - }, [initialNotifications]); + }, [initialNotifications, premium]); const unreadCount = notifications.filter((n) => !n.read).length; @@ -161,6 +167,7 @@ export const Inbox = ({ currentNotifications.map((notification, index) => ( handleMarkAsRead(notification.id)} onDelete={() => handleDelete(notification.id)} @@ -186,6 +193,14 @@ export const Inbox = ({

)} + {!premium && currentNotifications.length > 0 && ( + + {t('seeWhoWithPremium')} + + )} {totalPages > 1 && ( diff --git a/src/features/Inbox/NotificationEntry.stories.tsx b/src/features/Inbox/NotificationEntry.stories.tsx index 167ceff..e7a6609 100644 --- a/src/features/Inbox/NotificationEntry.stories.tsx +++ b/src/features/Inbox/NotificationEntry.stories.tsx @@ -36,11 +36,13 @@ const DefaultNotificationStory = () => { {}} onDelete={() => {}} /> @@ -57,11 +59,13 @@ const ReadNotificationStory = () => { {}} onDelete={() => {}} /> diff --git a/src/features/Inbox/NotificationEntry.tsx b/src/features/Inbox/NotificationEntry.tsx index 0ab1d5f..c139106 100644 --- a/src/features/Inbox/NotificationEntry.tsx +++ b/src/features/Inbox/NotificationEntry.tsx @@ -10,12 +10,32 @@ import type { Notification } from '@/types'; type NotificationEntryProps = { notification: Notification & { read: boolean; isDeleting?: boolean }; + premium?: boolean; onMarkAsRead: () => void; onDelete: () => void; } & HTMLAttributes; +const getNotificationMessage = ( + notification: Notification, + premium: boolean, + t: ReturnType>, +) => { + if (!premium) return t('anonymousCopyAlert'); + + if (notification.kind === 'view') { + return notification.actorName + ? t('userViewed', { user: notification.actorName }) + : t('guestViewed'); + } + + return notification.actorName + ? t('userCopied', { user: notification.actorName }) + : t('anonymousUserCopied'); +}; + export const NotificationEntry: React.FC = ({ notification, + premium = false, onMarkAsRead, onDelete, className, @@ -23,6 +43,9 @@ export const NotificationEntry: React.FC = ({ ...props }) => { const t = useTranslations('Inbox'); + const avatarUrl = premium ? notification.actorAvatarUrl : undefined; + const message = getNotificationMessage(notification, premium, t); + return (
= ({ }`} /> - +
-

{notification.message}

+

{message}

{notification.timestamp}
diff --git a/src/features/Navbar/Navbar.stories.tsx b/src/features/Navbar/Navbar.stories.tsx index bb364d5..ceaf87f 100644 --- a/src/features/Navbar/Navbar.stories.tsx +++ b/src/features/Navbar/Navbar.stories.tsx @@ -37,21 +37,26 @@ export const LoggedIn: Story = { {...args} iconUrl={MOCK_USER_AVATAR_URL} isLoggedIn + premium notifications={[ { id: '1', - message: t('anonymousUserCopied'), + kind: 'copy', + actorName: 'Mina Park', + actorAvatarUrl: MOCK_USER_AVATAR_URL, timestamp: t('minutesAgo', { count: 2 }), }, { id: '2', - message: t('userCopied', { user: 'xhev' }), + kind: 'view', + actorName: 'Sophie Laurent', + actorAvatarUrl: MOCK_USER_AVATAR_URL, timestamp: t('hoursAgo', { count: 1 }), - iconUrl: MOCK_USER_AVATAR_URL, }, { id: '3', - message: t('anonymousUserCopied'), + kind: 'copy', + isGuest: true, timestamp: t('hoursAgo', { count: 2 }), }, ]} diff --git a/src/features/Navbar/Navbar.tsx b/src/features/Navbar/Navbar.tsx index f7b167d..98027c8 100644 --- a/src/features/Navbar/Navbar.tsx +++ b/src/features/Navbar/Navbar.tsx @@ -11,6 +11,7 @@ type NavbarProps = { iconUrl?: string; isLoggedIn: boolean; notifications: Notifications; + premium?: boolean; onHomeClick?: () => void; onLoginClick: () => void; onProfileClick: () => void; @@ -25,6 +26,7 @@ export const Navbar: React.FC = ({ onHomeClick, onLoginClick, notifications, + premium = false, onProfileClick, onBumpProfileClick, onSettingsClick, @@ -57,7 +59,7 @@ export const Navbar: React.FC = ({ {isLoggedIn ? ( <> - +