diff --git a/packages/storybook/stories/gta-vi/1-Award.stories.tsx b/packages/storybook/stories/gta-vi/1-Award.stories.tsx new file mode 100644 index 0000000000..3fb9c35087 --- /dev/null +++ b/packages/storybook/stories/gta-vi/1-Award.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { Case, Page } from '../hero-cover/parts'; +import { GiveAwardFlow } from './production/GiveAward'; +import { CommentWithAward, ProfileAwards } from './production/AwardPreviews'; +import { productionAwards } from './data'; + +const meta: Meta = { + title: 'GTA VI/1. Vice City award', + parameters: { layout: 'fullscreen' }, + globals: { theme: 'dark' }, +}; + +export default meta; + +export const ViceCityAward: StoryObj = { + name: '1. Vice City award', + render: () => ( + + +
+ + +
+
+ + +
+ + +
+
+ + + + + + + + + + +
+ +
+
+
+ ), +}; diff --git a/packages/storybook/stories/gta-vi/2-Achievements.stories.tsx b/packages/storybook/stories/gta-vi/2-Achievements.stories.tsx new file mode 100644 index 0000000000..1f02ea0968 --- /dev/null +++ b/packages/storybook/stories/gta-vi/2-Achievements.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement } from 'react'; +import React from 'react'; +import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements'; +import { Case, Page } from '../hero-cover/parts'; +import { + AchievementDetail, + AchievementShelfCard, +} from './production/AchievementShelfCard'; +import type { GtaViMoment } from './data'; +import { + getShelf, + GTA_VI_RELEASE_AT, + readOnGtaDayAchievement, + viceCityAchievement, +} from './data'; + +const meta: Meta = { + title: 'GTA VI/2. Event achievements', + parameters: { layout: 'fullscreen' }, + globals: { theme: 'dark' }, +}; + +export default meta; + +/** Game Center's Achievement shelf: its header and grid, as production. */ +const Shelf = ({ moment }: { moment: GtaViMoment }): ReactElement => ( +
+

+ Achievement shelf +

+
+ {getShelf(moment).map((item) => ( + + ))} +
+
+); + +const unlocked = ( + achievement: UserAchievement['achievement'], +): UserAchievement => ({ + achievement, + progress: 1, + unlockedAt: GTA_VI_RELEASE_AT, + createdAt: null, + updatedAt: null, +}); + +const DetailFrame = ({ + userAchievement, +}: { + userAchievement: UserAchievement; +}): ReactElement => ( +
+ +
+); + +const moments: { moment: GtaViMoment; title: string; note: string }[] = [ + { + moment: 'releaseRead', + title: 'Nov 19, after a read', + note: 'Both event achievements lead the shelf. Made It to Vice City is everyone’s, so its chip says Free; the read is the one to earn, so its chip says Rare. Rarity percentages don’t exist on day one, so the chips name the kind instead. Click a card for its detail.', + }, + { + moment: 'releaseNotRead', + title: 'Nov 19, no read yet', + note: 'Opening daily.dev on release day unlocks Made It to Vice City. Still Read waits, grayed like any locked achievement, and says what to do instead of a 0 / 1 count.', + }, + { + moment: 'afterMissed', + title: 'Nov 20 on, the day missed', + note: 'Release day is gone, so Still Read can’t be earned and leaves the shelf. Made It to Vice City stays.', + }, + { + moment: 'before', + title: 'Before Nov 19', + note: 'Nothing on the shelf: the achievements are a surprise on the day. The streak calendar’s VI day is the teaser.', + }, +]; + +export const EventAchievements: StoryObj = { + name: '2. Event achievements', + render: () => ( +
+ + +
+ + +
+
+ + {moments.map(({ moment, title, note }) => ( + + + + ))} +
+
+ ), +}; diff --git a/packages/storybook/stories/gta-vi/3-Theme.stories.tsx b/packages/storybook/stories/gta-vi/3-Theme.stories.tsx new file mode 100644 index 0000000000..02a1ce6dcf --- /dev/null +++ b/packages/storybook/stories/gta-vi/3-Theme.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React, { useState } from 'react'; +import type { ReactElement } from 'react'; +import { Case, Page } from '../hero-cover/parts'; +import type { ThemeChoice } from './theme'; +import { SettingsPopover, ThemedHome } from './theme'; + +const meta: Meta = { + title: 'GTA VI/3. Vice City theme', + parameters: { layout: 'fullscreen' }, + globals: { theme: 'dark' }, +}; + +export default meta; + +const Popover = ({ initial }: { initial: ThemeChoice }): ReactElement => { + const [theme, setTheme] = useState(initial); + return ; +}; + +export const ViceCityTheme: StoryObj = { + name: '3. Vice City theme', + render: () => ( + + + + + + + + + + + + + + +
+ + +
+
+
+ ), +}; diff --git a/packages/storybook/stories/gta-vi/4-AllStates.stories.tsx b/packages/storybook/stories/gta-vi/4-AllStates.stories.tsx new file mode 100644 index 0000000000..773e01fc54 --- /dev/null +++ b/packages/storybook/stories/gta-vi/4-AllStates.stories.tsx @@ -0,0 +1,499 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { Case, Page } from '../hero-cover/parts'; +import { GiveAwardFlow } from './production/GiveAward'; +import { CommentWithAward, ProfileAwards } from './production/AwardPreviews'; +import { AchievementDetail } from './production/AchievementShelfCard'; +import { Shelf } from './production/Shelf'; +import { SettingsPopover, ThemedHome, ThemeRow } from './theme'; +import { DeviceRow, State, StateGrid } from './parts'; +import { + GTA_VI_RELEASE_AT, + productionAwards, + readOnGtaDayAchievement, + viceCityAchievement, +} from './data'; + +/* + * Every state of the three GTA VI pieces, for a careful review: each case is + * tagged Production (unchanged behavior), GTA VI (the change) or Proposed + * (a gap production doesn't cover yet). Devices rows load the Devices stories + * in real iframes at each width. + */ + +const meta: Meta = { + title: 'GTA VI/4. All states', + parameters: { layout: 'fullscreen' }, + globals: { theme: 'dark' }, +}; + +export default meta; + +const longName = 'Maximiliano Fernández-Castellanos de la Vega y Montoya'; +const maxNote = + `${'Patch notes, changelogs and a 4GB day-one download. '.repeat(8)}`.slice( + 0, + 400, + ); + +/* -------------------------------------------------------------- award */ + +export const Award: StoryObj = { + name: '1. Award', + render: () => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+ + + + + + + + + + +
+
+ ), +}; + +/* ------------------------------------------------------- achievements */ + +const unlocked = ( + achievement: typeof viceCityAchievement, +): Parameters[0]['userAchievement'] => ({ + achievement, + progress: 1, + unlockedAt: GTA_VI_RELEASE_AT, + createdAt: null, + updatedAt: null, +}); + +const DetailFrame = ({ + achievement, + isLocked = false, +}: { + achievement: typeof viceCityAchievement; + isLocked?: boolean; +}) => ( +
+ +
+); + +export const Achievements: StoryObj = { + name: '2. Achievements', + render: () => ( +
+ + + + + + + + + + + + + + + + {( + [ + ['releaseRead', 'Nov 19, after a read', 'GTA VI'], + ['releaseNotRead', 'Nov 19, no read yet', 'GTA VI'], + ['afterRead', 'Nov 20 on, read that day', 'GTA VI'], + ['afterMissed', 'Nov 20 on, the day missed', 'GTA VI'], + ['before', 'Before Nov 19', 'Production'], + ['loading', 'Loading', 'Production'], + ['empty', 'No achievements', 'Production'], + ['longText', 'Long names and descriptions', 'GTA VI'], + ['brokenImage', 'Images fail to load', 'Production'], + ] as const + ).map(([state, title, tag]) => ( + + +
+ +
+
+
+ ))} + + + + + + + +
+
+ ), +}; + +/* -------------------------------------------------------------- theme */ + +export const Theme: StoryObj = { + name: '3. Theme', + render: () => ( + + + + {(['dark', 'light', 'auto', 'viceCity'] as const).map((theme) => ( + + undefined} /> + + ))} + +
+ + Appearance + + undefined} /> +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ), +}; diff --git a/packages/storybook/stories/gta-vi/5-BuildNotes.stories.tsx b/packages/storybook/stories/gta-vi/5-BuildNotes.stories.tsx new file mode 100644 index 0000000000..b42e107de2 --- /dev/null +++ b/packages/storybook/stories/gta-vi/5-BuildNotes.stories.tsx @@ -0,0 +1,646 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Case, Page } from '../hero-cover/parts'; + +/* + * What a builder needs beyond the pictures: the production components and + * tokens each piece is made of, how every edge case is handled, the + * accessibility checks (contrast measured live from the tokens, so it + * follows the toolbar's theme), and the gaps to decide before building. + */ + +const meta: Meta = { + title: 'GTA VI/5. Build notes', + // Not pinned to dark, so the toolbar's theme switch re-measures contrast. + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +const Table = ({ + head, + rows, +}: { + head: string[]; + rows: ReactNode[][]; +}): ReactElement => ( +
+ + + + {head.map((cell) => ( + + ))} + + + + {rows.map((row, index) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {cell} +
+ {cell} +
+
+); + +const Code = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +/* ------------------------------------------------------------ contrast */ + +type Rgba = [number, number, number, number]; + +const parseColor = (value: string): Rgba | null => { + const rgb = value.match(/rgba?\(([^)]+)\)/); + if (rgb) { + const [r, g, b, a = '1'] = rgb[1].split(/[\s,/]+/).filter(Boolean); + return [Number(r), Number(g), Number(b), Number(a)]; + } + const srgb = value.match(/color\(srgb ([^)]+)\)/); + if (srgb) { + const [r, g, b, a = '1'] = srgb[1].split(/[\s/]+/).filter(Boolean); + return [Number(r) * 255, Number(g) * 255, Number(b) * 255, Number(a)]; + } + return null; +}; + +const over = (top: Rgba, bottom: Rgba): Rgba => { + const a = top[3]; + return [ + top[0] * a + bottom[0] * (1 - a), + top[1] * a + bottom[1] * (1 - a), + top[2] * a + bottom[2] * (1 - a), + 1, + ]; +}; + +const luminance = ([r, g, b]: Rgba): number => { + const channel = (value: number) => { + const c = value / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +}; + +const ratio = (a: Rgba, b: Rgba): number => { + const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (light + 0.05) / (dark + 0.05); +}; + +type Kind = 'text' | 'large' | 'ui'; + +const thresholds: Record = { + text: { aa: 4.5, aaa: 7 }, + large: { aa: 3, aaa: 4.5 }, + ui: { aa: 3, aaa: null }, +}; + +const Badge = ({ pass, label }: { pass: boolean; label: string }) => ( + + {label} {pass ? 'pass' : 'fail'} + +); + +/** + * Measures one pairing from the live tokens: the sample's text color over + * its background, over the page (or a worst-case art color). + */ +const Contrast = ({ + label, + where, + fg, + bg, + base, + kind, +}: { + label: string; + where: string; + fg: string; + bg: string; + /** A color behind the background, e.g. the art's lightest tone. */ + base?: string; + kind: Kind; +}): ReactElement => { + const sampleRef = useRef(null); + const [value, setValue] = useState(null); + const [theme, setTheme] = useState(document.documentElement.className); + + useEffect(() => { + const observer = new MutationObserver(() => + setTheme(document.documentElement.className), + ); + observer.observe(document.documentElement, { attributes: true }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const sample = sampleRef.current; + if (!sample) { + return; + } + const page = parseColor( + getComputedStyle(document.body).getPropertyValue('background-color'), + ); + const pageColor = + page && page[3] > 0 + ? page + : (parseColor( + getComputedStyle(sample.closest('.bg-background-default') ?? sample) + .backgroundColor, + ) as Rgba); + const baseColor = base + ? (parseColor(base) as Rgba) + : (pageColor ?? [14, 18, 23, 1]); + const background = over( + parseColor( + getComputedStyle(sample.parentElement as Element).backgroundColor, + ) ?? [0, 0, 0, 0], + baseColor, + ); + const foreground = over( + parseColor(getComputedStyle(sample).color) ?? [0, 0, 0, 1], + background, + ); + setValue(ratio(foreground, background)); + }, [base, bg, fg, theme]); + + const { aa, aaa } = thresholds[kind]; + return ( + + {label} + {where} + + + + Aa + + + + + {value ? `${value.toFixed(2)}:1` : '…'} + + + {value && ( + + = aa} label="AA" /> + {aaa && = aaa} label="AAA" />} + + )} + + + { + { text: 'Text', large: 'Large or bold text', ui: 'Icon / non-text' }[ + kind + ] + } + + + ); +}; + +/* -------------------------------------------------------------- page */ + +export const BuildNotes: StoryObj = { + name: '5. Build notes', + render: () => ( + + + + GiveAwardModal › AwardItem{' '} + (components/modals/award) + , + 'None to the component. A product with value 0 already reads Free; the backend lists it first during launch week.', + <> + ButtonVariant.Float, bg-surface-float,{' '} + rounded-14, image size-20,{' '} + TypographyType.Footnote /{' '} + TypographyColor.Secondary + , + ], + [ + 'Free footer', + <> + GiveAwardModal › CommentScreen + , + <> + For an event product, a Footnote line replaces{' '} + AwardFeesNote. + , + <> + TypographyType.Footnote,{' '} + TypographyColor.Tertiary; expired:{' '} + TypographyColor.StatusError + , + ], + [ + 'Award received', + <> + CommentAwardActions, PostAwardAction, + profile BadgesAndAwards › Award + , + 'None. The image renders at size-6 (comment), size-8 (profile).', + 'The art itself: light frame, white-outlined VI, 74% of its canvas like the other awards.', + ], + [ + 'Event chips', + <> + AchievementShelfCard{' '} + (features/profile/components/achievements) + , + <> + An event field on Achievement swaps + the <1% rare chip for Free or Rare, with{' '} + MedalBadgeIcon. + , + <> + Free: bg-accent-cabbage-default with{' '} + text-background-default, which flips with the theme + (any fixed ink fails AA at 14px in one of them). Rare: the + card's existing rarityPillClasses.{' '} + rounded-max. + , + ], + [ + 'Unlocked date', + AchievementShelfCard, + 'Event cards say "Unlocked Nov 19, 2026" (always the year) and the detail adds CalendarIcon.', + <> + TypographyType.Subhead,{' '} + TypographyColor.Tertiary,{' '} + IconSize.Size16 + , + ], + [ + 'Theme option', + <> + ThemeSection (ProfileMenu/sections), shown by{' '} + SidebarDesktopV2's settings popover,{' '} + ProfileMenu and Settings › Appearance + , + <> + A fourth themes entry. Picking it sets dark mode + plus the cover. + , + <> + ButtonGroup, ButtonSize.XSmall,{' '} + ButtonVariant.Float when active; glyph gradient + from --theme-accent-cabbage/bacon/bun-default + , + ], + [ + 'Home cover', + 'The home page header (Hero Cover rail header)', + 'Art behind the shortcuts and greeting, fading into the page.', + <> + Fade to background-default; shortcut labels on{' '} + bg-overlay-primary-pepperrounded-6; + no raw colors + , + ], + ]} + /> + + + +
+ + + +
+
+ + + {['Pair', 'Where', 'Sample', 'Ratio', 'WCAG', 'Kind'].map( + (cell) => ( + + ), + )} + + + + + + + + + + + + + +
+ {cell} +
+ +
+ + + + + + + + A normal award product with value: 0 and{' '} + flags.availableUntil; the products query hides it + after that date, and the award mutation rejects it after that + date. No client logic beyond the expired message. + , + ], + [ + 'Spam: a free award costs nothing', + 'Reuse the existing one-award-per-entity rule (userState.awarded). No new limit.', + ], + [ + 'Free awards and revenue share', + 'Value 0 means no transaction and no revenue share; AwardFeesNote is replaced for it.', + ], + [ + 'Achievement type has no event field', + <> + Add event?: 'free' | 'rare' to{' '} + Achievement in graphql/user/achievements.ts (types + are hand-written) and to the query fragment. + , + ], + [ + 'Which day counts as Nov 19', + 'The reader’s timezone (user.timezone), as the streak does. Granting: Vice City on any boot that day; Still Read on the first read that day, from the same event that counts the streak.', + ], + [ + 'Rarity on event achievements', + 'Exclude them from rarity: the chip names the kind, and they shouldn’t push others out of the <1% band.', + ], + [ + 'Still Read after the day', + 'The backend stops returning it to readers who don’t have it once Nov 20 starts in their timezone.', + ], + [ + 'Storing the theme', + <> + Add viceCity to the settings theme enum on the + backend. Clients that don’t know it fall back to dark, so old + extension versions don’t break. + , + ], + [ + 'Light mode and the theme', + 'VI is dark-only: picking it sets dark. Picking Light or Auto turns VI off.', + ], + [ + 'After the event', + 'Keep VI in the Theme row; people who like it keep it. Revisit at the next event.', + ], + [ + 'Feature flags', + <> + gta_vi_award, gta_vi_achievements,{' '} + gta_vi_theme in featureManagement.ts, all default + false. + , + ], + [ + 'Analytics', + <> + Theme: the existing LogEvent.ChangeSettings with{' '} + target_id: viceCity. Award: existing award events + carry the product id. + , + ], + [ + 'Found in production: the card description never clamps', + <> + AchievementShelfCard sets{' '} + line-clamp-1 hidden tablet:block, and{' '} + tablet:block overrides the clamp's display. + Fix separately with tablet:line-clamp-1 if product + wants one line; it changes every card, so it's not part of + this work. + , + ], + [ + 'Found in production: the achievement image fallback 404s', + <> + AchievementShelfCard's{' '} + fallbackImage is{' '} + https://daily.dev/default-achievement.png, which + returns 404. Point it at an image on media.daily.dev. + , + ], + [ + 'Assets', + 'Move the art to webapp/public/app/assets and load with fromCDN; give the cover explicit height so it never shifts the layout.', + ], + ]} + /> + + + ), +}; diff --git a/packages/storybook/stories/gta-vi/Devices.stories.tsx b/packages/storybook/stories/gta-vi/Devices.stories.tsx new file mode 100644 index 0000000000..f15ac76b2f --- /dev/null +++ b/packages/storybook/stories/gta-vi/Devices.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { GiveAwardFlow } from './production/GiveAward'; +import { CommentWithAward, ProfileAwards } from './production/AwardPreviews'; +import { Shelf } from './production/Shelf'; +import { FeedHeroProviders } from '../features/feed/feedHero.mocks'; +import { ThemedHome } from './theme'; + +/* + * One case per story, filling the window at its own size. The All states + * pages load these in iframes at each device's width, so media queries + * (tablet:, laptop:) and the modal's phone sheet behave as in the app. + */ + +const meta: Meta = { + title: 'GTA VI/Devices', + parameters: { layout: 'fullscreen' }, + globals: { theme: 'dark' }, +}; + +export default meta; + +/** Production's modal overlay, the sheet at the bottom on phones. */ +const ModalBackdrop = ({ children }: { children: ReactNode }): ReactElement => ( + // #__next is a stretched column in production's CSS, so the layout sits + // on a child. +
+
+ {children} +
+
+); + +const Page = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+); + +export const AwardPicker: StoryObj = { + name: 'Award picker', + render: () => ( + + + + ), +}; + +export const AwardNote: StoryObj = { + name: 'Award note', + render: () => ( + + + + ), +}; + +export const AwardReceived: StoryObj = { + name: 'Award received', + render: () => ( + +
+ +
+ +
+
+
+ ), +}; + +export const AchievementShelf: StoryObj = { + name: 'Achievement shelf', + render: () => ( + + + + ), +}; + +export const AchievementDetail: StoryObj = { + name: 'Achievement detail', + render: () => ( + + + + ), +}; + +export const ThemeOn: StoryObj = { + name: 'Theme on', + render: () => ( + + + + ), +}; + +export const ThemePicking: StoryObj = { + name: 'Theme picking', + render: () => ( + + + + ), +}; + +export const ThemeOff: StoryObj = { + name: 'Theme off', + render: () => ( + + + + ), +}; diff --git a/packages/storybook/stories/gta-vi/assets/achievement-read-on-gta-day.webp b/packages/storybook/stories/gta-vi/assets/achievement-read-on-gta-day.webp new file mode 100644 index 0000000000..c6a7163e03 Binary files /dev/null and b/packages/storybook/stories/gta-vi/assets/achievement-read-on-gta-day.webp differ diff --git a/packages/storybook/stories/gta-vi/assets/achievement-vice-city.webp b/packages/storybook/stories/gta-vi/assets/achievement-vice-city.webp new file mode 100644 index 0000000000..35f9d373f6 Binary files /dev/null and b/packages/storybook/stories/gta-vi/assets/achievement-vice-city.webp differ diff --git a/packages/storybook/stories/gta-vi/assets/award-gta-vi.webp b/packages/storybook/stories/gta-vi/assets/award-gta-vi.webp new file mode 100644 index 0000000000..f614d36932 Binary files /dev/null and b/packages/storybook/stories/gta-vi/assets/award-gta-vi.webp differ diff --git a/packages/storybook/stories/gta-vi/assets/theme-vice-city-cover.webp b/packages/storybook/stories/gta-vi/assets/theme-vice-city-cover.webp new file mode 100644 index 0000000000..386af76497 Binary files /dev/null and b/packages/storybook/stories/gta-vi/assets/theme-vice-city-cover.webp differ diff --git a/packages/storybook/stories/gta-vi/data.ts b/packages/storybook/stories/gta-vi/data.ts new file mode 100644 index 0000000000..a08650e25d --- /dev/null +++ b/packages/storybook/stories/gta-vi/data.ts @@ -0,0 +1,377 @@ +import type { Product } from '@dailydotdev/shared/src/graphql/njord'; +import type { + Achievement, + UserAchievement, +} from '@dailydotdev/shared/src/graphql/user/achievements'; +import { AchievementType } from '@dailydotdev/shared/src/graphql/user/achievements'; +import awardArt from './assets/award-gta-vi.webp'; +import viceCityArt from './assets/achievement-vice-city.webp'; +import readOnGtaDayArt from './assets/achievement-read-on-gta-day.webp'; + +/* + * The GTA VI launch beyond the streak (see stories/streak/gtaViParts.tsx): + * a free award, two event achievements and an opt-in home theme. Everything + * here is data; the award catalog and the shelf's other achievements are + * copied from production (the `products` and `achievements` queries, + * 2026-11-10). + */ + +export const GTA_VI_RELEASE_DAY = '2026-11-19'; +export const GTA_VI_RELEASE_AT = `${GTA_VI_RELEASE_DAY}T12:00:00.000Z`; + +/* ---------------------------------------------------------------- award */ + +const media = (path: string): string => + `https://media.daily.dev/image/upload/${path}`; + +const award = ( + id: string, + name: string, + image: string, + value: number, +): Product => ({ id, type: 'award', name, image, value }); + +export const gtaViAward: Product = { + id: 'gta-vi-award', + type: 'award', + name: 'Vice City', + image: awardArt, + value: 0, + flags: { description: 'Vice City', imageGlow: awardArt }, +}; + +export const productionAwards: Product[] = [ + award( + 'a39d7368', + 'Smiley Face', + media('s--Ft4p9rjO--/f_auto/v1743664345/public/happy'), + 10, + ), + award( + 'ef2e2633', + 'Laughing Face', + media('s--THFmDVKR--/f_auto/v1743664345/public/Lol'), + 25, + ), + award( + '8aeb45ce', + 'Heart', + media('s--IPWhKcis--/f_auto/v1743664345/public/Love'), + 50, + ), + award( + '352f1e07', + 'Coffee Mug', + media('s--aenMzSPZ--/f_auto/v1743664347/public/Coffee'), + 75, + ), + award( + '1b536ed1', + 'Star', + media('s--PdFJ22B8--/f_auto/v1743664345/public/Star'), + 100, + ), + award( + '17432ba4', + 'Hot Dog', + media('s--zOtftlFL--/f_auto/v1743664345/public/hotDog'), + 125, + ), + award( + '1ab1bc67', + 'Pizza', + media('s--n35mhpLB--/f_auto/v1743664345/public/Pizza'), + 150, + ), + award( + 'c0d239b9', + 'Stash of Cash', + media('s--7SPVvVPK--/f_auto/v1743664345/public/Cash'), + 250, + ), + award( + '8c0257d7', + 'Balloon Brackets', + media('s--eJMxq8lw--/f_auto/v1747300201/public/Curly%20Brackets'), + 300, + ), + award( + '0951d2c8', + 'Terminal', + media('s--KBzEqo01--/f_auto/v1743664345/public/Terminal'), + 400, + ), + award( + '7bd171f2', + 'Rubber Duck', + media('s--Q65Srvfv--/f_auto/v1743664345/public/Duck'), + 600, + ), + award( + '5efc4400', + 'Robotic Bug', + media('s--wJzQYBN0--/f_auto/v1747300201/public/Bug'), + 750, + ), + award( + '57c82004', + 'Gaming Controller', + media('s--IMOkH2qD--/f_auto/v1747300201/public/Controller'), + 900, + ), + award( + 'e9dab194', + 'Keyboard', + media('s--ezX7VAyg--/f_auto/v1743664345/public/Keyboard'), + 1200, + ), + award( + '9d2131e8', + 'Shady Hacker', + media('s--UvWxsXT5--/f_auto/v1747300201/public/Hacker'), + 1500, + ), +]; + +export const receiver = { name: 'nerdalytics', username: 'nerdalytics' }; + +/* --------------------------------------------------------- achievements */ + +export type GtaViAchievementKind = 'free' | 'rare'; + +export interface GtaViAchievement extends Achievement { + /** Event achievements carry their own chip instead of the rarity one. */ + event: GtaViAchievementKind; +} + +export const viceCityAchievement: GtaViAchievement = { + id: 'gta-vi-made-it', + name: 'Made It to Vice City', + description: 'You survived the wait. Now go make some memories.', + image: viceCityArt, + type: AchievementType.Instant, + criteria: { targetCount: 1 }, + xp: 10, + rarity: null, + unit: null, + event: 'free', +}; + +export const readOnGtaDayAchievement: GtaViAchievement = { + id: 'gta-vi-still-read', + name: 'Still Read on GTA VI Day', + description: + 'While the world was in Vice City, you still made time to read. Respect.', + image: readOnGtaDayArt, + type: AchievementType.Instant, + criteria: { targetCount: 1 }, + xp: 25, + rarity: null, + unit: null, + event: 'rare', +}; + +const achievement = ( + name: string, + description: string, + image: string, + targetCount: number, + rarity: number, +): Achievement => ({ + id: name, + name, + description, + image, + type: AchievementType.Milestone, + criteria: { targetCount }, + xp: 10, + rarity, + unit: null, +}); + +const owned = ( + item: Achievement, + progress: number, + unlockedAt: string | null = null, +): UserAchievement => ({ + achievement: item, + progress, + unlockedAt, + createdAt: null, + updatedAt: null, +}); + +/** The reader's shelf as production orders it: in progress, then rarest. */ +export const productionShelf: UserAchievement[] = [ + owned( + achievement( + 'On the path', + 'Complete 50 quests', + media('s--iwqvFWLT--/q_auto/v1773743172/achievements/on_the_path'), + 50, + 0.21, + ), + 46, + ), + owned( + achievement( + 'Committed', + 'Reach a 50-day reading streak', + media('v1770222887/achievements/Comitted.png'), + 50, + 1.69, + ), + 38, + ), + owned( + achievement( + 'Hero', + 'Complete 100 quests', + media('s--5WqXv9y7--/q_auto/v1773743176/achievements/heros_quest'), + 100, + 0.12, + ), + 46, + ), + owned( + achievement( + 'I took "daily dev" literally', + 'Reach a 365-day reading streak', + media('v1770224984/achievements/I_took_dailydev_literally.png'), + 365, + 0.25, + ), + 38, + ), + owned( + achievement( + "Can't spend it all", + 'Spend 10000 Cores', + media('s--_MjhSTze--/q_auto/v1773608417/achievements/cant_spend_it_all'), + 10000, + 0.01, + ), + 10000, + '2026-06-23T10:00:00.000Z', + ), + owned( + achievement( + 'In the big league', + 'Reach 10,000 reputation', + media('v1770222928/achievements/In_the_big_league.png'), + 10000, + 0.05, + ), + 10000, + '2026-02-11T10:00:00.000Z', + ), + owned( + achievement( + 'Organized', + 'Create a bookmark folder', + media('v1770222923/achievements/Organized.png'), + 1, + 0.08, + ), + 1, + '2026-02-11T10:00:00.000Z', + ), + owned( + achievement( + 'True chad', + 'Receive 1000 upvotes', + media('v1770222934/achievements/True_chad.png'), + 1000, + 0.1, + ), + 1000, + '2026-04-28T10:00:00.000Z', + ), + owned( + achievement( + 'Coraholic', + 'Spend 1000 Cores', + media('s--SNnLKKWe--/q_auto/v1773608419/achievements/coraholic'), + 1000, + 0.12, + ), + 1000, + '2026-03-16T10:00:00.000Z', + ), + owned( + achievement( + 'Poll it', + 'Create a poll', + media('s--J20Dc3CL--/q_auto/v1773608418/achievements/poll_it'), + 1, + 0.13, + ), + 1, + '2026-05-02T10:00:00.000Z', + ), + owned( + achievement( + 'Altruistic', + 'Give your first award', + media('s--bwQRPWPt--/q_auto/v1770802226/achievements/altruistic.png'), + 1, + 0.41, + ), + 1, + '2026-03-04T10:00:00.000Z', + ), + owned( + achievement( + 'Prophet', + 'Reach 100 followers', + media('v1770222920/achievements/Prophet.png'), + 100, + 0.21, + ), + 100, + '2026-07-19T10:00:00.000Z', + ), + owned( + achievement( + 'Curator', + 'Have 10 different shared links clicked', + media('v1770222887/achievements/Curator.png'), + 10, + 0.44, + ), + 10, + '2026-08-07T10:00:00.000Z', + ), +]; + +/** + * Where the reader stands against release day: before it, on it before and + * after their first read, and after it with or without that read. + */ +export type GtaViMoment = + | 'before' + | 'releaseNotRead' + | 'releaseRead' + | 'afterRead' + | 'afterMissed'; + +/** The event achievements first, then production's shelf, per moment. */ +export const getShelf = (moment: GtaViMoment): UserAchievement[] => { + const unlockedViceCity = owned(viceCityAchievement, 1, GTA_VI_RELEASE_AT); + const event: Record = { + // Nothing before release: the surprise is on the day. + before: [], + releaseNotRead: [unlockedViceCity, owned(readOnGtaDayAchievement, 0)], + releaseRead: [ + unlockedViceCity, + owned(readOnGtaDayAchievement, 1, GTA_VI_RELEASE_AT), + ], + afterRead: [ + unlockedViceCity, + owned(readOnGtaDayAchievement, 1, GTA_VI_RELEASE_AT), + ], + // The day is gone, so the read can't be earned any more: it leaves. + afterMissed: [unlockedViceCity], + }; + return [...event[moment], ...productionShelf].slice(0, 15); +}; diff --git a/packages/storybook/stories/gta-vi/parts.tsx b/packages/storybook/stories/gta-vi/parts.tsx new file mode 100644 index 0000000000..738952f74e --- /dev/null +++ b/packages/storybook/stories/gta-vi/parts.tsx @@ -0,0 +1,97 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { Frame } from '../startups-onboarding/frame'; + +/* + * Review furniture for the All states pages: a state grid with a label and + * a tag per case, and a row of real device frames (iframes at each width, + * from Devices.stories.tsx). + */ + +export type Tag = 'Production' | 'GTA VI' | 'Proposed'; + +const tagClass: Record = { + Production: 'bg-surface-float text-text-tertiary', + 'GTA VI': 'bg-accent-cabbage-default text-background-default', + Proposed: 'bg-accent-bun-default text-background-default', +}; + +export const State = ({ + title, + note, + tag, + children, +}: { + title: string; + note?: string; + tag: Tag; + children: ReactNode; +}): ReactElement => ( +
+
+ + + {title} + + + {tag} + + + {note && {note}} +
+ {children} +
+); + +export const StateGrid = ({ + children, +}: { + children: ReactNode; +}): ReactElement => ( +
{children}
+); + +export const devices = [ + { label: 'Phone · 375', width: 375, height: 812, shown: 200 }, + { label: 'Tablet · 768', width: 768, height: 1024, shown: 300 }, + { label: 'Laptop · 1280', width: 1280, height: 800, shown: 420 }, + { label: 'Laptop L · 1440', width: 1440, height: 900, shown: 460 }, + { label: 'Desktop · 1920', width: 1920, height: 1080, shown: 520 }, +]; + +/** One story in a real iframe at every device width; click opens it live. */ +export const DeviceRow = ({ + storyId, + title, +}: { + storyId: string; + title: string; +}): ReactElement => ( +
+ {devices.map((device) => ( +
+ {device.label} + + + Open full size + +
+ ))} +
+); diff --git a/packages/storybook/stories/gta-vi/production/AchievementShelfCard.tsx b/packages/storybook/stories/gta-vi/production/AchievementShelfCard.tsx new file mode 100644 index 0000000000..518c1c7ec9 --- /dev/null +++ b/packages/storybook/stories/gta-vi/production/AchievementShelfCard.tsx @@ -0,0 +1,337 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements'; +import { getTargetCount } from '@dailydotdev/shared/src/graphql/user/achievements'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { LazyImage } from '@dailydotdev/shared/src/components/LazyImage'; +import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; +import CloseButton from '@dailydotdev/shared/src/components/CloseButton'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { + ModalKind, + ModalSize, +} from '@dailydotdev/shared/src/components/modals/common/types'; +import { MedalBadgeIcon } from '@dailydotdev/shared/src/components/icons/MedalBadge'; +import { CalendarIcon } from '@dailydotdev/shared/src/components/icons/Calendar'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + AchievementRarityTier, + getAchievementRarityTier, +} from '@dailydotdev/shared/src/features/profile/components/achievements/achievementRarity'; +import type { GtaViAchievementKind } from '../data'; + +/* + * Production's AchievementShelfCard (features/profile/components/achievements) + * with the GTA VI branch: an event achievement swaps the "<1% rare" chip for + * its own (Free for everyone's, Rare for the read), says "Unlocked" with the + * year, and its detail view keeps the date beside a calendar. Everything else + * is production's, line for line, so the diff is the production change. + */ + +const fallbackImage = 'https://daily.dev/default-achievement.png'; + +const formatUnlockedAt = (value: string): string => { + const date = new Date(value); + const now = new Date(); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }), + }); +}; + +// An event happened on one day, so its date reads in full. +const formatEventDate = (value: string): string => + new Date(value).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + +const rarityPillClasses = 'bg-[#efab27] text-[#08110c]'; + +const eventPillClasses: Record = { + // Ink that flips with the theme: cabbage is light in dark mode and dark in + // light mode, and a fixed ink falls under 4.5:1 at 14px in one of them. + free: 'bg-accent-cabbage-default text-background-default', + rare: rarityPillClasses, +}; + +const eventPillLabel: Record = { + free: 'Free', + rare: 'Rare', +}; + +const EventPill = ({ + event, + isLarge, +}: { + event: GtaViAchievementKind; + isLarge?: boolean; +}): ReactElement => ( + + + {eventPillLabel[event]} + +); + +const getEvent = ( + userAchievement: UserAchievement, +): GtaViAchievementKind | undefined => + (userAchievement.achievement as { event?: GtaViAchievementKind }).event; + +/** The detail view the card opens, also drawn on its own for review. */ +export const AchievementDetail = ({ + userAchievement, + onClose, +}: { + userAchievement: UserAchievement; + onClose?: () => void; +}): ReactElement => { + const { achievement, progress, unlockedAt } = userAchievement; + const event = getEvent(userAchievement); + const targetCount = getTargetCount(achievement); + const isUnlocked = unlockedAt !== null; + const progressPercentage = Math.min((progress / targetCount) * 100, 100); + const isRarest = + isUnlocked && + getAchievementRarityTier(achievement.rarity) === + AchievementRarityTier.Emerald; + const progressLabel = `${progress.toLocaleString()} / ${targetCount.toLocaleString()}`; + + return ( +
+
+ {achievement.name} + {event && } + {!event && isRarest && ( + + <1% rare + + )} + +
+
+ + {achievement.name} + + + {achievement.description} + + + {isUnlocked && event && ( + + Unlocked {formatEventDate(unlockedAt)} + + + )} + {isUnlocked && !event && ( + + Completed {formatUnlockedAt(unlockedAt)} + + )} + {!isUnlocked && event && ( + + Read a post before midnight to unlock + + )} + {!isUnlocked && !event && ( + <> + + {progressLabel} + + + + )} +
+
+ ); +}; + +export function AchievementShelfCard({ + userAchievement, + initialExpanded = false, +}: { + userAchievement: UserAchievement; + /** Opens the detail on load, for review pages. */ + initialExpanded?: boolean; +}): ReactElement { + const [isExpanded, setIsExpanded] = useState(initialExpanded); + const { achievement, progress, unlockedAt } = userAchievement; + const event = getEvent(userAchievement); + const targetCount = getTargetCount(achievement); + const isUnlocked = unlockedAt !== null; + const progressPercentage = Math.min((progress / targetCount) * 100, 100); + const isRarest = + isUnlocked && + getAchievementRarityTier(achievement.rarity) === + AchievementRarityTier.Emerald; + const progressLabel = `${progress.toLocaleString()} / ${targetCount.toLocaleString()}`; + + return ( + <> +
+ + +
+ +
+ + {isExpanded && ( + setIsExpanded(false)} + kind={ModalKind.FlexibleCenter} + size={ModalSize.XSmall} + className="overflow-hidden" + > + setIsExpanded(false)} + /> + + )} + + ); +} diff --git a/packages/storybook/stories/gta-vi/production/AwardPreviews.tsx b/packages/storybook/stories/gta-vi/production/AwardPreviews.tsx new file mode 100644 index 0000000000..bc7ddb19f5 --- /dev/null +++ b/packages/storybook/stories/gta-vi/production/AwardPreviews.tsx @@ -0,0 +1,195 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { ActivityContainer } from '@dailydotdev/shared/src/components/profile/ActivitySection'; +import { + Award, + BadgesAndAwardsSkeleton, + SummaryCard, +} from '@dailydotdev/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwardsComponents'; +import { + DiscussIcon, + DownvoteIcon, + ShareIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { fallbackImages } from '@dailydotdev/shared/src/lib/config'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import type { Product } from '@dailydotdev/shared/src/graphql/njord'; +import { gtaViAward, productionAwards } from '../data'; + +/* + * Where a received Vice City award shows, as production draws any award: + * the comment's award count (CommentAwardActions) with the featured award at + * size-6, and the profile's Badges & Awards widget at size-8. Nothing here + * changes for the event award; it's the same image at the same sizes. + */ + +const ActionIcon = ({ + icon, + label, + count, +}: { + icon: ReactElement; + label: string; + count?: number; +}): ReactElement => ( + +); + +/** A comment that received awards, with one of them featured. */ +export const CommentWithAward = ({ + numAwards = 3, + featured = gtaViAward, + text = "Day-one patch notes are already 4GB. I'll read the changelog here while it downloads.", + className, +}: { + numAwards?: number; + featured?: Product; + text?: string; + className?: string; +}): ReactElement => ( +
+
+ +
+ + nerdalytics + + + @nerdalytics · 2h + +
+
+ + {text} + +
+ } label="Upvote" count={42} /> + } label="Downvote" /> + } label="Reply" /> + + } label="Share" /> +
+
+); + +export type ProfileAwardsState = + | 'loading' + | 'empty' + | 'single' + | 'some' + | 'many'; + +const counts = [6, 3, 12, 1, 2, 5, 1, 2, 9, 4, 1, 3, 2, 1, 7]; + +const awardsFor = ( + state: ProfileAwardsState, +): { id: string; image: string; count: number }[] => { + const viceCity = { id: gtaViAward.id, image: gtaViAward.image, count: 4 }; + const others = productionAwards.map((award, index) => ({ + id: award.id, + image: award.image, + count: counts[index], + })); + if (state === 'empty' || state === 'loading') { + return []; + } + if (state === 'single') { + return [{ ...viceCity, count: 1 }]; + } + if (state === 'some') { + return [viceCity, ...others.slice(0, 8)]; + } + return [{ ...viceCity, count: 1284 }, ...others]; +}; + +/** The profile's Badges & Awards widget, the Vice City award first. */ +export const ProfileAwards = ({ + state = 'some', +}: { + state?: ProfileAwardsState; +}): ReactElement => { + if (state === 'loading') { + return ; + } + const awards = awardsFor(state); + const total = awards.reduce((sum, award) => sum + award.count, 0); + return ( + + + Badges & Awards + + Learn more +
+ + +
+ {awards.length > 0 && ( +
+ {awards.map((award) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/packages/storybook/stories/gta-vi/production/GiveAward.tsx b/packages/storybook/stories/gta-vi/production/GiveAward.tsx new file mode 100644 index 0000000000..2c5b675456 --- /dev/null +++ b/packages/storybook/stories/gta-vi/production/GiveAward.tsx @@ -0,0 +1,569 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import CloseButton from '@dailydotdev/shared/src/components/CloseButton'; +import { + ArrowIcon, + CoreIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { AwardFeesNote } from '@dailydotdev/shared/src/components/cores/AwardFeesNote'; +import { ElementPlaceholder } from '@dailydotdev/shared/src/components/ElementPlaceholder'; +import { maxNoteLength } from '@dailydotdev/shared/src/contexts/GiveAwardModalContext'; +import { fallbackImages } from '@dailydotdev/shared/src/lib/config'; +import { featuredAwardImage } from '@dailydotdev/shared/src/lib/image'; +import { labels } from '@dailydotdev/shared/src/lib/labels'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import type { Product } from '@dailydotdev/shared/src/graphql/njord'; +import { gtaViAward, productionAwards, receiver } from '../data'; + +/* + * GiveAwardModal's three screens (components/modals/award) drawn inline, so + * they sit side by side for review. The markup is production's; the GTA VI + * branch is the event award first in the grid, drawn like every other award, + * and a free event award's footer, which says until when it's free. Every + * state the modal can be in is a prop, so a review page can pin each one. + */ + +export type AwardStep = 'INTRO' | 'COMMENT' | 'SUCCESS'; + +/** Where the send stands: production's mutation, plus the event's window. */ +export type SendState = 'idle' | 'sending' | 'error' | 'expired'; + +/** + * Phone is production's bottom sheet (ModalKind.FlexibleTop below 656px), + * the rest the centered XSmall modal; auto picks by the window, for device + * frames. + */ +export type ModalLayout = 'phone' | 'desktop' | 'auto'; + +const PHONE_BELOW = 656; + +const isPhoneLayout = (layout: ModalLayout): boolean => + layout === 'phone' || + (layout === 'auto' && + typeof window !== 'undefined' && + window.innerWidth < PHONE_BELOW); + +export interface AwardScenario { + /** The viewer's Core balance. */ + balance?: number; + /** The products query hasn't answered yet. */ + isLoading?: boolean; + /** Launch week: Nov 19 through Nov 25. */ + isEventLive?: boolean; + receiverName?: string; + receiverUsername?: string; + /** Awards the post or comment already has. */ + numAwards?: number; + sendState?: SendState; + note?: string; +} + +const ModalFrame = ({ + isPhone, + isFluid, + header, + footer, + children, +}: { + isPhone: boolean; + isFluid: boolean; + header: ReactNode; + footer?: ReactNode; + children: ReactNode; +}): ReactElement => ( +
+
+ {header} +
+
+ {children} +
+ {footer && ( +
+ {footer} +
+ )} +
+); + +/** BuyCreditsButton: the balance and the + to buy more. */ +const Balance = ({ amount }: { amount: number }): ReactElement => ( + + + + Balance: + {largeNumberFormat(amount)} + Cores + + + + +); + +const priceLabel = (product: Product): string => + product.value === 0 ? 'Free' : formatCoresCurrency(product.value); + +const AwardItem = ({ + item, + onClick, +}: { + item: Product; + onClick: () => void; +}): ReactElement => ( + +); + +/** Proposed: production paints an empty grid until the products load. */ +const AwardItemPlaceholder = (): ReactElement => ( + + + + +); + +const ReceiverName = ({ + scenario, +}: { + scenario: AwardScenario; +}): ReactElement => ( + + {scenario.receiverName || `@${scenario.receiverUsername}`} + +); + +const IntroScreen = ({ + isPhone, + isFluid, + scenario, + onPick, +}: { + isPhone: boolean; + isFluid: boolean; + scenario: Required; + onPick: (product: Product) => void; +}): ReactElement => { + const [buyFor, setBuyFor] = useState(null); + const hasAwards = scenario.numAwards > 0; + const awards = scenario.isEventLive + ? [gtaViAward, ...productionAwards] + : productionAwards; + + return ( + + + {isPhone ? ( + + ) : ( + + )} + + } + footer={ + buyFor && ( + <> + + + + ) + } + > +
+ + + {hasAwards + ? `${largeNumberFormat(scenario.numAwards)} Award${ + scenario.numAwards === 1 ? '' : 's' + } given` + : 'Give an Award'} + +
+ {hasAwards && ( + + )} + + Show your appreciation! Pick an Award to send to{' '} + ! + +
+ {scenario.isLoading + ? Array.from({ length: 12 }, (_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + )) + : awards.map((item) => ( + { + // Production: an Award the balance can't cover stays on + // this screen and offers Cores in the footer. + if (item.value > scenario.balance) { + setBuyFor(item); + return; + } + setBuyFor(null); + onPick(item); + }} + /> + ))} +
+
+ ); +}; + +/** Production's error toast (useTransactionError), pinned to the modal. */ +const Toast = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+); + +const CommentScreen = ({ + isPhone, + isFluid, + scenario, + product, + onBack, + onSend, +}: { + isPhone: boolean; + isFluid: boolean; + scenario: Required; + product: Product; + onBack: () => void; + onSend: () => void; +}): ReactElement => { + const isEvent = product.id === gtaViAward.id; + const isExpired = isEvent && scenario.sendState === 'expired'; + const hasAwards = scenario.numAwards > 0; + + return ( + <> + + + {isExpired && ( + // Proposed: the free window closed while the modal was open. + + The Vice City award stopped being free on Nov 25. Go back to + pick another Award. + + )} + {isEvent && !isExpired && ( + + Free to send through Nov 25, as many times as you like. + + )} + {!isEvent && } + + } + > +
+ + + Awesome choice! We're sure that{' '} + will appreciate this award! + +
+ +