diff --git a/.claude/launch.json b/.claude/launch.json index 7477c90ce6d..98a69bf035a 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -34,6 +34,33 @@ ], "port": 5002, "url": "https://app.staging.daily.dev:5002" + }, + { + "name": "storybook", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "--filter", + "@dailydotdev/storybook", + "dev" + ], + "port": 6006 + }, + { + "name": "storybook-static", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "--filter", + "@dailydotdev/storybook", + "exec", + "vite", + "preview", + "--outDir", + "storybook-static", + "--port", + "6007", + "--strictPort" + ], + "port": 6007 } ] -} \ No newline at end of file +} diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index 167b0a9714d..a84ba18e2d8 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -133,6 +133,29 @@ const preview: Preview = { 'Tips exploration', ], ], + 'Cores & Boost', + [ + 'Flow board', + 'Flows', + [ + '1. First Cores', + '2. Give an award', + '3. Receive an award', + '4. Wallet and buying', + '5. First boost', + '6. After the boost', + ], + 'Redesign', + [ + '0. Today vs redesign', + '1. First Cores', + '2. Give an award', + '3. Receive an award', + '4. Wallet and buying', + '5. First boost', + '6. After the boost', + ], + ], ], }, }, diff --git a/packages/storybook/stories/cores-boost/FlowBoard.stories.tsx b/packages/storybook/stories/cores-boost/FlowBoard.stories.tsx new file mode 100644 index 00000000000..90cbca3c2e1 --- /dev/null +++ b/packages/storybook/stories/cores-boost/FlowBoard.stories.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Board } from './board'; +import { BoardContent, frames } from './journeys'; + +const meta: Meta = { + title: 'Cores & Boost/Flow board', + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const TodayAndFixed: Story = { + name: 'Today and fixed', + render: () => ( + + + + ), +}; diff --git a/packages/storybook/stories/cores-boost/assets/boost-strip-patchy-v2.png b/packages/storybook/stories/cores-boost/assets/boost-strip-patchy-v2.png new file mode 100644 index 00000000000..8b54f304c3a Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/boost-strip-patchy-v2.png differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--nudge-on-own-post.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--nudge-on-own-post.jpg new file mode 100644 index 00000000000..8b51a59629d Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--nudge-on-own-post.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--quest-reward.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--quest-reward.jpg new file mode 100644 index 00000000000..e821a3e9eaf Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--quest-reward.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--wallet-use-your-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--wallet-use-your-cores.jpg new file mode 100644 index 00000000000..ebf077c086c Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--wallet-use-your-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--your-first-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--your-first-cores.jpg new file mode 100644 index 00000000000..1c99d061176 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-1-first-cores--your-first-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--buy-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--buy-cores.jpg new file mode 100644 index 00000000000..b10bbd127cd Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--buy-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--flow@award-sent.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--flow@award-sent.jpg new file mode 100644 index 00000000000..165699b9b29 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--flow@award-sent.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--note-and-send.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--note-and-send.jpg new file mode 100644 index 00000000000..1972cfdf2c6 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--note-and-send.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--old-ios.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--old-ios.jpg new file mode 100644 index 00000000000..6b9fca6465d Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--old-ios.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--pick-an-award.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--pick-an-award.jpg new file mode 100644 index 00000000000..6ec0c3f48c3 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--pick-an-award.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--processing.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--processing.jpg new file mode 100644 index 00000000000..e8e46ac6cdd Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-2-give-an-award--processing.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-3-receive-an-award--award-received-with-context.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-3-receive-an-award--award-received-with-context.jpg new file mode 100644 index 00000000000..faf82bf336a Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-3-receive-an-award--award-received-with-context.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-3-receive-an-award--balance-refreshes.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-3-receive-an-award--balance-refreshes.jpg new file mode 100644 index 00000000000..0cd671ab7af Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-3-receive-an-award--balance-refreshes.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-3-receive-an-award--wallet-row-highlighted.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-3-receive-an-award--wallet-row-highlighted.jpg new file mode 100644 index 00000000000..420193d0ac7 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-3-receive-an-award--wallet-row-highlighted.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--back-to-the-wallet.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--back-to-the-wallet.jpg new file mode 100644 index 00000000000..b6e3a53807d Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--back-to-the-wallet.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--cores.jpg new file mode 100644 index 00000000000..02b26c5b6ad Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--walkthrough@you-got-your-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--walkthrough@you-got-your-cores.jpg new file mode 100644 index 00000000000..15d352c9108 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--walkthrough@you-got-your-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--wallet.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--wallet.jpg new file mode 100644 index 00000000000..7a936ec839a Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--wallet.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--ways-in.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--ways-in.jpg new file mode 100644 index 00000000000..6dfd7077f81 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-4-wallet-and-buying--ways-in.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-form-reading.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-form-reading.jpg new file mode 100644 index 00000000000..664608045f8 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-form-reading.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-form.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-form.jpg new file mode 100644 index 00000000000..4a904d93dd9 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-form.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-is-live.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-is-live.jpg new file mode 100644 index 00000000000..52804a274ba Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-is-live.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-launches.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-launches.jpg new file mode 100644 index 00000000000..a143dccf2ef Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--boost-launches.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--buy-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--buy-cores.jpg new file mode 100644 index 00000000000..223a1b0b8f4 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--buy-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--flow@processing.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--flow@processing.jpg new file mode 100644 index 00000000000..4c8cefb55ef Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--flow@processing.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--own-post-new.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--own-post-new.jpg new file mode 100644 index 00000000000..ce84f2bcc8b Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--own-post-new.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--own-post-taking-off.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--own-post-taking-off.jpg new file mode 100644 index 00000000000..7155e26af68 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-5-first-boost--own-post-taking-off.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--boost-again.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--boost-again.jpg new file mode 100644 index 00000000000..a37a8cc8255 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--boost-again.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--boosts-page-empty.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--boosts-page-empty.jpg new file mode 100644 index 00000000000..291d9353729 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--boosts-page-empty.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--boosts-page-story.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--boosts-page-story.jpg new file mode 100644 index 00000000000..4f36d5ce922 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--boosts-page-story.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--campaign-ended.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--campaign-ended.jpg new file mode 100644 index 00000000000..fd532f5d794 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--campaign-ended.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--campaign-live.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--campaign-live.jpg new file mode 100644 index 00000000000..e519c2cd4ff Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--campaign-live.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--ended-notification.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--ended-notification.jpg new file mode 100644 index 00000000000..089adb148f3 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--ended-notification.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--live-notification.jpg b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--live-notification.jpg new file mode 100644 index 00000000000..2c846455b60 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/flows-6-after-the-boost--live-notification.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--nudge-on-own-post.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--nudge-on-own-post.jpg new file mode 100644 index 00000000000..037d9b89ebf Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--nudge-on-own-post.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--quest-reward.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--quest-reward.jpg new file mode 100644 index 00000000000..e821a3e9eaf Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--quest-reward.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--wallet-use-your-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--wallet-use-your-cores.jpg new file mode 100644 index 00000000000..5816003e31f Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--wallet-use-your-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--your-first-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--your-first-cores.jpg new file mode 100644 index 00000000000..b42f5db7aba Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-1-first-cores--your-first-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--award-sent.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--award-sent.jpg new file mode 100644 index 00000000000..cb4dea64127 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--award-sent.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--buy-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--buy-cores.jpg new file mode 100644 index 00000000000..950c4aea6a4 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--buy-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--note-and-send.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--note-and-send.jpg new file mode 100644 index 00000000000..f83ede8e45a Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--note-and-send.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--old-ios.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--old-ios.jpg new file mode 100644 index 00000000000..a9ee6d8ecdb Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--old-ios.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--pick-an-award.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--pick-an-award.jpg new file mode 100644 index 00000000000..97d25cb53ea Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--pick-an-award.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--processing.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--processing.jpg new file mode 100644 index 00000000000..5788d78ba8f Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-2-give-an-award--processing.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-3-receive-an-award--award-received-with-context.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-3-receive-an-award--award-received-with-context.jpg new file mode 100644 index 00000000000..25c664af760 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-3-receive-an-award--award-received-with-context.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-3-receive-an-award--balance-refreshes.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-3-receive-an-award--balance-refreshes.jpg new file mode 100644 index 00000000000..f29881d056e Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-3-receive-an-award--balance-refreshes.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-3-receive-an-award--wallet-row-highlighted.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-3-receive-an-award--wallet-row-highlighted.jpg new file mode 100644 index 00000000000..2872a7941a0 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-3-receive-an-award--wallet-row-highlighted.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--back-to-the-wallet.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--back-to-the-wallet.jpg new file mode 100644 index 00000000000..9c3ef83b96e Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--back-to-the-wallet.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--cores.jpg new file mode 100644 index 00000000000..3ed9f31273e Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--wallet-screen.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--wallet-screen.jpg new file mode 100644 index 00000000000..3fcd3cf18a9 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--wallet-screen.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--ways-in.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--ways-in.jpg new file mode 100644 index 00000000000..bba52c6b9a0 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--ways-in.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--you-got-your-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--you-got-your-cores.jpg new file mode 100644 index 00000000000..19cc2f8ec1c Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-4-wallet-and-buying--you-got-your-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-form-custom.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-form-custom.jpg new file mode 100644 index 00000000000..d18b906b353 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-form-custom.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-form-reading.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-form-reading.jpg new file mode 100644 index 00000000000..6552aea1c21 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-form-reading.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-form.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-form.jpg new file mode 100644 index 00000000000..b503a3ebe75 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-form.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-is-live.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-is-live.jpg new file mode 100644 index 00000000000..3c3f39fb1ee Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-is-live.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-launches.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-launches.jpg new file mode 100644 index 00000000000..2666ab48ee3 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--boost-launches.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--buy-cores.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--buy-cores.jpg new file mode 100644 index 00000000000..0276e389a77 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--buy-cores.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--own-post-new.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--own-post-new.jpg new file mode 100644 index 00000000000..41ada16c4e7 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--own-post-new.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--own-post-taking-off.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--own-post-taking-off.jpg new file mode 100644 index 00000000000..ce9e8cf7952 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--own-post-taking-off.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--processing.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--processing.jpg new file mode 100644 index 00000000000..3d55ac64071 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-5-first-boost--processing.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boost-again.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boost-again.jpg new file mode 100644 index 00000000000..d97e1d0aabf Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boost-again.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boost-is-live.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boost-is-live.jpg new file mode 100644 index 00000000000..d43243597f5 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boost-is-live.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boosts-page-empty.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boosts-page-empty.jpg new file mode 100644 index 00000000000..cd02c453b59 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boosts-page-empty.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boosts-page-story.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boosts-page-story.jpg new file mode 100644 index 00000000000..2224401ad36 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--boosts-page-story.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--campaign-ended.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--campaign-ended.jpg new file mode 100644 index 00000000000..a610f3980a0 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--campaign-ended.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--campaign-live.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--campaign-live.jpg new file mode 100644 index 00000000000..57c0d495502 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--campaign-live.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--ended-notification.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--ended-notification.jpg new file mode 100644 index 00000000000..089adb148f3 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--ended-notification.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--live-notification.jpg b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--live-notification.jpg new file mode 100644 index 00000000000..2c846455b60 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/compare/redesign-6-after-the-boost--live-notification.jpg differ diff --git a/packages/storybook/stories/cores-boost/assets/cores-checkout-friendship.png b/packages/storybook/stories/cores-boost/assets/cores-checkout-friendship.png new file mode 100644 index 00000000000..31bf5cba979 Binary files /dev/null and b/packages/storybook/stories/cores-boost/assets/cores-checkout-friendship.png differ diff --git a/packages/storybook/stories/cores-boost/board.tsx b/packages/storybook/stories/cores-boost/board.tsx new file mode 100644 index 00000000000..df6efd35350 --- /dev/null +++ b/packages/storybook/stories/cores-boost/board.tsx @@ -0,0 +1,688 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons/Core'; +import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost'; +import { MedalIcon } from '@dailydotdev/shared/src/components/icons/Medal'; +import { BellIcon } from '@dailydotdev/shared/src/components/icons/Bell'; +import { CreditCardIcon } from '@dailydotdev/shared/src/components/icons/CreditCard'; +import { WarningIcon } from '@dailydotdev/shared/src/components/icons/Warning'; +import { SparkleIcon } from '@dailydotdev/shared/src/components/icons/Sparkle'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; +import { MinusIcon } from '@dailydotdev/shared/src/components/icons/Minus'; + +// A Miro-style board: an infinite dotted canvas you pan by dragging (or with +// the wheel) and zoom with pinch / ctrl + wheel. Frames hold lanes of steps, +// and stickies hang under the step they talk about. + +export type Surface = + | 'Page' + | 'Modal' + | 'Sheet' + | 'Notification' + | 'Toast' + | 'Menu' + | 'Checkout' + | 'Email' + | 'System' + | 'Widget'; + +export type StepStatus = + | 'same' + | 'changed' + | 'new' + | 'broken' + | 'deadend' + | 'removed'; + +export type NoteTone = 'pain' | 'fix' | 'data' | 'voice' | 'backend'; + +export interface Note { + tone: NoteTone; + text: ReactNode; +} + +export interface Step { + surface: Surface; + title: string; + // Mini screen lines. "[Label]" draws a primary button, "(Label)" a + // secondary one, "# Text" a heading, "~" a skeleton line, "! Text" an + // error line, anything else plain text. + ui?: string[]; + where?: string; + status?: StepStatus; + via?: string; + notes?: Note[]; +} + +const surfaceIcon: Record = { + Page: , + Modal: , + Sheet: , + Notification: , + Toast: , + Menu: , + Checkout: , + Email: , + System: , + Widget: , +}; + +const statusBadge: Record< + StepStatus, + { label: string; className: string } | null +> = { + same: null, + changed: { + label: 'Changed', + className: 'bg-accent-cabbage-flat text-accent-cabbage-default', + }, + new: { + label: 'New', + className: 'bg-accent-avocado-flat text-accent-avocado-default', + }, + broken: { + label: 'Broken', + className: 'bg-accent-ketchup-flat text-accent-ketchup-default', + }, + deadend: { + label: 'Dead end', + className: 'bg-accent-ketchup-flat text-accent-ketchup-default', + }, + removed: { + label: 'Removed', + className: 'bg-surface-float text-text-tertiary', + }, +}; + +const statusBorder: Partial> = { + changed: 'border-accent-cabbage-default', + new: 'border-accent-avocado-default', + broken: 'border-accent-ketchup-default', + deadend: 'border-accent-ketchup-default border-dashed', + removed: 'border-border-subtlest-tertiary border-dashed opacity-60', +}; + +const noteStyle: Record< + NoteTone, + { label: string; className: string; labelClassName: string } +> = { + pain: { + label: 'Pain point', + className: 'bg-accent-ketchup-flat', + labelClassName: 'text-accent-ketchup-default', + }, + fix: { + label: 'Fix', + className: 'bg-accent-avocado-flat', + labelClassName: 'text-accent-avocado-default', + }, + data: { + label: 'Data', + className: 'bg-accent-water-flat', + labelClassName: 'text-accent-water-default', + }, + voice: { + label: 'Users said', + className: 'bg-accent-cheese-flat', + labelClassName: 'text-accent-cheese-default', + }, + backend: { + label: 'Needs backend', + className: 'bg-accent-onion-flat', + labelClassName: 'text-accent-onion-default', + }, +}; + +const tilts = ['-rotate-1', 'rotate-1', 'rotate-0', '-rotate-1', 'rotate-1']; + +export const Sticky = ({ + tone, + children, + index = 0, + className, +}: { + tone: NoteTone; + children: ReactNode; + index?: number; + className?: string; +}): ReactElement => { + const style = noteStyle[tone]; + return ( +
+ + {style.label} + +
{children}
+
+ ); +}; + +const UiLine = ({ line }: { line: string }): ReactElement => { + if (line === '~') { + return ; + } + const parts = line + .split(/(\[[^\]]+\]|\([^)]+\))/) + .filter((part) => part.trim()); + if (parts.some((part) => /^[[(]/.test(part) && /[\])]$/.test(part))) { + return ( + + {parts.map((part, index) => { + const key = `${part}-${index}`; + if (part.startsWith('[') && part.endsWith(']')) { + return ( + + {part.slice(1, -1)} + + ); + } + if (part.startsWith('(') && part.endsWith(')')) { + return ( + + {part.slice(1, -1)} + + ); + } + return ( + + {part.trim()} + + ); + })} + + ); + } + if (line.startsWith('# ')) { + return {line.slice(2)}; + } + if (line.startsWith('! ')) { + return ( + + {line.slice(2)} + + ); + } + return {line}; +}; + +export const STEP_WIDTH = 'w-60'; + +export const StepCard = ({ step }: { step: Step }): ReactElement => { + const badge = statusBadge[step.status ?? 'same']; + return ( +
+
+ + {surfaceIcon[step.surface]} + + {step.surface} + {badge && ( + + {badge.label} + + )} +
+ {step.title} + {step.ui && ( +
+ {step.ui.map((line, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} +
+ )} + {step.where && ( + + {step.where} + + )} +
+ ); +}; + +const Connector = ({ label }: { label?: string }): ReactElement => ( +
+ {label && ( + + {label} + + )} +
+ +
+
+); + +// Steps are a fixed 15rem wide and connectors 6rem, so a branch lane can +// start right under the step it leaves from. +const branchOffsetRem = (from: number): number => from * (15 + 6); + +export const Lane = ({ + label, + tone = 'today', + steps, + from, +}: { + label: string; + tone?: 'today' | 'fixed' | 'branch'; + steps: Step[]; + from?: number; +}): ReactElement => ( +
+ + {from !== undefined ? `↳ ${label}` : label} + +
+ {steps.map((step, index) => ( + + {index > 0 && } +
+ + {step.notes?.map((note, noteIndex) => ( + + {note.text} + + ))} +
+
+ ))} +
+
+); + +export const Metric = ({ + value, + label, +}: { + value: string; + label: string; +}): ReactElement => ( +
+ {value} + {label} +
+); + +export const frameIcons = { + core: , + boost: , + award: , + bell: , + card: , + warning: , + sparkle: , +}; + +export const Frame = ({ + id, + number, + title, + icon, + summary, + metrics, + children, + className, +}: { + id: string; + number: string; + title: string; + icon?: ReactElement; + summary?: ReactNode; + metrics?: { value: string; label: string }[]; + children: ReactNode; + className?: string; +}): ReactElement => ( +
+ + {number} · {title} + +
+
+
+ {icon && ( + + {icon} + + )} +

{title}

+
+ {summary && ( +
+ {summary} +
+ )} + {metrics && ( +
+ {metrics.map((metric) => ( + + ))} +
+ )} +
+ {children} +
+
+); + +interface View { + x: number; + y: number; + scale: number; +} + +const MIN_SCALE = 0.04; +const MAX_SCALE = 2; +const clamp = (value: number): number => + Math.min(MAX_SCALE, Math.max(MIN_SCALE, value)); + +export const Board = ({ + frames, + children, +}: { + frames: { id: string; title: string }[]; + children: ReactNode; +}): ReactElement => { + const viewportRef = useRef(null); + const contentRef = useRef(null); + const [view, setView] = useState({ x: 0, y: 0, scale: 0.3 }); + const [isDragging, setIsDragging] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const drag = useRef<{ x: number; y: number } | null>(null); + + const fitRect = useCallback( + (left: number, top: number, width: number, height: number) => { + const viewport = viewportRef.current; + if (!viewport) { + return; + } + const padding = 48; + const toolbar = 72; + const availableWidth = viewport.clientWidth - padding * 2; + const availableHeight = viewport.clientHeight - padding * 2 - toolbar; + const scale = clamp( + Math.min(availableWidth / width, availableHeight / height), + ); + setIsAnimating(true); + setView({ + scale, + x: (viewport.clientWidth - width * scale) / 2 - left * scale, + y: + padding + + toolbar + + (availableHeight - height * scale) / 2 - + top * scale, + }); + }, + [], + ); + + const fitAll = useCallback(() => { + const content = contentRef.current; + if (content) { + fitRect(0, 0, content.offsetWidth, content.offsetHeight); + } + }, [fitRect]); + + const goToFrame = useCallback( + (id: string) => { + const frame = contentRef.current?.querySelector( + `[data-frame="${id}"]`, + ); + if (frame) { + fitRect( + frame.offsetLeft, + frame.offsetTop, + frame.offsetWidth, + frame.offsetHeight, + ); + } + }, + [fitRect], + ); + + const zoomAt = useCallback((factor: number, cx?: number, cy?: number) => { + const viewport = viewportRef.current; + if (!viewport) { + return; + } + const originX = cx ?? viewport.clientWidth / 2; + const originY = cy ?? viewport.clientHeight / 2; + setView((current) => { + const scale = clamp(current.scale * factor); + const ratio = scale / current.scale; + return { + scale, + x: originX - (originX - current.x) * ratio, + y: originY - (originY - current.y) * ratio, + }; + }); + }, []); + + useEffect(() => { + // Fonts and icons settle the frame sizes a beat after mount. + const timeout = setTimeout(fitAll, 150); + return () => clearTimeout(timeout); + }, [fitAll]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) { + return undefined; + } + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + setIsAnimating(false); + if (event.ctrlKey || event.metaKey) { + const rect = viewport.getBoundingClientRect(); + zoomAt( + Math.exp(-event.deltaY * 0.01), + event.clientX - rect.left, + event.clientY - rect.top, + ); + return; + } + setView((current) => ({ + ...current, + x: current.x - event.deltaX, + y: current.y - event.deltaY, + })); + }; + viewport.addEventListener('wheel', onWheel, { passive: false }); + return () => viewport.removeEventListener('wheel', onWheel); + }, [zoomAt]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement) { + return; + } + if (event.key === '+' || event.key === '=') { + zoomAt(1.2); + } else if (event.key === '-') { + zoomAt(1 / 1.2); + } else if (event.key === '0' || event.key === 'f') { + fitAll(); + } else { + const index = Number(event.key) - 1; + if (frames[index]) { + goToFrame(frames[index].id); + } + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [fitAll, frames, goToFrame, zoomAt]); + + const dotSize = 24 * view.scale; + + return ( +
{ + if ((event.target as HTMLElement).closest('[data-board-ui]')) { + return; + } + drag.current = { x: event.clientX, y: event.clientY }; + setIsDragging(true); + setIsAnimating(false); + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + if (!drag.current) { + return; + } + const dx = event.clientX - drag.current.x; + const dy = event.clientY - drag.current.y; + drag.current = { x: event.clientX, y: event.clientY }; + setView((current) => ({ + ...current, + x: current.x + dx, + y: current.y + dy, + })); + }} + onPointerUp={() => { + drag.current = null; + setIsDragging(false); + }} + > +
setIsAnimating(false)} + > + {children} +
+ + + +
+ + + {Math.round(view.scale * 100)}% + + + +
+ +
+ Drag or scroll to move · pinch or ⌘ + scroll to zoom · 1–9 jumps to a + frame · F fits the board +
+
+ ); +}; diff --git a/packages/storybook/stories/cores-boost/flows/AfterBoost.stories.tsx b/packages/storybook/stories/cores-boost/flows/AfterBoost.stories.tsx new file mode 100644 index 00000000000..3af54bf5760 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/AfterBoost.stories.tsx @@ -0,0 +1,278 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import type { Campaign } from '@dailydotdev/shared/src/graphql/campaigns'; +import { CoresProviders } from '../real/providers'; +import { coresHandlers } from '../real/handlers'; +import { BoostsPage, CampaignPage } from './after-boost/BoostsPage'; +import { + BoostEndedNotification, + BoostLiveNotification, + NotificationsColumn, +} from './after-boost/BoostNotifications'; +import { BoostAgainModal } from './after-boost/BoostAgainModal'; +import { + durationOf, + endedCampaign, + liveCampaign, +} from './after-boost/fixtures'; + +// The fixed "After the boost" lane from the flow board: only the screens that +// change. Built from the production boost components (see after-boost/). + +const BALANCE = 5000; + +const meta: Meta = { + title: 'Cores & Boost/Flows/6. After the boost', + parameters: { + layout: 'fullscreen', + msw: { handlers: coresHandlers({ balance: BALANCE }) }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const lastBoostOf = (campaign: Campaign) => { + const days = durationOf(campaign); + + return { + days, + coresPerDay: Math.round((campaign.flags.budget ?? 0) / days), + }; +}; + +/** Boost again from the ended notification, over the notifications page */ +const BoostAgainScreen = ({ + onSeeBoost, +}: { + onSeeBoost?: () => void; +}): ReactElement => { + const [isOpen, setIsOpen] = useState(false); + useEffect(() => setIsOpen(true), []); + + return ( + <> + + setIsOpen(true)} + /> + + {isOpen && ( + setIsOpen(false)} + onSeeBoost={() => { + setIsOpen(false); + onSeeBoost?.(); + }} + /> + )} + + ); +}; + +enum Screen { + Boosts = 'Boosts page', + Campaign = 'Campaign', + Live = 'Boost is live', + Ended = 'Boost ended', + Again = 'Boost again', +} + +const screens = Object.values(Screen); + +const WalkthroughStory = (): ReactElement => { + const [screen, setScreen] = useState(Screen.Boosts); + const [campaign, setCampaign] = useState(liveCampaign); + const index = screens.indexOf(screen); + const openCampaign = (next: Campaign) => { + setCampaign(next); + setScreen(Screen.Campaign); + }; + const goTo = (next: Screen) => { + if (next === Screen.Campaign) { + setCampaign(liveCampaign); + } + setScreen(next); + }; + + return ( + <> + {screen === Screen.Boosts && } + {screen === Screen.Campaign && ( + setScreen(Screen.Boosts)} + onStop={() => goTo(Screen.Live)} + onExtend={() => goTo(Screen.Live)} + onBoostAgain={() => setScreen(Screen.Again)} + /> + )} + {screen === Screen.Live && ( + + openCampaign(liveCampaign)} + /> + + )} + {screen === Screen.Ended && ( + + openCampaign(endedCampaign)} + onBoostAgain={() => setScreen(Screen.Again)} + /> + + )} + {screen === Screen.Again && ( + setScreen(Screen.Boosts)} /> + )} + + + ); +}; + +export const Walkthrough: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'The five changed screens in order. Their own buttons move along the flow (open a campaign, See your boost, Boost again), and the bar at the bottom jumps to any step.', + }, + }, + }, +}; + +export const BoostsPageStory: Story = { + name: '1 · Boosts page', + render: () => , + parameters: { + docs: { + description: { + story: + 'New: the Ads dashboard modal becomes a /boosts page with Live and Ended tabs and all-time totals, including clicks on the post.', + }, + }, + }, +}; + +export const BoostsPageEmpty: Story = { + name: '1 · Boosts page, nothing live', + render: () => , + parameters: { + docs: { + description: { + story: 'The /boosts page before any boost, with the empty Live tab.', + }, + }, + }, +}; + +export const CampaignLive: Story = { + name: '2 · Campaign', + render: () => , + parameters: { + docs: { + description: { + story: + 'Changed: the campaign is a page, shows clicks on the post, adds Extend, and the stop button names the refund before stopping.', + }, + }, + }, +}; + +export const CampaignEnded: Story = { + name: '2 · Campaign, ended', + render: () => , + parameters: { + docs: { + description: { + story: + 'Changed: an ended campaign keeps production’s Boost again, which now opens the boost modal prefilled.', + }, + }, + }, +}; + +export const LiveNotification: Story = { + name: '3 · Boost is live', + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: + 'Changed: sent when the boost starts instead of at 70% spend ("Your post boost is 70% done"), and names the post with a See your boost button.', + }, + }, + }, +}; + +export const EndedNotification: Story = { + name: '4 · Boost ended, with results', + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: + 'Changed: "Your post boost has ended" now carries the results and the refunded Cores, with Boost again on the row.', + }, + }, + }, +}; + +export const BoostAgain: Story = { + name: '5 · Boost again, prefilled', + render: () => , + parameters: { + docs: { + description: { + story: + 'Changed: Boost again opens the boost modal (today it crashes) prefilled with the last budget and duration, which it names.', + }, + }, + }, +}; diff --git a/packages/storybook/stories/cores-boost/flows/FirstBoost.stories.tsx b/packages/storybook/stories/cores-boost/flows/FirstBoost.stories.tsx new file mode 100644 index 00000000000..68b0ef381f5 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/FirstBoost.stories.tsx @@ -0,0 +1,269 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoresProviders, noop } from '../real/providers'; +import { coresHandlers } from '../real/handlers'; +import { boostablePost } from '../real/screens'; +import { Stage } from './purchase/stage'; +import type { OwnPostState } from './purchase/BoostFixed'; +import { + BoostLaunchingModal, + BoostLiveFixedModal, + BoostPostFixedModal, + OwnPostFixed, + starterBoost, +} from './purchase/BoostFixed'; +import { + BuyCoresFixedModal, + BuyCoresProcessingFixed, +} from './purchase/BuyCoresFixed'; + +// "First boost", fixed lane of the flow board. Only the changed screens; +// Paddle checkout is production as is. + +const balance = 20; + +/** Just published and untagged, so production is still reading it */ +const newPost = { + ...boostablePost, + id: 'sb-new-post', + createdAt: new Date().toISOString(), + tags: [], +} as Post; + +type Step = 'post' | 'form' | 'buy' | 'processing' | 'launching' | 'live'; + +const Walkthrough = (): ReactElement => { + const { user, updateUser } = useAuthContext(); + const [step, setStep] = useState('post'); + const [budget, setBudget] = useState(starterBoost); + const total = budget.coresPerDay * budget.totalDays; + const toPost = () => setStep('post'); + const onLaunched = useCallback(() => { + if (user) { + updateUser({ + ...user, + balance: { amount: user.balance.amount - total }, + }); + } + setStep('live'); + }, [total, updateUser, user]); + + return ( + { + if (user) { + updateUser({ ...user, balance: { amount: balance } }); + } + setStep('form'); + }} + /> + } + > + {step === 'form' && ( + { + setBudget(next); + setStep('buy'); + }} + onBoost={(next) => { + setBudget(next); + setStep('launching'); + }} + onRequestClose={toPost} + /> + )} + {step === 'buy' && ( + { + setStep('processing'); + setTimeout(() => { + if (user) { + updateUser({ + ...user, + balance: { amount: user.balance.amount + pack.value }, + }); + } + setStep('launching'); + }, 1500); + }} + onRequestClose={() => setStep('form')} + /> + )} + {step === 'processing' && ( + + )} + {step === 'launching' && ( + setStep('form')} + onRequestClose={toPost} + /> + )} + {step === 'live' && ( + + )} + + ); +}; + +/** Your own post behind the boost modals, as the strip leaves it */ +const OwnPostBackdrop = (): ReactElement => ( + undefined} /> +); + +const meta: Meta = { + title: 'Cores & Boost/Flows/5. First boost', + parameters: { + layout: 'fullscreen', + msw: { handlers: coresHandlers({ balance }) }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const story = ( + name: string, + description: string, + render: () => ReactElement, + startBalance = balance, +): Story => ({ + name, + parameters: { + msw: { handlers: coresHandlers({ balance: startBalance }) }, + docs: { description: { story: description } }, + }, + render: () => ( + + {render()} + + ), +}); + +const OwnPost = ({ state }: { state: OwnPostState }) => ( + + } + /> +); + +export const Flow = story( + 'Walkthrough', + 'The whole fixed flow with 20 Cores: Boost from the strip, take the starter preset, buy the pack that covers it, and the boost starts by itself.', + () => , +); + +export const OwnPostNew = story( + '1 · Own post', + 'The new-post strip gets its own Boost button and shows on article posts too, next to the Boost button in the header.', + () => , +); + +export const OwnPostTakingOff = story( + '1 · Own post · taking off', + 'New: posts with traction from authors who never boosted get a "This post is taking off" strip.', + () => , +); + +export const BoostForm = story( + '2 · Boost your post', + 'A starter preset (1 day · 2,000 Cores) sits next to the custom sliders, and with a low balance the CTA says "Get 1,980 Cores and boost" instead of "Boost post for 50K".', + () => ( + }> + + + ), +); + +export const BoostFormReading = story( + '2 · Boost your post · reading the post', + 'While a new post is still being processed, the estimate says "Reading your post to estimate reach…" instead of a bare spinner.', + () => ( + }> + + + ), +); + +export const BuyCores = story( + '3 · Buy Cores, pack preselected', + 'The smallest pack covering the boost is selected, and the copy says "boost" instead of "Award".', + () => ( + }> + + + ), +); + +export const BoostLaunches = story( + '5 · Boost launches', + 'New: once the Cores land, the boost the user set up starts by itself, with a Change option, instead of a second press on the form.', + () => ( + }> + + + ), + 2420, +); + +export const BoostIsLive = story( + '6 · Boost is live!', + 'The success modal points to the Boosts page ("See your boost") instead of opening the ads dashboard modal ("Track performance").', + () => ( + }> + + + ), + 420, +); + +// The story index reads names statically, not through the helper +Flow.storyName = 'Walkthrough'; +OwnPostNew.storyName = '1 · Own post'; +OwnPostTakingOff.storyName = '1 · Own post · taking off'; +BoostForm.storyName = '2 · Boost your post'; +BoostFormReading.storyName = '2 · Boost your post · reading the post'; +BuyCores.storyName = '3 · Buy Cores, pack preselected'; +BoostLaunches.storyName = '5 · Boost launches'; +BoostIsLive.storyName = '6 · Boost is live!'; diff --git a/packages/storybook/stories/cores-boost/flows/FirstCores.stories.tsx b/packages/storybook/stories/cores-boost/flows/FirstCores.stories.tsx new file mode 100644 index 00000000000..f5735eaca0e --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/FirstCores.stories.tsx @@ -0,0 +1,227 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { mocked } from 'storybook/test'; +import mockedRouter, { useRouter } from 'next/router'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoresRole } from '@dailydotdev/shared/src/lib/user'; +import { CoresProviders } from '../real/providers'; +import { Shell } from './cores/Shell'; +import { FlowStepper } from './cores/FlowStepper'; +import type { FirstCoresPersona } from './cores/FirstCoresSheet'; +import { FirstCoresSheet } from './cores/FirstCoresSheet'; +import { FixedWallet } from './cores/FixedWallet'; +import { GatedQuestCard } from './cores/GatedEntryPoints'; +import { questHandlers, SwapInPage } from '../real/pages'; +import { findQuestCard, OwnPostScreen, QuestScreen } from './cores/screens'; +import { firstQuestReward, readQuest, walletHandlers } from './cores/fixtures'; + +// Fixed lane of "First Cores: earning to a first spend" on the flow board. +// Only the screens the fix changes or adds; the rest stays as production. + +const FIRST_REWARD = 20; +const NUDGE_BALANCE = 5400; + +type Args = { persona: FirstCoresPersona }; + +const meta: Meta = { + title: 'Cores & Boost/Flows/1. First Cores', + parameters: { layout: 'fullscreen' }, + beforeEach: () => { + mocked(useRouter).mockReturnValue({ + ...mockedRouter, + isReady: true, + pathname: '/', + asPath: '/', + } as never); + }, + args: { persona: 'reader' }, + argTypes: { + persona: { + control: { type: 'inline-radio' }, + options: ['reader', 'poster'], + description: 'Posters see Boost first, readers see Award first', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const handlers = walletHandlers(FIRST_REWARD, [firstQuestReward]); + +const steps = [ + 'Quest reward', + 'Your first Cores', + 'Wallet', + 'Nudge on own post', +]; + +const WalkthroughFlow = ({ + persona, +}: { + persona: FirstCoresPersona; +}): ReactElement => { + const { user, updateUser } = useAuthContext(); + const [step, setStep] = useState(0); + + // Jumping ahead lands on the balance that step expects. The nudge comes + // later, once the balance covers the smallest boost. + useEffect(() => { + const expected = [0, FIRST_REWARD, FIRST_REWARD, NUDGE_BALANCE][step]; + + if (user && step > 0 && user.balance.amount !== expected) { + updateUser({ ...user, balance: { amount: expected } }); + } + }, [step, user, updateUser]); + + return ( + + {step <= 1 && ( + setStep(1)} /> + )} + {step === 1 && ( + setStep(2)} + onLater={() => setStep(2)} + /> + )} + {step === 2 && ( + { + if (use === 'boost') { + setStep(3); + } + }} + /> + )} + {step === 3 && } + + + ); +}; + +export const Walkthrough: Story = { + parameters: { + msw: { handlers }, + docs: { + description: { + story: + 'Claim the quest, then follow the screens; the bar at the bottom left jumps between steps.', + }, + }, + }, + render: ({ persona }) => ( + + + + ), +}; + +export const QuestReward: StoryObj = { + name: '1 · Quest reward', + args: { coresAccess: true }, + argTypes: { + coresAccess: { + control: 'boolean', + description: 'Without Cores access (coresRole None) the reward hides', + }, + }, + parameters: { + msw: { handlers }, + docs: { + description: { + story: + 'Quest Cores rewards and the sidebar Cores cell only render with Cores access; today users without it see Cores and get redirected home from the wallet.', + }, + }, + }, + render: ({ coresAccess }) => ( + + + {!coresAccess && ( + + + + )} + + ), +}; + +export const YourFirstCores: Story = { + name: '2 · Your first Cores', + parameters: { + msw: { handlers: [...questHandlers(readQuest, true), ...handlers] }, + docs: { + description: { + story: + 'New: shown once after the first claim, with the ways to spend Cores ordered by who the user is (persona control).', + }, + }, + }, + render: ({ persona }) => ( + + + + undefined} + onLater={() => undefined} + /> + + + ), +}; + +export const WalletUseYourCores: Story = { + name: '3 · Wallet with "Use your Cores"', + parameters: { + msw: { handlers }, + docs: { + description: { + story: + 'The wallet adds "Use your Cores" under the totals, each row saying whether the balance covers it; today the only action is Buy Cores.', + }, + }, + }, + render: () => ( + + + + + + ), +}; + +export const NudgeOnOwnPost: Story = { + name: '4 · Nudge on own post', + parameters: { + msw: { handlers }, + docs: { + description: { + story: + 'New: on your own post, only once the balance covers the smallest boost (boost_settings.min, 5,000 today), so it never leads to a purchase wall.', + }, + }, + }, + render: () => ( + + + + + + ), +}; diff --git a/packages/storybook/stories/cores-boost/flows/GiveAward.stories.tsx b/packages/storybook/stories/cores-boost/flows/GiveAward.stories.tsx new file mode 100644 index 00000000000..dc25f332f30 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/GiveAward.stories.tsx @@ -0,0 +1,248 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { Product } from '@dailydotdev/shared/src/graphql/njord'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoresProviders } from '../real/providers'; +import { coresHandlers } from '../real/handlers'; +import { awards } from '../real/catalog'; +import { Stage } from './purchase/stage'; +import { + AwardNoteFixed, + AwardPickerFixed, + AwardSent, +} from './purchase/AwardFixed'; +import { + BuyCoresFixedModal, + BuyCoresProcessingFixed, +} from './purchase/BuyCoresFixed'; + +// "Give an award with a low balance", fixed lane of the flow board. Only the +// changed screens; Paddle checkout and "Award sent" are production as is. + +const balance = 20; +const coffee = awards.find(({ name }) => name === 'Coffee Mug') as Product; + +type Step = + | 'pick' + | 'buy' + | 'processing' + | 'timeout' + | 'note' + | 'sent' + | 'done'; + +const Walkthrough = (): ReactElement => { + const { user, updateUser } = useAuthContext(); + const [step, setStep] = useState('pick'); + const [product, setProduct] = useState(coffee); + const [bought, setBought] = useState(0); + const [hasTimedOut, setTimedOut] = useState(false); + const close = () => setStep('done'); + + const settle = useCallback( + (cores: number) => { + const delay = hasTimedOut ? 1200 : 2000; + setTimeout(() => { + // The first try shows the timeout state, Check again lands the Cores + if (!hasTimedOut) { + setTimedOut(true); + setStep('timeout'); + return; + } + if (user) { + updateUser({ ...user, balance: { amount: balance + cores } }); + } + setStep('note'); + }, delay); + }, + [hasTimedOut, updateUser, user], + ); + + return ( + { + if (user) { + updateUser({ ...user, balance: { amount: balance } }); + } + setTimedOut(false); + setStep('pick'); + }} + > + {step === 'pick' && ( + { + setProduct(picked); + setStep('buy'); + }} + onPicked={(picked) => { + setProduct(picked); + setStep('note'); + }} + onRequestClose={close} + /> + )} + {step === 'buy' && ( + { + setBought(pack.value); + setStep('processing'); + settle(pack.value); + }} + onRequestClose={() => setStep('pick')} + /> + )} + {(step === 'processing' || step === 'timeout') && ( + { + setStep('processing'); + settle(bought); + }} + onRequestClose={close} + /> + )} + {step === 'note' && ( + setStep('pick')} + onSent={() => setStep('sent')} + onRequestClose={close} + /> + )} + {step === 'sent' && ( + + )} + + ); +}; + +const meta: Meta = { + title: 'Cores & Boost/Flows/2. Give an award', + parameters: { + layout: 'fullscreen', + msw: { handlers: coresHandlers({ balance }) }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const story = ( + name: string, + description: string, + render: () => ReactElement, + startBalance = balance, +): Story => ({ + name, + parameters: { + msw: { handlers: coresHandlers({ balance: startBalance }) }, + docs: { description: { story: description } }, + }, + render: () => ( + + {render()} + + ), +}); + +export const Flow = story( + 'Walkthrough', + 'The whole fixed flow with 20 Cores: pick Coffee Mug, buy the pack that covers it, wait out a slow payment, then send the award without picking it again.', + () => , +); + +export const PickAnAward = story( + '2 · Pick an award', + 'Each award shows how many Cores the balance is missing, the picked award stays highlighted, and the CTA says "Get 55 Cores and send" instead of "Buy Cores 75".', + () => ( + + undefined} + onPicked={() => undefined} + onRequestClose={() => undefined} + /> + + ), +); + +export const BuyCores = story( + '3 · Buy Cores, pack preselected', + 'The smallest pack that covers the shortfall is selected on every width, the copy counts the current balance, and the header "+" focuses the packs instead of doing nothing.', + () => ( + + undefined} + onRequestClose={() => undefined} + /> + + ), +); + +export const Processing = story( + '5 · Processing', + 'A slow payment stays in the modal with Check again and keeps the award draft, instead of "Processing timed out" sending the user to the wallet.', + () => ( + + undefined} + onRequestClose={() => undefined} + /> + + ), +); + +export const NoteAndSend = story( + '6 · Note and send', + 'Once the Cores land, the note step opens with Coffee Mug still selected, instead of "You got your Cores!" and the picker again.', + () => ( + + undefined} + onSent={() => undefined} + onRequestClose={() => undefined} + /> + + ), + balance + 100, +); + +export const OldIos = story( + '8 · Back where you were · old iOS', + 'iOS builds without in-app purchase link to the App Store with "Update the app to buy Cores", instead of a disabled "Insufficient Cores" button.', + () => ( + + undefined} + onPicked={() => undefined} + onRequestClose={() => undefined} + /> + + ), +); + +// The story index reads names statically, not through the helper +Flow.storyName = 'Walkthrough'; +PickAnAward.storyName = '2 · Pick an award'; +BuyCores.storyName = '3 · Buy Cores, pack preselected'; +Processing.storyName = '5 · Processing'; +NoteAndSend.storyName = '6 · Note and send'; +OldIos.storyName = '8 · Back where you were · old iOS'; diff --git a/packages/storybook/stories/cores-boost/flows/ReceiveAward.stories.tsx b/packages/storybook/stories/cores-boost/flows/ReceiveAward.stories.tsx new file mode 100644 index 00000000000..1d240987641 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/ReceiveAward.stories.tsx @@ -0,0 +1,187 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { mocked } from 'storybook/test'; +import mockedRouter, { useRouter } from 'next/router'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoresProviders } from '../real/providers'; +import { Shell, useAnimateCores } from './cores/Shell'; +import { FlowStepper } from './cores/FlowStepper'; +import { FixedWallet } from './cores/FixedWallet'; +import { AwardNotificationPopup } from './cores/AwardNotification'; +import { NotificationsScreen } from './cores/screens'; +import { bunPost, PostModalOverFeed } from '../real/pages'; +import { idoAward, receiveHistory, walletHandlers } from './cores/fixtures'; + +// Fixed lane of "Receive an award" on the flow board. Only the screens the +// fix changes or adds; sending the award stays as production. + +const BEFORE = 20; +const AFTER = BEFORE + idoAward.valueIncFees; + +const meta: Meta = { + title: 'Cores & Boost/Flows/3. Receive an award', + parameters: { + layout: 'fullscreen', + msw: { handlers: walletHandlers(AFTER, receiveHistory) }, + }, + beforeEach: () => { + mocked(useRouter).mockReturnValue({ + ...mockedRouter, + isReady: true, + pathname: '/wallet', + asPath: '/wallet', + } as never); + }, +}; + +export default meta; + +type Story = StoryObj; + +/** The award lands while you browse: popup, and the header balance counts up */ +const AwardArrives = ({ onOpen }: { onOpen?: () => void }): ReactElement => { + const { user, updateUser } = useAuthContext(); + const animateCores = useAnimateCores(); + const [isPopupOpen, setIsPopupOpen] = useState(false); + const [arrivals, setArrivals] = useState(0); + + const arrive = useCallback(() => { + setIsPopupOpen(true); + animateCores(idoAward.valueIncFees); + }, [animateCores]); + + useEffect(() => { + const timer = window.setTimeout(arrive, 800); + return () => window.clearTimeout(timer); + // Once per arrival; `arrive` changes as the balance does + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [arrivals]); + + return ( + <> + + {isPopupOpen && ( + setIsPopupOpen(false)} + /> + )} + + + ); +}; + +const steps = ['Award arrives', 'Notifications', 'Wallet']; + +const WalkthroughFlow = (): ReactElement => { + const { user, updateUser } = useAuthContext(); + const [step, setStep] = useState(0); + + // Jumping past the arrival lands on the balance it leaves behind + useEffect(() => { + if (step > 0 && user && user.balance.amount !== AFTER) { + updateUser({ ...user, balance: { amount: AFTER } }); + } + }, [step, user, updateUser]); + + return ( + + {step === 0 && setStep(2)} />} + {step === 1 && setStep(2)} />} + {step === 2 && } + + + ); +}; + +export const Walkthrough: Story = { + parameters: { + docs: { + description: { + story: + 'The award arrives on your post: open the popup, or step to the bell list and use View in wallet.', + }, + }, + }, + render: () => ( + + + + ), +}; + +export const AwardReceivedWithContext: Story = { + name: '2 · Award received, with context', + parameters: { + docs: { + description: { + story: + 'The notification names the award, the net Cores, the post and the note, and adds View in wallet; today it says "gave you an Award" with Say thanks only. Needs the copy from the backend.', + }, + }, + }, + render: () => ( + + + + + + + ), +}; + +export const BalanceRefreshes: Story = { + name: '3 · Balance refreshes', + parameters: { + docs: { + description: { + story: + 'New: the header Cores pill counts up when a Cores notification arrives, the way a quest claim does; today it stays stale until the next boot.', + }, + }, + }, + render: () => ( + + + + + + ), +}; + +export const WalletRowHighlighted: Story = { + name: '4 · Wallet, row highlighted', + parameters: { + docs: { + description: { + story: + 'View in wallet lands on the award row: it explains the fee, shows the note and offers Say thanks, with "Use your Cores" above.', + }, + }, + }, + render: () => ( + + + + + + ), +}; diff --git a/packages/storybook/stories/cores-boost/flows/WalletAndBuying.stories.tsx b/packages/storybook/stories/cores-boost/flows/WalletAndBuying.stories.tsx new file mode 100644 index 00000000000..5cb1b6c4e8a --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/WalletAndBuying.stories.tsx @@ -0,0 +1,252 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { mocked } from 'storybook/test'; +import mockedRouter, { useRouter } from 'next/router'; +import CoresPage from '../../../../webapp/pages/cores/index'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { RequestKey } from '@dailydotdev/shared/src/lib/query'; +import { useQueryClient } from '@tanstack/react-query'; +import { NotificationsScreen } from './cores/screens'; +import { CoresProviders } from '../real/providers'; +import { activeHistory } from '../real/handlers'; +import { Shell } from './cores/Shell'; +import { FlowStepper } from './cores/FlowStepper'; +import { FixedWallet } from './cores/FixedWallet'; +import { CoresPurchasedModal } from './cores/CoresPurchasedModal'; +import { + afterPurchaseHistory, + idoAward, + landedPurchase, + stuckPurchase, + walletHandlers, + walletHistory, +} from './cores/fixtures'; + +// Fixed lane of "Wallet and buying Cores" on the flow board. Only the screens +// the fix changes or adds; Paddle checkout stays as production. + +const BALANCE = 1275; +const PACK = landedPurchase.value; + +const routeTo = (pathname: string, query: Record = {}) => { + const search = new URLSearchParams(query).toString(); + + mocked(useRouter).mockReturnValue({ + ...mockedRouter, + isReady: true, + pathname, + asPath: search ? `${pathname}?${search}` : pathname, + query, + } as never); +}; + +const coresQuery = { origin: Origin.WalletPageCTA, next: '/wallet' }; + +const meta: Meta = { + title: 'Cores & Boost/Flows/4. Wallet and buying', + parameters: { layout: 'fullscreen' }, + beforeEach: () => routeTo('/wallet'), +}; + +export default meta; + +type Story = StoryObj; + +/** Production /cores, opened from the wallet with next=/wallet */ +const CoresScreen = ({ onPaid }: { onPaid?: () => void }): ReactElement => ( + <> + + {onPaid && ( +
+ + Storybook: Paddle checkout can't open here + + +
+ )} + +); + +const steps = ['Wallet', '/cores', 'Checkout', 'Back to the wallet']; + +const beforePurchase = [idoAward, ...activeHistory]; +let walkthroughHistory = beforePurchase; + +const WalkthroughFlow = (): ReactElement => { + const { user, updateUser } = useAuthContext(); + const queryClient = useQueryClient(); + const [step, setStep] = useState(0); + const isPaid = step === 3; + + useEffect(() => { + routeTo(step === 1 || step === 2 ? '/cores' : '/wallet', coresQuery); + }, [step]); + + // The purchase lands: new row, new totals, new balance + useEffect(() => { + walkthroughHistory = isPaid + ? [landedPurchase, ...beforePurchase] + : beforePurchase; + queryClient.invalidateQueries({ queryKey: [RequestKey.Transactions] }); + }, [isPaid, queryClient]); + + useEffect(() => { + const expected = isPaid ? BALANCE + PACK : BALANCE; + + if (user && user.balance.amount !== expected) { + updateUser({ ...user, balance: { amount: expected } }); + } + }, [isPaid, user, updateUser]); + + return ( + + {step === 0 && setStep(1)} />} + {(step === 1 || step === 2) && ( + setStep(2) : undefined} /> + )} + {step === 2 && ( + setStep(3)} /> + )} + {step === 3 && } + + + ); +}; + +export const Walkthrough: Story = { + parameters: { + msw: { + handlers: walletHandlers(BALANCE, () => walkthroughHistory), + }, + docs: { + description: { + story: + 'Buy Cores from the wallet, pay, and Got it brings you back to the wallet with the new Cores showing; today it lands on the feed.', + }, + }, + }, + render: () => ( + + + + ), +}; + +export const WaysIn: Story = { + name: '1 · Ways in', + parameters: { + msw: { handlers: walletHandlers(BALANCE, walletHistory) }, + docs: { + description: { + story: + 'The same wallet links, each behind Cores access, plus a new one from the award notification; the boost rows are on the After the boost page.', + }, + }, + }, + render: () => ( + + + + + + ), +}; + +type WalletArgs = { isPendingLong: boolean }; + +let walletStoryHistory = walletHistory; + +export const Wallet: StoryObj = { + name: '2 · Wallet', + args: { isPendingLong: false }, + argTypes: { + isPendingLong: { + name: 'Pending for 10+ minutes', + control: { type: 'boolean' }, + }, + }, + parameters: { + msw: { handlers: walletHandlers(BALANCE, () => walletStoryHistory) }, + docs: { + description: { + story: + 'Totals reconcile (a Fees total; Spent counts streak restores), "Use your Cores" sits under them, received awards explain the fee, and a pending purchase says what is happening.', + }, + }, + }, + render: ({ isPendingLong }) => { + walletStoryHistory = isPendingLong + ? [stuckPurchase, ...walletHistory.slice(1)] + : walletHistory; + + return ( + + + + + + ); + }, +}; + +export const Cores: Story = { + name: '3 · /cores', + parameters: { + msw: { handlers: walletHandlers(BALANCE, walletHistory) }, + docs: { + description: { + story: + 'The production /cores page, now opened with next=/wallet from every wallet link (header, packs, See more options), so Got it returns to the wallet.', + }, + }, + }, + beforeEach: () => routeTo('/cores', coresQuery), + render: () => ( + + + + + + ), +}; + +export const BackToTheWallet: Story = { + name: '5 · Back to the wallet', + parameters: { + msw: { handlers: walletHandlers(BALANCE + PACK, afterPurchaseHistory) }, + docs: { + description: { + story: + 'Got it returns to the wallet, where the Purchased total shows what was added and the purchase tops the history; today it lands on the homepage.', + }, + }, + }, + render: () => ( + + + + + + ), +}; diff --git a/packages/storybook/stories/cores-boost/flows/after-boost/BoostAgainModal.tsx b/packages/storybook/stories/cores-boost/flows/after-boost/BoostAgainModal.tsx new file mode 100644 index 00000000000..89c0267eb20 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/after-boost/BoostAgainModal.tsx @@ -0,0 +1,414 @@ +import type { ReactElement } from 'react'; +import dynamic from 'next/dynamic'; +import React, { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import type { ModalProps } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoreIcon, PlusIcon } from '@dailydotdev/shared/src/components/icons'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; +import { BuyCoresModal } from '@dailydotdev/shared/src/components/modals/award/BuyCoresModal'; +import { usePostImage } from '@dailydotdev/shared/src/hooks/post/usePostImage'; +import { useCampaignMutation } from '@dailydotdev/shared/src/features/boost/useCampaignMutation'; +import { useBoostWallet } from '@dailydotdev/shared/src/features/boost/useBoostWallet'; +import { BoostWalletPicker } from '@dailydotdev/shared/src/features/boost/BoostWalletPicker'; +import { ActionSuccessModal } from '@dailydotdev/shared/src/components/modals/utils/ActionSuccessModal'; +import { boostSuccessCover } from '@dailydotdev/shared/src/lib/image'; +import { + boostDocsLink, + walletUrl, +} from '@dailydotdev/shared/src/lib/constants'; +import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { + CampaignType, + CampaignWallet, +} from '@dailydotdev/shared/src/graphql/campaigns'; +import { usePostBoostEstimation } from '@dailydotdev/shared/src/features/boost/usePostBoostEstimation'; +import { updatePostCache } from '@dailydotdev/shared/src/lib/query'; +import { useFeaturesReadyContext } from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { boostSettingsFeature } from '@dailydotdev/shared/src/lib/featureManagement'; +import { + getBoostErrorExtra, + useBoostLogging, +} from '@dailydotdev/shared/src/features/boost/useBoostLogging'; +import { BeforeIcon } from '@dailydotdev/shared/src/components/icons/Before'; + +// `BoostPostModal` as "Boost again" opens it: prefilled with the last +// campaign's budget and duration, which it names above the sliders. The +// slider minimum stretches down to the last budget, in case it was set +// under a starter preset with a lower minimum. + +const Slider = dynamic( + () => + import('@dailydotdev/shared/src/components/fields/Slider').then( + (mod) => mod.Slider, + ), + { ssr: false }, +); + +export interface LastBoost { + coresPerDay: number; + days: number; +} + +interface BoostPostModalProps extends ModalProps { + post: Post; + origin?: Origin; + lastBoost: LastBoost; + /** The success screen links to the boost's page on /boosts */ + onSeeBoost?: () => void; +} + +const SCREENS = { + FORM: 'FORM', + BUY_CORES: 'BUY_CORES', + SUCCESS: 'SUCCESS', +} as const; + +export type Screens = keyof typeof SCREENS; + +export function BoostAgainModal({ + post, + origin = Origin.BoostAgain, + lastBoost, + onSeeBoost, + ...props +}: BoostPostModalProps): ReactElement { + const { user } = useAuthContext(); + const client = useQueryClient(); + const { getFeatureValue } = useFeaturesReadyContext(); + const [activeScreen, setActiveScreen] = useState(SCREENS.FORM); + const boostSettings = getFeatureValue(boostSettingsFeature); + const [coresPerDay, setCoresPerDay] = React.useState(lastBoost.coresPerDay); + const [totalDays, setTotalDays] = React.useState(lastBoost.days); + const minCoresPerDay = Math.min(boostSettings.min, lastBoost.coresPerDay); + const [estimate, setEstimate] = useState({ coresPerDay, totalDays }); + const [updateEstimate] = useDebounceFn(setEstimate, 400); + const totalSpendInt = coresPerDay * totalDays; + const totalSpend = largeNumberFormat(totalSpendInt); + const { squadWallet, wallet, setWallet, balance } = useBoostWallet( + post.source?.type === SourceType.Squad ? post.source.id : undefined, + ); + const { estimatedReach, canBoost, isLoading } = usePostBoostEstimation({ + post, + query: { budget: estimate.coresPerDay }, + }); + const { logBoostEvent, logBuyCores } = useBoostLogging({ + type: CampaignType.Post, + referenceId: post.id, + origin, + }); + const { onStartBoost: onBoostPost } = useCampaignMutation({ + onBoostSuccess: (data, vars) => { + logBoostEvent(LogEvent.CompleteBoost, vars, { + campaign_id: data.referenceId, + }); + + if (data.referenceId) { + updatePostCache(client, vars.value, (old) => ({ + flags: { ...old.flags, campaignId: data.referenceId }, + })); + } + + setActiveScreen(SCREENS.SUCCESS); + }, + onBoostError: (error, vars) => + logBoostEvent(LogEvent.ErrorBoost, vars, getBoostErrorExtra(error)), + }); + const image = usePostImage(post); + + const onBuyCores = () => { + logBuyCores(); + setActiveScreen(SCREENS.BUY_CORES); + }; + + const onButtonClick = () => { + const hasEnoughBalance = balance >= totalSpendInt; + + logBoostEvent( + LogEvent.StartBoost, + { budget: coresPerDay, duration: totalDays }, + { has_enough_balance: hasEnoughBalance }, + ); + + if (!hasEnoughBalance) { + // Cores are bought for the user, not for the squad wallet + return wallet === CampaignWallet.User ? onBuyCores() : undefined; + } + + return onBoostPost({ + duration: totalDays, + budget: coresPerDay, + value: post.id, + type: CampaignType.Post, + wallet, + }); + }; + + if (activeScreen === SCREENS.BUY_CORES) { + const missingCores = Math.max( + 0, + totalSpendInt - (user?.balance.amount ?? 0), + ); + + return ( + setActiveScreen(SCREENS.FORM)} + onRequestClose={() => setActiveScreen(SCREENS.FORM)} + amountNeededCopy={ + missingCores > 0 ? ( + + You need{' '} + {missingCores.toLocaleString()} more Cores to + boost the post. + + ) : undefined + } + origin={Origin.BoostPost} + /> + ); + } + + if (activeScreen === SCREENS.SUCCESS) { + return ( + + ); + } + + // just to avoid any edge case where the min, for some reason is greater than max + const maxReach = Math.max(estimatedReach.min, estimatedReach.max); + + const potentialReach = (() => { + if (isLoading || !canBoost) { + return ; + } + + const min = largeNumberFormat(estimatedReach.min); + const max = largeNumberFormat(maxReach); + + return `${min} - ${max}`; + })(); + + const onRequestClose: ModalProps['onRequestClose'] = (event) => { + logBoostEvent(LogEvent.CloseBoost, { + budget: coresPerDay, + duration: totalDays, + }); + props.onRequestClose?.(event); + }; + + return ( + + + + Boost your post + +
+ +
+
+ + + + Give your content the spotlight it deserves. Our auto-targeting engine + ensures your post gets shown to the developers most likely to care. + + Learn more + + +
+
+ + {post.title ?? post.sharedPost?.title} + + {image && } +
+
+ + {totalSpend} Cores over {totalDays}{' '} + {totalDays === 1 ? 'day' : 'days'} + + + Total spend + + + {potentialReach} + + + Estimated daily reach + +
+
+
+ + + Last boost:{' '} + {(lastBoost.coresPerDay * lastBoost.days).toLocaleString('en-US')}{' '} + Cores over {lastBoost.days} {lastBoost.days === 1 ? 'day' : 'days'} + +
+
+ + Budget + + + + + {largeNumberFormat(coresPerDay || 0)} Cores per day + + + { + updateEstimate((state) => ({ ...state, coresPerDay: value })); + setCoresPerDay(value); + }} + /> + + Duration + + + {totalDays} {totalDays === 1 ? 'day' : 'days'} + + setTotalDays(value)} + /> +
+ {squadWallet && ( + + )} +
+ + + + + ); +} + +export default BoostAgainModal; diff --git a/packages/storybook/stories/cores-boost/flows/after-boost/BoostNotifications.tsx b/packages/storybook/stories/cores-boost/flows/after-boost/BoostNotifications.tsx new file mode 100644 index 00000000000..d2f42cfc912 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/after-boost/BoostNotifications.tsx @@ -0,0 +1,215 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { NotificationItemLead } from '@dailydotdev/shared/src/components/notifications/NotificationItemLead'; +import type { NotificationType } from '@dailydotdev/shared/src/components/notifications/utils'; +import { NotificationIconType } from '@dailydotdev/shared/src/components/notifications/utils'; +import { NotificationAvatarType } from '@dailydotdev/shared/src/graphql/notifications'; +import { + Image, + ImageType, +} from '@dailydotdev/shared/src/components/image/Image'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import type { Campaign } from '@dailydotdev/shared/src/graphql/campaigns'; +import { CampaignState } from '@dailydotdev/shared/src/graphql/campaigns'; +import { viewer } from '../../real/providers'; +import { + findNotificationRow, + NotificationsPage, + SwapInPage, +} from '../../real/pages'; +import { unspentOf } from './fixtures'; + +// The in-app rows for `campaign_post_first_milestone` and +// `campaign_post_completed`. Production builds them in daily-api +// (`generateCampaignPostNotification`): DailyDev icon, the viewer's own +// avatar, a title only, no attachment. The rows below keep `NotificationItem`'s +// markup and add what the fix needs: the post, the results, the refund and an +// action that goes straight to the boost. + +interface BoostNotificationRowProps { + type: string; + title: string; + campaign: Campaign; + createdAt: string; + isUnread?: boolean; + details?: ReactNode; + actions: ReactNode; +} + +const greyLine = 'flex items-baseline gap-1.5 text-text-tertiary typo-footnote'; + +const BoostNotificationRow = ({ + type, + title, + campaign, + createdAt, + isUnread, + details, + actions, +}: BoostNotificationRowProps): ReactElement => { + const { post } = campaign; + + return ( +
+
+ +
+
+
+ {title} +
+
+ {post.title} +
+ {details} +
+ +
+ {actions} +
+
+ + {`Cover + +
+
+ ); +}; + +interface NotificationProps { + campaign: Campaign; + onSeeBoost?: () => void; + onBoostAgain?: () => void; +} + +export const BoostLiveNotification = ({ + campaign, + onSeeBoost, +}: NotificationProps): ReactElement => ( + + See your boost + + } + /> +); + +export const BoostEndedNotification = ({ + campaign, + onSeeBoost, + onBoostAgain, +}: NotificationProps): ReactElement => { + const { impressions = 0, users = 0 } = campaign.flags; + const unspent = unspentOf(campaign); + + return ( + +
+ {largeNumberFormat(impressions)} impressions ·{' '} + {largeNumberFormat(users)} developers reached +
+ {unspent > 0 && ( +
+ + {unspent.toLocaleString('en-US')} unused Cores refunded to your + wallet +
+ )} + + } + actions={ + <> + + + + } + /> + ); +}; + +/** + * The real notifications page, with the boost row production sends today + * drawn as the flow fixes it + */ +export const NotificationsColumn = ({ + children, +}: { + children: ReactNode; +}): ReactElement => { + // The row's campaign says which production row it stands for + const isLive = React.Children.toArray(children).some( + (child) => + React.isValidElement(child) && + (child.props as { campaign?: Campaign }).campaign?.state === + CampaignState.Active, + ); + + return ( + <> + + + {children} + + + ); +}; diff --git a/packages/storybook/stories/cores-boost/flows/after-boost/BoostsPage.tsx b/packages/storybook/stories/cores-boost/flows/after-boost/BoostsPage.tsx new file mode 100644 index 00000000000..bb29d4ff964 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/after-boost/BoostsPage.tsx @@ -0,0 +1,232 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import { PageWidgets } from '@dailydotdev/shared/src/components/utilities/common'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + boostDocsLink, + creatorsTermsOfService, +} from '@dailydotdev/shared/src/lib/constants'; +import { + ArrowIcon, + CoreIcon, + DocsIcon, + FeedbackIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { ListCardDivider } from '@dailydotdev/shared/src/components/cards/common/Card'; +import { WidgetContainer } from '@dailydotdev/shared/src/components/widgets/common'; +import TabList from '@dailydotdev/shared/src/components/tabs/TabList'; +import { CampaignList } from '@dailydotdev/shared/src/features/boost/CampaignList'; +import { + BoostStatus, + CampaignListItem, +} from '@dailydotdev/shared/src/features/boost/CampaignListItem'; +import { boostDashboardInfo } from '@dailydotdev/shared/src/features/boost/common'; +import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { Campaign } from '@dailydotdev/shared/src/graphql/campaigns'; +import { CampaignState } from '@dailydotdev/shared/src/graphql/campaigns'; +import { CampaignView, clicksInfo } from './CampaignView'; +import { allCampaigns, campaignTotals } from './fixtures'; + +// /boosts and /boosts/[id]: the ads dashboard and campaign modals as pages, +// laid out like the wallet page (`webapp/pages/wallet.tsx`), so the Boosting +// button, wallet boost rows and the profile menu can link to them. Storybook +// has no app shell, so the tablet header shows at every width. + +const pageClassName = + 'm-auto flex w-full max-w-screen-laptop flex-col pb-12 tablet:pb-0 laptop:min-h-page laptop:flex-row laptop:border-l laptop:border-r laptop:border-border-subtlest-tertiary laptop:pb-6 laptopL:pb-0'; + +const PageFrame = ({ + header, + children, +}: { + header: ReactNode; + children: ReactNode; +}): ReactElement => ( +
+
+
+ {header} +
+
{children}
+
+ + +
+ + + +
+
+
+
+); + +const Divider = (): ReactElement => ( +
+); + +const noScroll = { + fetchNextPage: async () => undefined, + canFetchMore: false, + isFetchingNextPage: false, +}; + +enum BoostsTab { + Live = 'Live', + Ended = 'Ended', +} + +interface BoostsPageProps { + campaigns?: Campaign[]; + onOpenCampaign?: (campaign: Campaign) => void; +} + +export function BoostsPage({ + campaigns = allCampaigns, + onOpenCampaign, +}: BoostsPageProps): ReactElement { + const live = campaigns.filter(({ state }) => state === CampaignState.Active); + const ended = campaigns.filter(({ state }) => state !== CampaignState.Active); + const [tab, setTab] = useState( + live.length ? BoostsTab.Live : BoostsTab.Ended, + ); + const labelOf = (name: BoostsTab, count: number) => `${name} (${count})`; + const tabs = [ + { name: BoostsTab.Live, list: live }, + { name: BoostsTab.Ended, list: ended }, + ]; + const active = tabs.find(({ name }) => name === tab); + + return ( + + Boosts + + } + > +
+ } + /> + + + +
+ +
+ + tag="button" + items={tabs.map(({ name, list }) => ({ + label: labelOf(name, list.length), + }))} + active={labelOf(active.name, active.list.length)} + onClick={(label) => + setTab( + label.startsWith(BoostsTab.Live) + ? BoostsTab.Live + : BoostsTab.Ended, + ) + } + /> + {active.list.length ? ( +
    + {active.list.map((campaign) => ( +
  • + onOpenCampaign?.(campaign)} + className="px-4 py-2 tablet:px-6" + /> +
  • + ))} +
+ ) : ( + + )} +
+
+ ); +} + +interface CampaignPageProps { + campaign: Campaign; + onBack?: () => void; + onStop?: () => void; + onExtend?: () => void; + onBoostAgain?: () => void; +} + +export function CampaignPage({ + campaign, + onBack, + ...actions +}: CampaignPageProps): ReactElement { + return ( + + + + +
+ ) : ( + + )} +
+ ); +} diff --git a/packages/storybook/stories/cores-boost/flows/after-boost/fixtures.ts b/packages/storybook/stories/cores-boost/flows/after-boost/fixtures.ts new file mode 100644 index 00000000000..48e8dde6fa1 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/after-boost/fixtures.ts @@ -0,0 +1,142 @@ +import type { Campaign } from '@dailydotdev/shared/src/graphql/campaigns'; +import { + CampaignState, + CampaignType, +} from '@dailydotdev/shared/src/graphql/campaigns'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import { boostablePost } from '../../real/screens'; +import { viewer } from '../../real/providers'; + +// Campaigns in the `CampaignFragment` shape. `flags.budget` is the total +// budget, as `CampaignListView` reads it ("2,000 | 1 day"). + +const oneDay = 24 * 60 * 60 * 1000; +const daysFromNow = (days: number): Date => + new Date(Date.now() + days * oneDay); + +const postNamed = (id: string, title: string): Post => + ({ ...boostablePost, id, title }) as Post; + +const campaign = ( + id: string, + post: Post, + fields: Pick, +): Campaign => ({ + id, + referenceId: post.id, + type: CampaignType.Post, + post, + user: viewer as LoggedUser, + ...fields, +}); + +export const ciPost = boostablePost; + +/** Started 2 days ago, 5 days long: 800 of 2,000 spent, 1,200 left to refund */ +export const liveCampaign = campaign( + 'sb-campaign-live', + postNamed('sb-post-bun', 'Why we moved our monorepo from Node to Bun'), + { + state: CampaignState.Active, + createdAt: daysFromNow(-2), + endedAt: daysFromNow(3), + flags: { + budget: 2000, + spend: 800, + impressions: 2400, + users: 1500, + clicks: 96, + }, + }, +); + +/** The first boost from the First boost flow: 1 day, 2,000 Cores, 300 unspent */ +export const endedCampaign = campaign('sb-campaign-ci', ciPost, { + state: CampaignState.Completed, + createdAt: daysFromNow(-1), + endedAt: daysFromNow(0), + flags: { + budget: 2000, + spend: 1700, + impressions: 5700, + users: 3500, + clicks: 212, + }, +}); + +export const endedCampaigns: Campaign[] = [ + endedCampaign, + campaign( + 'sb-campaign-2', + postNamed('sb-post-rsc', 'React Server Components, one year in production'), + { + state: CampaignState.Completed, + createdAt: daysFromNow(-24), + endedAt: daysFromNow(-17), + flags: { + budget: 35000, + spend: 35000, + impressions: 61000, + users: 38000, + clicks: 1900, + }, + }, + ), + campaign( + 'sb-campaign-3', + postNamed('sb-post-pg', 'Postgres indexes I wish I knew about sooner'), + { + state: CampaignState.Cancelled, + createdAt: daysFromNow(-40), + endedAt: daysFromNow(-38), + flags: { + budget: 10000, + spend: 4100, + impressions: 7200, + users: 4600, + clicks: 240, + }, + }, + ), + campaign( + 'sb-campaign-4', + postNamed('sb-post-ts', 'TypeScript 6: what actually changes for you'), + { + state: CampaignState.Completed, + createdAt: daysFromNow(-71), + endedAt: daysFromNow(-66), + flags: { + budget: 25000, + spend: 25000, + impressions: 44000, + users: 27000, + clicks: 1300, + }, + }, + ), +]; + +export const allCampaigns = [liveCampaign, ...endedCampaigns]; + +/** What `userCampaignStats` returns: totals over every campaign */ +export const campaignTotals = allCampaigns.reduce( + (totals, { flags }) => ({ + spend: totals.spend + (flags.spend ?? 0), + impressions: totals.impressions + (flags.impressions ?? 0), + users: totals.users + (flags.users ?? 0), + clicks: totals.clicks + (flags.clicks ?? 0), + }), + { spend: 0, impressions: 0, users: 0, clicks: 0 }, +); + +export const unspentOf = ({ flags }: Campaign): number => + Math.max(0, (flags.budget ?? 0) - (flags.spend ?? 0)); + +export const durationOf = ({ createdAt, endedAt }: Campaign): number => + Math.max( + 1, + Math.round( + (new Date(endedAt).getTime() - new Date(createdAt).getTime()) / oneDay, + ), + ); diff --git a/packages/storybook/stories/cores-boost/flows/cores/AwardNotification.tsx b/packages/storybook/stories/cores-boost/flows/cores/AwardNotification.tsx new file mode 100644 index 00000000000..855bc0204ec --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/AwardNotification.tsx @@ -0,0 +1,172 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { NotificationItemLead } from '@dailydotdev/shared/src/components/notifications/NotificationItemLead'; +import { NotificationSayThanksButton } from '@dailydotdev/shared/src/components/notifications/NotificationSayThanksButton'; +import { InAppNotificationItem } from '@dailydotdev/shared/src/components/notifications/InAppNotificationItem'; +import styles from '@dailydotdev/shared/src/components/notifications/InAppNotification.module.css'; +import { + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { NotificationAvatarType } from '@dailydotdev/shared/src/graphql/notifications'; +import type { + NewNotification, + NotificationAvatar, +} from '@dailydotdev/shared/src/graphql/notifications'; +import { + Image, + ImageType, +} from '@dailydotdev/shared/src/components/image/Image'; +import { ModalClose } from '@dailydotdev/shared/src/components/modals/common/ModalClose'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { publishTimeRelativeShort } from '@dailydotdev/shared/src/lib/dateFormat'; +import { walletUrl } from '@dailydotdev/shared/src/lib/constants'; +import { coffee, ido, idoAward, ownPost } from './fixtures'; + +// The award notification, with the context the board asks for: the award, the +// net Cores, the post and the note, plus a way to the wallet. The bell row is +// a copy of production NotificationItem (award path only); the real-time popup +// uses the production InAppNotificationItem inside a copy of its container. +// Production copy today: "Ido gave you an Award", with Say thanks only. + +const net = idoAward.valueIncFees; + +export const awardTitle = `

${ido.name} gave you ${coffee?.name} (+${net} Cores)

`; +const popupTitle = `

${ido.name} gave you ${coffee?.name} (+${net} Cores) for “${ownPost.title}”

`; +const note = `“${idoAward.flags.note}”`; + +const idoAvatar: NotificationAvatar = { + type: NotificationAvatarType.User, + image: ido.image, + name: ido.name, + targetUrl: ido.permalink, + referenceId: ido.id, +}; + +export const ViewInWalletButton = ({ + onClick, +}: { + onClick?: () => void; +}): ReactElement => ( + +); + +export const AwardNotificationRow = ({ + isUnread = true, + createdAt = idoAward.createdAt, + onViewWallet, +}: { + isUnread?: boolean; + createdAt?: Date; + onViewWallet?: () => void; +}): ReactElement => { + const timeText = publishTimeRelativeShort(createdAt); + + return ( +
+
+ +
+
+
+ +
+
+ {note} +
+
+ {ownPost.title} + · + +
+ + + + + + +
+
+ + {`Cover + +
+
+ ); +}; + +const popupNotification = { + id: 'sb-award-notification', + type: NotificationType.UserReceivedAward, + icon: NotificationIconType.Core, + title: popupTitle, + avatars: [idoAvatar], + targetUrl: `${walletUrl}?highlight=${idoAward.id}`, +} as NewNotification; + +/** The real-time popup, placed inline unless `isFloating` */ +export const AwardNotificationPopup = ({ + isFloating, + onClick, + onClose, +}: { + isFloating?: boolean; + onClick?: () => void; + onClose?: () => void; +}): ReactElement => ( +
+ + { + event.preventDefault(); + onClick?.(); + }} + /> +
+); diff --git a/packages/storybook/stories/cores-boost/flows/cores/CoresNudgeStrip.tsx b/packages/storybook/stories/cores-boost/flows/cores/CoresNudgeStrip.tsx new file mode 100644 index 00000000000..724a99bc3d4 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/CoresNudgeStrip.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import stripArt from '../../assets/boost-strip-patchy-v2.png'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost'; +import { CoreIcon, MiniCloseIcon } from '@dailydotdev/shared/src/components/icons'; +import { + Typography, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; + +// New, on your own post once the balance covers the smallest boost. Same +// frame as the production BoostNewPostStrip (features/boost), with the +// balance as the reason and its own Boost button (no arrow to the header). + +export interface CoresNudgeStripProps { + balance: number; + onBoost: () => void; + onClose?: () => void; + className?: string; +} + +export function CoresNudgeStrip({ + balance, + onBoost, + onClose, + className, +}: CoresNudgeStripProps): ReactElement { + return ( +
+ {/* New art: kept to the left so it never sits behind the copy */} + +
+ + + You have + + {formatCoresCurrency(balance)} Cores, + + enough to boost this post + + +
+
+ ); +} diff --git a/packages/storybook/stories/cores-boost/flows/cores/CoresPurchasedModal.tsx b/packages/storybook/stories/cores-boost/flows/cores/CoresPurchasedModal.tsx new file mode 100644 index 00000000000..64513fb416a --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/CoresPurchasedModal.tsx @@ -0,0 +1,57 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; + +// Production "You got your Cores!" (BuyCoresModal ProcessingCompleted), copied +// unchanged: the fix is where Got it goes, the `next` the wallet passed. + +export const CoresPurchasedModal = ({ + amount, + onGotIt, +}: { + amount: number; + onGotIt: () => void; +}): ReactElement => ( + + + + + {formatCoresCurrency(amount)} + + + You got your Cores! + + + Success! Your Cores are now available in your balance. + + + + +); diff --git a/packages/storybook/stories/cores-boost/flows/cores/FirstCoresSheet.tsx b/packages/storybook/stories/cores-boost/flows/cores/FirstCoresSheet.tsx new file mode 100644 index 00000000000..f95b5977947 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/FirstCoresSheet.tsx @@ -0,0 +1,104 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import type { CoresUse } from './UseYourCores'; +import { CoresUseRow } from './UseYourCores'; + +// New, shown once after the first Cores claim. Built on the production +// "You got your Cores!" screen (BuyCoresModal ProcessingCompleted): same +// modal, icon, sizes and button, with the ways to spend them in between. + +export type FirstCoresPersona = 'poster' | 'reader'; + +const orderFor: Record = { + poster: ['boost', 'award', 'streak'], + reader: ['award', 'boost', 'streak'], +}; + +export interface FirstCoresSheetProps { + amount: number; + persona: FirstCoresPersona; + onShowMe: () => void; + onLater: () => void; +} + +export const FirstCoresSheet = ({ + amount, + persona, + onShowMe, + onLater, +}: FirstCoresSheetProps): ReactElement => { + const [primary, ...rest] = orderFor[persona]; + + return ( + + + + + +{formatCoresCurrency(amount)} + + + You earned your first Cores + + + Cores are what you spend on daily.dev. Here is what yours can do. + +
    + + {rest.map((use) => ( + + ))} +
+
+ + +
+
+
+ ); +}; diff --git a/packages/storybook/stories/cores-boost/flows/cores/FixedTransactionItem.tsx b/packages/storybook/stories/cores-boost/flows/cores/FixedTransactionItem.tsx new file mode 100644 index 00000000000..6dda7d2cdc7 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/FixedTransactionItem.tsx @@ -0,0 +1,179 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { + CoreIcon, + CreditCardIcon, + InfoIcon, + MinusIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import type { UserImageProps } from '@dailydotdev/shared/src/components/ProfilePicture'; +import { + ProfileImageSize, + ProfilePicture, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { Separator } from '@dailydotdev/shared/src/components/cards/common/common'; +import { DateFormat } from '@dailydotdev/shared/src/components/utilities/DateFormat'; +import { TimeFormatType } from '@dailydotdev/shared/src/lib/dateFormat'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { TransactionItemType } from '@dailydotdev/shared/src/lib/transaction'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; +import { ProfileLink } from '@dailydotdev/shared/src/components/profile/ProfileLink'; +import { ProfileTooltip } from '@dailydotdev/shared/src/components/profile/ProfileTooltip'; + +// Copy of production TransactionItem (components/cores/TransactionItem.tsx). +// Added: `details` (fee breakdown, pending hint), `action` (Say thanks) and +// `isHighlighted` for the row a notification or purchase points at. + +export type FixedTransactionItemProps = { + type: TransactionItemType; + user: UserImageProps; + profileUser?: UserShortProfile; + date: Date; + amount: number; + label: ReactNode; + extraLabel?: ReactNode; + note?: ReactNode; + details?: ReactNode; + action?: ReactNode; + isHighlighted?: boolean; +}; + +const TransactionTypeToIcon: Record = { + receive: ( +
+ +
+ ), + send: ( +
+ +
+ ), + purchase: ( +
+ +
+ ), + unknown: ( +
+ +
+ ), +}; + +export const FixedTransactionItem = ({ + type, + user, + profileUser, + date, + amount, + label, + extraLabel, + note, + details, + action, + isHighlighted, +}: FixedTransactionItemProps): ReactElement => { + const itemRef = useRef(null); + + useEffect(() => { + if (isHighlighted) { + itemRef.current?.scrollIntoView({ block: 'center' }); + } + }, [isHighlighted]); + + const linkToProfile = (children: ReactElement): ReactElement => { + if (!profileUser) { + return children; + } + + return ( + + + {children} + + + ); + }; + + return ( +
  • +
    + {TransactionTypeToIcon[type]} + {linkToProfile( + , + )}{' '} +
    + {linkToProfile( + + {user.name} + , + )} +
    + + {extraLabel} + +
    + + {label} + +
    + + +
    +
    + {!!details && ( + + {details} + + )} + {!!note && ( + + {note} + + )} + {action && {action}} +
    +
    +
    +
    + + + {amount === 0 && 'Free'} + {amount !== 0 && + `${amount > 0 ? '+' : ''}${formatCoresCurrency(amount)}`} + +
    +
  • + ); +}; diff --git a/packages/storybook/stories/cores-boost/flows/cores/FixedWallet.tsx b/packages/storybook/stories/cores-boost/flows/cores/FixedWallet.tsx new file mode 100644 index 00000000000..9cb5b2c3824 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/FixedWallet.tsx @@ -0,0 +1,453 @@ +import type { ReactElement, ReactNode } from 'react'; +import { useRoute } from '../../real/providers'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; +import type { WithClassNameProps } from '@dailydotdev/shared/src/components/utilities'; +import { PageWidgets } from '@dailydotdev/shared/src/components/utilities'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + coresDocsLink, + creatorsTermsOfService, + webappUrl, +} from '@dailydotdev/shared/src/lib/constants'; +import { + CoreIcon, + CreditCardIcon, + DocsIcon, + FeedbackIcon, + InfoIcon, + MinusIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { ListCardDivider } from '@dailydotdev/shared/src/components/cards/common/Card'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import classed from '@dailydotdev/shared/src/lib/classed'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { + getTransactionLabel, + getTransactionType, +} from '@dailydotdev/shared/src/lib/transaction'; +import { useQuery } from '@tanstack/react-query'; +import { + generateQueryKey, + RequestKey, + StaleTime, +} from '@dailydotdev/shared/src/lib/query'; +import type { UserTransaction } from '@dailydotdev/shared/src/graphql/njord'; +import { + getTransactions, + getTransactionSummary, + ProductType, + UserTransactionStatus, + UserTransactionType, +} from '@dailydotdev/shared/src/graphql/njord'; +import { FeaturedCoresWidget } from '@dailydotdev/shared/src/components/cores/FeaturedCoresWidget'; +import { PlusCoresGivebackWidget } from '@dailydotdev/shared/src/components/cores/PlusCoresGivebackWidget'; +import { NotificationSayThanksButton } from '@dailydotdev/shared/src/components/notifications/NotificationSayThanksButton'; +import { WidgetContainer } from '@dailydotdev/shared/src/components/widgets/common'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { ShellPage } from '@dailydotdev/shared/src/components/shell/ShellPageContext'; +import { useIsPhone } from '@dailydotdev/shared/src/hooks/useViewSize'; +import { useLaptopLayout } from '@dailydotdev/shared/src/hooks/layout/useLaptopLayout'; +import { getPathnameWithQuery } from '@dailydotdev/shared/src/lib'; +import type { CoresUse } from './UseYourCores'; +import { UseYourCores } from './UseYourCores'; +import { FixedTransactionItem } from './FixedTransactionItem'; + +// Copy of the production wallet page (webapp/pages/wallet.tsx), changed only +// where the fixed flows need it: +// - a Fees total, so Received + Fees adds up to what people gave +// - "Use your Cores" under the totals +// - fee breakdown, the note and Say thanks on received award rows +// - pending purchases explain themselves, and link to support after 10 minutes +// - Buy Cores passes next=/wallet, so a purchase comes back here +// Data still comes from the production queries (TransactionSummary, +// transactions), answered by the story's MSW handlers. + +type BalanceBlockProps = { + Icon: ReactElement; + title: string; + description: string; + balance: number; + isLoading?: boolean; + change?: number; +} & WithClassNameProps; + +const BalanceBlock = ({ + Icon, + balance, + title, + description, + className, + isLoading, + change, +}: BalanceBlockProps): ReactElement => { + return ( +
    + {Icon} + +
    + {title} + +
    +
    + {isLoading ? ( + + + + ) : ( +
    + + {formatCoresCurrency(balance)} + + {!!change && ( + + +{formatCoresCurrency(change)} + + )} +
    + )} +
    + ); +}; + +const Divider = classed('div', 'h-px w-full bg-border-subtlest-tertiary'); + +const PENDING_SUPPORT_MS = 10 * 60 * 1000; + +const isAwardFromPerson = (transaction: UserTransaction): boolean => + !!transaction.sender && + transaction.sender.id !== 'system' && + transaction.product?.type !== ProductType.StreakFreeze && + !!transaction.product; + +const getDetails = ( + transaction: UserTransaction, + type: ReturnType, +): ReactNode => { + if (transaction.status === UserTransactionStatus.Processing) { + const age = Date.now() - new Date(transaction.createdAt).getTime(); + + if (age < PENDING_SUPPORT_MS) { + return 'Payment received. Your Cores usually land in under a minute.'; + } + + return ( + <> + Taking longer than usual.{' '} + + Contact support + + + ); + } + + if (type === 'receive' && isAwardFromPerson(transaction)) { + const fee = transaction.value - transaction.valueIncFees; + + return `${formatCoresCurrency(transaction.value)} Cores award · ${formatCoresCurrency(fee)} Cores fee`; + } + + return undefined; +}; + +const getNote = (transaction: UserTransaction): string | undefined => { + const { note } = transaction.flags; + + if ( + !note || + !isAwardFromPerson(transaction) || + transaction.referenceType === UserTransactionType.PostBoost + ) { + return undefined; + } + + return `“${note}”`; +}; + +export interface FixedWalletProps { + /** Transaction a notification or a purchase points at */ + highlightId?: string; + /** Cores a purchase just added, shown on the Purchased total */ + purchasedChange?: number; + onBuyCores?: () => void; + onUse?: (use: CoresUse) => void; +} + +export const FixedWallet = ({ + highlightId, + purchasedChange, + onBuyCores, + onUse, +}: FixedWalletProps): ReactElement | null => { + useRoute('/wallet'); + const { user, isLoggedIn } = useAuthContext(); + const isLaptopLayout = useLaptopLayout(); + const isPhone = useIsPhone(); + + const { data: transactionSummary, isPending: isPendingSummary } = useQuery({ + queryKey: generateQueryKey(RequestKey.Transactions, user, 'summary'), + queryFn: getTransactionSummary, + enabled: isLoggedIn, + staleTime: StaleTime.Default, + }); + const summary = transactionSummary as + | (typeof transactionSummary & { fees?: number }) + | undefined; + + const { data: transactions, isPending: isPendingTransactions } = useQuery({ + queryKey: generateQueryKey(RequestKey.Transactions, user, 'list', { + first: 20, + }), + queryFn: () => getTransactions({ first: 20 }), + enabled: isLoggedIn, + staleTime: StaleTime.Default, + }); + + // Every /cores link on the page (header, featured packs) leads to the + // story's /cores step instead of leaving Storybook + const onLinkClick = useCallback( + (event: React.MouseEvent) => { + const link = (event.target as HTMLElement).closest('a'); + + if (!/cores(\?|$)/.test(link?.getAttribute('href') ?? '')) { + return; + } + event.preventDefault(); + onBuyCores?.(); + }, + [onBuyCores], + ); + + if (!user) { + return null; + } + + const buyCoresButton = ( + + ); + + const desktopHeader = !isLaptopLayout && !isPhone && ( +
    + + Core wallet + + {buyCoresButton} +
    + ); + + const edges = transactions?.edges ?? []; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
    + {isLaptopLayout && ( + {buyCoresButton} + )} +
    +
    + {isPhone && ( + + )} + {desktopHeader} +
    +
    + } + title="Balance" + description="Your current balance" + balance={user.balance.amount} + className="basis-[calc(50%-0.5rem)] tablet:basis-0" + /> + + +
    + } + title="Purchased" + description="Amount of cores you have purchased" + balance={summary?.purchased || 0} + isLoading={isPendingSummary} + change={purchasedChange} + className="basis-[calc(50%-0.5rem)] tablet:basis-0" + /> + + +
    + } + title="Received" + description="Cores you have received, after fees" + balance={summary?.received || 0} + isLoading={isPendingSummary} + className="basis-[calc(50%-0.5rem)] tablet:basis-0" + /> + + +
    + } + title="Fees" + description="The share of awards you received that daily.dev keeps" + balance={summary?.fees || 0} + isLoading={isPendingSummary} + className="basis-[calc(50%-0.5rem)] tablet:basis-0" + /> + + + + } + title="Spent" + description="Cores you have spent on awards, boosts and streak restores" + balance={summary?.spent || 0} + isLoading={isPendingSummary} + className="basis-full tablet:basis-0" + /> + + + + +
    + + Transaction history + + {!isPendingTransactions && !edges.length && ( + + You have no transactions yet. + + )} +
      + {edges.map(({ node: transaction }) => { + const type = getTransactionType({ transaction, user }); + const transactionUser = + type === 'receive' + ? (transaction.sender ?? transaction.receiver) + : transaction.receiver; + const canThank = + type === 'receive' && isAwardFromPerson(transaction); + + return ( + + ) + } + isHighlighted={transaction.id === highlightId} + /> + ); + })} +
    +
    + + + + undefined} + amounts={[100, 300, 600]} + /> + + +
    + + + +
    +
    +
    + + + ); +}; diff --git a/packages/storybook/stories/cores-boost/flows/cores/FlowStepper.tsx b/packages/storybook/stories/cores-boost/flows/cores/FlowStepper.tsx new file mode 100644 index 00000000000..d89b7b85bd7 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/FlowStepper.tsx @@ -0,0 +1,38 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; + +// Storybook-only control for the walkthroughs, not part of the product UI. +// The screens' own buttons move the flow forward; this jumps between steps. + +export const FlowStepper = ({ + steps, + index, + onChange, +}: { + steps: string[]; + index: number; + onChange: (index: number) => void; +}): ReactElement => ( + +); diff --git a/packages/storybook/stories/cores-boost/flows/cores/GatedEntryPoints.tsx b/packages/storybook/stories/cores-boost/flows/cores/GatedEntryPoints.tsx new file mode 100644 index 00000000000..b68c5eab98b --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/GatedEntryPoints.tsx @@ -0,0 +1,135 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useHasAccessToCores } from '@dailydotdev/shared/src/hooks/useCoresFeature'; +import { QuestCard } from '@dailydotdev/shared/src/components/quest/QuestCard'; +import type { UserQuest } from '@dailydotdev/shared/src/graphql/quests'; +import { + QuestRewardType, + QuestStatus, +} from '@dailydotdev/shared/src/graphql/quests'; +import { + reputation as reputationDocsUrl, + walletUrl, +} from '@dailydotdev/shared/src/lib/constants'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import { formatCurrency } from '@dailydotdev/shared/src/lib/utils'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + CoreIcon, + ReputationIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { + Typography, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; + +// Cores entry points behind useHasAccessToCores, as the fix asks. Today the +// quest card and the sidebar strip show Cores to users who can't open the +// wallet (coresRole None), and the wallet then redirects them home. + +/** Production QuestCard; Cores rewards are dropped without Cores access */ +export const GatedQuestCard = ({ + quest, + onClaim, + isClaimed, +}: { + quest: UserQuest; + onClaim?: () => void; + isClaimed?: boolean; +}): ReactElement => { + const hasCoresAccess = useHasAccessToCores(); + const rewards = hasCoresAccess + ? quest.rewards + : quest.rewards.filter(({ type }) => type !== QuestRewardType.Cores); + + return ( + onClaim?.()} + showLevelSystem + isClaiming={false} + isClaimAnimating={false} + showClaimedStamp={!!isClaimed} + animateClaimedStamp={!!isClaimed} + suppressPersistedClaimedStamp={false} + /> + ); +}; + +const cellClass = + 'focus-outline flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 transition-colors hover:bg-surface-hover'; + +/** Copy of production SidebarProfileStats with the Cores cell gated */ +export const GatedSidebarProfileStats = (): ReactElement | null => { + const { user } = useAuthContext(); + const hasCoresAccess = useHasAccessToCores(); + + if (!user) { + return null; + } + + const reputation = user.reputation ?? 0; + const balance = user.balance?.amount ?? 0; + const preciseBalance = formatCurrency(balance, { minimumFractionDigits: 0 }); + + return ( + + ); +}; diff --git a/packages/storybook/stories/cores-boost/flows/cores/Shell.tsx b/packages/storybook/stories/cores-boost/flows/cores/Shell.tsx new file mode 100644 index 00000000000..9946f88089d --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/Shell.tsx @@ -0,0 +1,66 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useCallback } from 'react'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { QuestRewardType } from '@dailydotdev/shared/src/graphql/quests'; +import type { QuestRewardCounterEventDetail } from '@dailydotdev/shared/src/lib/questRewardAnimation'; +import { QUEST_REWARD_COUNTER_EVENT } from '@dailydotdev/shared/src/lib/questRewardAnimation'; + +const emit = (detail: QuestRewardCounterEventDetail) => + window.dispatchEvent( + new CustomEvent(QUEST_REWARD_COUNTER_EVENT, { + detail, + }), + ); + +const HITS = 5; +const HIT_MS = 140; + +/** + * Counts the header Cores pill up the way a quest claim does (the production + * QuestButton events), then stores the new balance on the user. + */ +export const useAnimateCores = (): ((delta: number) => void) => { + const { user, updateUser } = useAuthContext(); + + return useCallback( + (delta: number) => { + if (!user) { + return; + } + + const base = user.balance.amount; + const claimId = `sb-${Date.now()}`; + const step = Math.ceil(delta / HITS); + + emit({ + phase: 'start', + rewardType: QuestRewardType.Cores, + claimId, + baseValue: base, + clearAfterMs: HITS * HIT_MS + 600, + }); + Array.from({ length: HITS }).forEach((_, index) => { + window.setTimeout( + () => + emit({ + phase: 'hit', + rewardType: QuestRewardType.Cores, + claimId, + delta: Math.min(step, delta - step * index), + }), + (index + 1) * HIT_MS, + ); + }); + window.setTimeout( + () => updateUser({ ...user, balance: { amount: base + delta } }), + HITS * HIT_MS + 300, + ); + }, + [updateUser, user], + ); +}; + +/** The screen inside the production app shell (see CoresProviders) */ +export const Shell = ({ children }: { children: ReactNode }): ReactElement => ( +
    {children}
    +); diff --git a/packages/storybook/stories/cores-boost/flows/cores/UseYourCores.tsx b/packages/storybook/stories/cores-boost/flows/cores/UseYourCores.tsx new file mode 100644 index 00000000000..0bd2f8ba761 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/UseYourCores.tsx @@ -0,0 +1,197 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + ArrowIcon, + CoreIcon, + ReadingStreakIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { awards } from '../../real/catalog'; +import { minAward, minBoost } from './fixtures'; + +// New wallet section: what the balance can do, and whether it covers it. +// Each row opens the real flow (the feed for Award, the latest post for Boost). + +export type CoresUse = 'award' | 'boost' | 'streak'; + +interface UseDefinition { + title: string; + description: string; + icon: ReactNode; + /** Cheapest way to use it, or null when the price is set at the time */ + from: number | null; +} + +const heart = awards.find(({ name }) => name === 'Heart'); + +const uses: Record = { + award: { + title: 'Give an award', + description: 'Thank a creator for a post or a comment', + icon: Award, + from: minAward, + }, + boost: { + title: 'Boost your post', + description: 'Show your post to more developers', + icon: , + from: minBoost, + }, + streak: { + title: 'Restore a lost streak', + description: 'Bring back a streak the day after it breaks', + icon: ( + + ), + from: null, + }, +}; + +const Coverage = ({ + balance, + from, +}: { + balance: number; + from: number | null; +}): ReactElement => { + if (from === null) { + return ( + + Price varies + + ); + } + + if (balance >= from) { + return ( + + You have enough + + ); + } + + return ( + + {formatCoresCurrency(from - balance)} more needed + + ); +}; + +export const CoresUseRow = ({ + use, + balance, + onClick, + isPrimary, + isCompact, +}: { + use: CoresUse; + balance: number; + onClick?: () => void; + isPrimary?: boolean; + /** Title only, for the first Cores sheet */ + isCompact?: boolean; +}): ReactElement => { + const { title, description, icon, from } = uses[use]; + + return ( +
  • + +
  • + ); +}; + +export const UseYourCores = ({ + balance, + order = ['award', 'boost', 'streak'], + onUse, + className, +}: { + balance: number; + order?: CoresUse[]; + onUse?: (use: CoresUse) => void; + className?: string; +}): ReactElement => ( +
    + + Use your Cores + +
      + {order.map((use) => ( + onUse?.(use)} + /> + ))} +
    +
    +); diff --git a/packages/storybook/stories/cores-boost/flows/cores/fixtures.ts b/packages/storybook/stories/cores-boost/flows/cores/fixtures.ts new file mode 100644 index 00000000000..68623e9c9ea --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/fixtures.ts @@ -0,0 +1,264 @@ +import { delay, graphql, HttpResponse } from 'msw'; +import type { UserTransaction } from '@dailydotdev/shared/src/graphql/njord'; +import { + UserTransactionStatus, + UserTransactionType, +} from '@dailydotdev/shared/src/graphql/njord'; +import type { UserQuest } from '@dailydotdev/shared/src/graphql/quests'; +import { + QuestRewardType, + QuestStatus, + QuestType, +} from '@dailydotdev/shared/src/graphql/quests'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; +import { fallbackImages } from '@dailydotdev/shared/src/lib/config'; +import { cloudinaryPostImageCoverPlaceholder } from '@dailydotdev/shared/src/lib/image'; +import { awards } from '../../real/catalog'; +import { activeHistory, coresHandlers } from '../../real/handlers'; +import { productionFlags, viewer } from '../../real/providers'; +import { + bunPost, + notificationsHandlers, + questHandlers, +} from '../../real/pages'; +import { + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import type { Notification } from '@dailydotdev/shared/src/graphql/notifications'; +import { NotificationAvatarType } from '@dailydotdev/shared/src/graphql/notifications'; + +// People, posts and transactions for the Cores flows. Shapes follow +// production, as in real/handlers.ts. + +const me = viewer as unknown as UserShortProfile; + +const system = { + id: 'system', + name: 'daily.dev', + username: 'system', + image: 'https://cdn.daily.dev/assets/maskable_icon.png', + permalink: 'https://app.daily.dev/system', +} as UserShortProfile; + +export const ido = { + id: 'sb-ido', + name: 'Ido Kaplan', + username: 'idokaplan', + image: fallbackImages.avatar, + permalink: 'https://app.daily.dev/idokaplan', +} as UserShortProfile; + +export const coffee = awards.find(({ name }) => name === 'Coffee Mug'); + +export const ownPost = { + id: 'sb-bun-post', + title: 'Why we moved to Bun', + image: cloudinaryPostImageCoverPlaceholder, +}; + +/** The smallest award and the smallest boost (one day at the daily minimum) */ +export const minAward = Math.min(...awards.map(({ value }) => value)); +export const minBoost = (productionFlags.boost_settings as { min: number }).min; + +const minutesAgo = (minutes: number): Date => + new Date(Date.now() - minutes * 60 * 1000); + +const row = ( + id: string, + createdAt: Date, + fields: Partial, +): UserTransaction => ({ + id, + status: UserTransactionStatus.Success, + receiver: me, + value: 0, + valueIncFees: 0, + flags: {}, + balance: { amount: 0 }, + createdAt, + ...fields, +}); + +export const firstQuestReward = row('fc-quest', minutesAgo(1), { + sender: system, + value: 20, + valueIncFees: 20, + referenceType: 'quest_reward:fc-quest' as UserTransactionType, + flags: { note: 'Quest reward: Read 3 posts' }, +}); + +export const idoAward = row('rcv-ido', minutesAgo(2), { + sender: ido, + product: coffee, + value: 50, + valueIncFees: 35, + referenceType: 'post' as UserTransactionType, + referenceId: ownPost.id, + flags: { note: 'Great write-up!' }, +}); + +export const receiveHistory: UserTransaction[] = [idoAward, firstQuestReward]; + +export const pendingPurchase = row('buy-pending', minutesAgo(1), { + status: UserTransactionStatus.Processing, + value: 300, + valueIncFees: 300, +}); + +export const stuckPurchase = row('buy-stuck', minutesAgo(14), { + status: UserTransactionStatus.Processing, + value: 300, + valueIncFees: 300, +}); + +export const landedPurchase = row('buy-landed', minutesAgo(0), { + value: 300, + valueIncFees: 300, +}); + +export const walletHistory: UserTransaction[] = [ + pendingPurchase, + idoAward, + ...activeHistory, +]; + +export const afterPurchaseHistory: UserTransaction[] = [ + landedPurchase, + idoAward, + ...activeHistory, +]; + +/** Fees count what daily.dev kept from the awards you received */ +export const summaryWithFees = (history: UserTransaction[]) => + history.reduce( + (summary, transaction) => { + if (transaction.status !== UserTransactionStatus.Success) { + return summary; + } + if (!transaction.sender && !transaction.product) { + return { ...summary, purchased: summary.purchased + transaction.value }; + } + if (transaction.sender?.id === me.id) { + return { ...summary, spent: summary.spent + transaction.value }; + } + return { + ...summary, + received: summary.received + transaction.valueIncFees, + fees: summary.fees + transaction.value - transaction.valueIncFees, + }; + }, + { purchased: 0, received: 0, spent: 0, fees: 0 }, + ); + +type History = UserTransaction[] | (() => UserTransaction[]); + +const read = (history: History): UserTransaction[] => + typeof history === 'function' ? history() : history; + +/** + * The production handlers, with a summary that also returns fees. Pass a + * function to change the history while the story runs (after a purchase). + */ +export const walletHandlers = (balance: number, history: History) => [ + graphql.query('TransactionSummary', async () => { + await delay(400); + return HttpResponse.json({ + data: { transactionSummary: summaryWithFees(read(history)) }, + }); + }), + graphql.query('transactions', async () => { + await delay(400); + return HttpResponse.json({ + data: { + transactions: { + edges: read(history).map((node) => ({ node })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + }), + graphql.mutation('SayThanksForAward', async () => { + await delay(400); + return HttpResponse.json({ data: { sayThanksForAward: { _: true } } }); + }), + ...questHandlers(readQuest), + ...notificationsHandlers(awardNotifications()), + ...coresHandlers({ balance }), +]; + +export const readQuest: UserQuest = { + userQuestId: 'sb-user-quest', + rotationId: 'sb-rotation', + progress: 3, + status: QuestStatus.Completed, + locked: false, + claimable: true, + quest: { + id: 'sb-quest', + name: 'Read 3 posts', + description: 'Open three posts from your feed today', + type: QuestType.Daily, + eventType: 'post_read', + targetCount: 3, + }, + rewards: [ + { type: QuestRewardType.Xp, amount: 30 }, + { type: QuestRewardType.Cores, amount: 20 }, + ], +}; + +const avatarOf = (person: UserShortProfile) => ({ + type: NotificationAvatarType.User, + image: person.image, + name: person.name, + targetUrl: person.permalink, + referenceId: person.id, +}); + +/** + * The notifications page around the award: the award first, then the reader's + * other recent notifications on the same post. The award row is the + * production row; the flow stories draw their own over it. + */ +export const awardNotifications = (): Notification[] => + [ + { + id: 'sb-award-notification', + type: NotificationType.UserReceivedAward, + icon: NotificationIconType.Core, + title: `

    ${ido.name} gave you ${coffee?.name} (+35 Cores)

    `, + description: '“Great write-up!”', + avatars: [avatarOf(ido)], + attachments: [], + referenceId: 'sb-award-ido', + targetUrl: '/wallet', + createdAt: minutesAgo(2), + }, + { + id: 'sb-upvote-milestone', + type: NotificationType.ArticleUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: `

    Your post ${bunPost.title} reached 50 upvotes

    `, + avatars: [], + attachments: [ + { type: 'post', image: bunPost.image, title: bunPost.title }, + ], + referenceId: bunPost.id, + targetUrl: bunPost.commentsPermalink, + createdAt: minutesAgo(48), + }, + { + id: 'sb-new-comment', + type: NotificationType.ArticleNewComment, + icon: NotificationIconType.Comment, + title: `

    Noa Levi commented on ${bunPost.title}

    `, + description: + 'Did you hit any issues with native addons? We had to keep two services on Node because of sharp.', + avatars: [avatarOf({ ...ido, id: 'sb-noa', name: 'Noa Levi' })], + attachments: [], + referenceId: bunPost.id, + targetUrl: bunPost.commentsPermalink, + createdAt: minutesAgo(95), + }, + ] as unknown as Notification[]; diff --git a/packages/storybook/stories/cores-boost/flows/cores/screens.tsx b/packages/storybook/stories/cores-boost/flows/cores/screens.tsx new file mode 100644 index 00000000000..a35be41b9b6 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/cores/screens.tsx @@ -0,0 +1,100 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { + bunPost, + DailyQuestsPage, + QUEST_CLAIMED_EVENT, + findNotificationRow, + NotificationsPage, + PostPage, + SwapInPage, +} from '../../real/pages'; +import { CoresNudgeStrip } from './CoresNudgeStrip'; +import { AwardNotificationRow } from './AwardNotification'; + +// Screens the Cores flow stories share, each on the real production page it +// happens on (real/pages.tsx), inside CoresProviders' production shell. + +/** + * The real daily quests page with today's claimable quest. Claim is the + * production QuestButton's, in the sidebar or on the page. + */ +export const QuestScreen = ({ + onClaimed, +}: { + isClaimed?: boolean; + onClaimed?: () => void; +}): ReactElement => { + const claimed = useRef(onClaimed); + claimed.current = onClaimed; + + useEffect(() => { + let timeout: number | undefined; + const onReward = () => { + window.clearTimeout(timeout); + timeout = window.setTimeout(() => claimed.current?.(), 1400); + }; + window.addEventListener(QUEST_CLAIMED_EVENT, onReward); + return () => { + window.removeEventListener(QUEST_CLAIMED_EVENT, onReward); + window.clearTimeout(timeout); + }; + }, []); + + return ; +}; + +/** Your own post on the real post page, with the nudge where the strip goes */ +export const OwnPostScreen = ({ + balance, + onBoost, + showNudge = true, +}: { + balance: number; + onBoost?: () => void; + showNudge?: boolean; +}): ReactElement => { + const [isDismissed, setIsDismissed] = useState(false); + + return ( + onBoost?.()} + onClose={() => setIsDismissed(true)} + /> + ) + } + /> + ); +}; + +/** The real notifications page, with the award row drawn as the flow fixes it */ +export const NotificationsScreen = ({ + onViewWallet, +}: { + onViewWallet?: () => void; +}): ReactElement => ( + <> + + + + + +); + +/** The quest's card on the real page, which the access check changes */ +export const findQuestCard = (): HTMLElement | null => { + const claim = [...document.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Claim', + ); + let card = claim?.parentElement ?? null; + while (card && !card.className.includes('border')) { + card = card.parentElement; + } + return card; +}; diff --git a/packages/storybook/stories/cores-boost/flows/purchase/AwardFixed.tsx b/packages/storybook/stories/cores-boost/flows/purchase/AwardFixed.tsx new file mode 100644 index 00000000000..7ec75254e39 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/purchase/AwardFixed.tsx @@ -0,0 +1,431 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { ModalKind } from '@dailydotdev/shared/src/components/modals/common/types'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons/Core'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { Justify } from '@dailydotdev/shared/src/components/utilities/common'; +import MarkdownInput from '@dailydotdev/shared/src/components/fields/MarkdownInput'; +import { BuyCreditsButton } from '@dailydotdev/shared/src/components/credit/BuyCreditsButton'; +import { AwardFeesNote } from '@dailydotdev/shared/src/components/cores/AwardFeesNote'; +import { CoresBalanceNote } from '@dailydotdev/shared/src/components/cores/CoresBalanceNote'; +import { maxNoteLength } from '@dailydotdev/shared/src/contexts/GiveAwardModalContext'; +import type { Product } from '@dailydotdev/shared/src/graphql/njord'; +import { + award, + getProductsQueryOptions, +} from '@dailydotdev/shared/src/graphql/njord'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + useViewSize, + ViewSize, +} from '@dailydotdev/shared/src/hooks/useViewSize'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { appStoreUrl } from '@dailydotdev/shared/src/lib/constants'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { fallbackImages } from '@dailydotdev/shared/src/lib/config'; + +// Fixed copies of the GiveAwardModal screens +// (components/modals/award/GiveAwardModal.tsx). Changed: each award shows what +// the balance is missing, the CTA names the next step, old iOS builds link to +// the App Store, and after a purchase the note step opens with the award kept. + +export const awardReceiver = { + id: 'sb-sam', + name: 'Sam Rivera', + username: 'samrivera', + image: fallbackImages.avatar, +}; + +const receiverName = awardReceiver.name; + +const AwardItem = ({ + item, + balance, + isSelected, + onClick, +}: { + item: Product; + balance: number; + isSelected: boolean; + onClick: () => void; +}): ReactElement => { + const missing = item.value - balance; + + return ( + + ); +}; + +export interface AwardPickerFixedProps { + /** Preselect an award, e.g. the one the user tapped before buying */ + initialAward?: string; + /** Old iOS builds without in-app purchase */ + canPurchaseCores?: boolean; + onBuyCores: (product: Product) => void; + onPicked: (product: Product) => void; + onRequestClose: () => void; +} + +export const AwardPickerFixed = ({ + initialAward, + canPurchaseCores = true, + onBuyCores, + onPicked, + onRequestClose, +}: AwardPickerFixedProps): ReactElement => { + const { user } = useAuthContext(); + const isMobile = useViewSize(ViewSize.MobileL); + const balance = user?.balance?.amount ?? 0; + const { data: awards } = useQuery(getProductsQueryOptions()); + const [picked, setPicked] = useState(initialAward); + const product = awards?.edges + .map(({ node }) => node) + .find(({ name }) => name === picked); + const missing = product ? product.value - balance : 0; + + return ( + + + product && onBuyCores(product)} + origin={Origin.Award} + /> + {isMobile ? ( + + ) : null} + + +
    + Award user + + Give an Award + +
    + + Show your appreciation! Pick an Award to send to{' '} + + {receiverName} + + ! + +
    + {awards?.edges?.map(({ node: item }) => ( + { + if (item.value > balance) { + setPicked(item.name); + return; + } + onPicked(item); + }} + /> + ))} +
    +
    + {!!product && missing > 0 && ( + + {canPurchaseCores ? ( + + ) : ( + + )} + + + + )} +
    + ); +}; + +export const AwardNoteFixed = ({ + product, + coresAdded, + onBack, + onSent, + onRequestClose, +}: { + product: Product; + /** Cores the purchase just added, confirmed above the note */ + coresAdded?: number; + onBack: () => void; + onSent: () => void; + onRequestClose: () => void; +}): ReactElement => { + const { updateUser, user } = useAuthContext(); + const isMobile = useViewSize(ViewSize.MobileL); + const [note, setNote] = useState(''); + const { mutate: awardMutation, isPending } = useMutation({ + mutationFn: award, + onSuccess: async () => { + if (user) { + await updateUser({ + ...user, + balance: { amount: user.balance.amount - product.value }, + }); + } + onSent(); + }, + }); + + return ( + + + + + + + + ); +}; + +/** Production SuccessScreen, unchanged, so the walkthrough has an end */ +export const AwardSent = ({ + product, + onRequestClose, +}: { + product: Product; + onRequestClose: () => void; +}): ReactElement => ( + + +
    +
    + {product.name} +
    + + + {formatCoresCurrency(product.value)} + +
    +
    +
    + + Congrats! Your Award has been sent to {receiverName} + + + Thanks for supporting the community! + +
    + +
    +
    +
    +); diff --git a/packages/storybook/stories/cores-boost/flows/purchase/BoostFixed.tsx b/packages/storybook/stories/cores-boost/flows/purchase/BoostFixed.tsx new file mode 100644 index 00000000000..c2c6706d59b --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/purchase/BoostFixed.tsx @@ -0,0 +1,574 @@ +import type { ReactElement } from 'react'; +import dynamic from 'next/dynamic'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { ownArticle, PostPage } from '../../real/pages'; +import stripArt from '../../assets/boost-strip-patchy-v2.png'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons/Core'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; +import { MiniCloseIcon } from '@dailydotdev/shared/src/components/icons/MiniClose'; +import { TrendingIcon } from '@dailydotdev/shared/src/components/icons/Trending'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { TooltipArrow } from '@dailydotdev/shared/src/svg/TooltipArrow'; +import { ActionSuccessModal } from '@dailydotdev/shared/src/components/modals/utils/ActionSuccessModal'; +import { BoostWalletPicker } from '@dailydotdev/shared/src/features/boost/BoostWalletPicker'; +import { StartupsBoostPromo } from '@dailydotdev/shared/src/features/squads/components/promos/StartupsPromo'; +import { useBoostWallet } from '@dailydotdev/shared/src/features/boost/useBoostWallet'; +import { usePostBoostEstimation } from '@dailydotdev/shared/src/features/boost/usePostBoostEstimation'; +import { useFeaturesReadyContext } from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { boostSettingsFeature } from '@dailydotdev/shared/src/lib/featureManagement'; +import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CampaignWallet } from '@dailydotdev/shared/src/graphql/campaigns'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import { boostSuccessCover } from '@dailydotdev/shared/src/lib/image'; +import { + boostDocsLink, + walletUrl, +} from '@dailydotdev/shared/src/lib/constants'; + +const Slider = dynamic( + () => + import('@dailydotdev/shared/src/components/fields/Slider').then( + (mod) => mod.Slider, + ), + { ssr: false }, +); + +// Fixed copies of the first-boost screens: the post page entries +// (PostHeaderActions, BoostNewPostStrip), BoostPostModal, and its success +// modal. Changed: the strip has its own Boost button and a "taking off" +// variant, the form offers a starter preset, the CTA names the purchase, and +// the boost starts by itself once the Cores land. + +/** The starter preset under test. Below today's 5,000 a day minimum, so the + * lower minimum ships in the same experiment (boost_settings). */ +export const starterBoost = { coresPerDay: 2000, totalDays: 1 }; + +const Strip = ({ + children, + onBoost, + onDismiss, + className, +}: { + children: React.ReactNode; + onBoost: () => void; + onDismiss?: () => void; + className?: string; +}): ReactElement => { + return ( +
    + {/* New art: kept to the left so it never sits behind the copy */} + +
    + + + + {children} + + + {onDismiss && ( +
    +
    + ); +}; + +export type OwnPostState = 'new' | 'takingOff'; + +/** Your own article on the real post page, the strip where production puts it */ +export const OwnPostFixed = ({ + post, + state, + onBoost, +}: { + post: Post; + state: OwnPostState; + onBoost: () => void; +}): ReactElement => { + const [isDismissed, setDismissed] = useState(false); + + return ( + setDismissed(true)}> + Boost to reach + 10k more devs + now! + + ) : ( + setDismissed(true)}> + This post is taking off. + Boost it while developers are paying attention. + + )) + } + /> + ); +}; + +type BudgetMode = 'starter' | 'custom'; + +export interface BoostPostFixedModalProps { + post: Post; + initialMode?: BudgetMode; + onBuyCores: (budget: typeof starterBoost) => void; + onBoost: (budget: typeof starterBoost) => void; + onRequestClose: () => void; +} + +const PresetOption = ({ + isSelected, + title, + description, + onClick, +}: { + isSelected: boolean; + title: string; + description: string; + onClick: () => void; +}): ReactElement => ( + +); + +export const BoostPostFixedModal = ({ + post, + initialMode = 'starter', + onBuyCores, + onBoost, + onRequestClose, +}: BoostPostFixedModalProps): ReactElement => { + const { user } = useAuthContext(); + const { getFeatureValue } = useFeaturesReadyContext(); + const boostSettings = getFeatureValue(boostSettingsFeature); + const [mode, setMode] = useState(initialMode); + const [custom, setCustom] = useState({ + coresPerDay: boostSettings.default_cores, + totalDays: boostSettings.default_days, + }); + const budget = mode === 'starter' ? starterBoost : custom; + const [estimate, setEstimate] = useState(budget.coresPerDay); + const [updateEstimate] = useDebounceFn(setEstimate, 400); + const totalSpendInt = budget.coresPerDay * budget.totalDays; + const totalSpend = largeNumberFormat(totalSpendInt); + const squadId = + post.source?.type === SourceType.Squad ? post.source.id : undefined; + const { squadWallet, wallet, setWallet, balance } = useBoostWallet(squadId); + const { estimatedReach, canBoost, isLoading } = usePostBoostEstimation({ + post, + query: { budget: estimate }, + }); + const missing = totalSpendInt - balance; + + useEffect(() => { + updateEstimate(budget.coresPerDay); + }, [budget.coresPerDay, updateEstimate]); + + const potentialReach = (() => { + if (!canBoost) { + return ( + + + + Reading your post to estimate reach… + + + ); + } + if (isLoading) { + return ; + } + + const min = largeNumberFormat(estimatedReach.min); + const max = largeNumberFormat( + Math.max(estimatedReach.min, estimatedReach.max), + ); + + return `${min} - ${max}`; + })(); + + const onButtonClick = () => { + if (missing > 0) { + return wallet === CampaignWallet.User ? onBuyCores(budget) : undefined; + } + + return onBoost(budget); + }; + + return ( + + + + Boost your post + +
    + +
    +
    + + + + Give your content the spotlight it deserves. Our auto-targeting engine + ensures your post gets shown to the developers most likely to care. + + Learn more + + +
    +
    + + {post.title} + + {post.image && ( + + )} +
    +
    + + {totalSpend} Cores over {budget.totalDays}{' '} + {budget.totalDays === 1 ? 'day' : 'days'} + + + Total spend + + + {potentialReach} + + + Estimated daily reach + +
    +
    +
    + + Budget + +
    + setMode('starter')} + /> + setMode('custom')} + /> +
    + {mode === 'custom' && ( +
    + + + + {largeNumberFormat(custom.coresPerDay || 0)} Cores per day + + + + setCustom((state) => ({ ...state, coresPerDay: value })) + } + /> + + Duration + + + {custom.totalDays} days + + + setCustom((state) => ({ ...state, totalDays: value })) + } + /> +
    + )} +
    + {squadWallet && ( + + )} + +
    + + + + + ); +}; + +const launchSeconds = 3; + +/** New: the boost the user set up starts by itself once the Cores land */ +export const BoostLaunchingModal = ({ + post, + budget, + onLaunched, + onChange, + onRequestClose, + isPaused, +}: { + post: Post; + budget: typeof starterBoost; + onLaunched: () => void; + onChange: () => void; + onRequestClose: () => void; + /** Hold the countdown, to review the screen */ + isPaused?: boolean; +}): ReactElement => { + const [secondsLeft, setSecondsLeft] = useState(launchSeconds); + + useEffect(() => { + if (isPaused) { + return undefined; + } + if (secondsLeft === 0) { + onLaunched(); + return undefined; + } + const timer = setTimeout(() => setSecondsLeft((left) => left - 1), 1000); + return () => clearTimeout(timer); + }, [secondsLeft, onLaunched, isPaused]); + + const total = budget.coresPerDay * budget.totalDays; + + return ( + + + + + Your Cores are in. Starting your boost… + +
    + {post.image && ( + + )} +
    + + Boosting for {budget.totalDays}{' '} + {budget.totalDays === 1 ? 'day' : 'days'} ·{' '} + {total.toLocaleString()} Cores + + + {post.title} + +
    + +
    + + + + Starts in {secondsLeft}s + + +
    +
    + ); +}; + +/** Production success modal; changed: it points to the Boosts page */ +export const BoostLiveFixedModal = ({ + onSeeBoost, + onRequestClose, +}: { + onSeeBoost: () => void; + onRequestClose: () => void; +}): ReactElement => ( + +); diff --git a/packages/storybook/stories/cores-boost/flows/purchase/BuyCoresFixed.tsx b/packages/storybook/stories/cores-boost/flows/purchase/BuyCoresFixed.tsx new file mode 100644 index 00000000000..1615284eda2 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/purchase/BuyCoresFixed.tsx @@ -0,0 +1,345 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import type { Paddle } from '@paddle/paddle-js'; +import { useQuery } from '@tanstack/react-query'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { ModalBody } from '@dailydotdev/shared/src/components/modals/common/ModalBody'; +import { ModalKind } from '@dailydotdev/shared/src/components/modals/common/types'; +import { BuyCreditsButton } from '@dailydotdev/shared/src/components/credit/BuyCreditsButton'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons/Core'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { CoreOptionList } from '@dailydotdev/shared/src/components/cores/CoreOptionList'; +import { getRecommendedCorePrice } from '@dailydotdev/shared/src/components/cores/recommendedCorePrice'; +import type { + BuyCoresContextData, + CoreProductOption, +} from '@dailydotdev/shared/src/contexts/BuyCoresContext/types'; +import { BuyCoresContext } from '@dailydotdev/shared/src/contexts/BuyCoresContext/types'; +import { coresPricesQueryOptions } from '@dailydotdev/shared/src/graphql/njord'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + useViewSize, + ViewSize, +} from '@dailydotdev/shared/src/hooks/useViewSize'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { walletUrl } from '@dailydotdev/shared/src/lib/constants'; +import { noop } from '../../real/providers'; +import { StartupsBuyCoresPromo } from '@dailydotdev/shared/src/features/squads/components/promos/StartupsPromo'; + +// Fixed copy of BuyCoresModal (components/modals/award/BuyCoresModal.tsx). +// Changed: the smallest pack covering the shortfall is preselected on every +// width, the copy names what the Cores are for (award or boost) and counts the +// balance, the header "+" works, and a timeout keeps the user in the modal. + +export type CoresPurpose = 'award' | 'boost'; + +/** Stands in for the Paddle iframe, which needs a client token */ +export const CheckoutStandIn = ({ + pack, + onPay, + className, +}: { + pack?: { cores: number; price: string }; + onPay: () => void; + className?: string; +}): ReactElement => ( +
    + + Paddle checkout opens here in production + + + {pack ? `${formatCoresCurrency(pack.cores)} Cores` : 'Pick a pack'} + + +
    +); + +export const NeededCopy = ({ + purpose, + cost, + balance, + awardName, + selected, +}: { + purpose: CoresPurpose; + cost: number; + balance: number; + awardName?: string; + selected?: CoreProductOption; +}): ReactElement => { + const target = + purpose === 'award' ? (awardName ?? 'the Award') : 'your boost'; + const missing = cost - balance; + + if (!selected || selected.value < missing) { + return ( + + You need{' '} + + {formatCoresCurrency(missing)} more Cores + {' '} + {purpose === 'award' ? `to send ${target}.` : 'to boost the post.'} + + ); + } + + return ( + + + Covers your {purpose}. + {' '} + {formatCoresCurrency(cost)} Cores go to {target}, and{' '} + + {formatCoresCurrency(balance + selected.value - cost)} + {' '} + stay in your balance. + + ); +}; + +export interface BuyCoresFixedModalProps { + purpose: CoresPurpose; + /** What the award or boost costs */ + cost: number; + awardName?: string; + onPay: (pack: CoreProductOption) => void; + onRequestClose: () => void; +} + +export const BuyCoresFixedModal = ({ + purpose, + cost, + awardName, + onPay, + onRequestClose, +}: BuyCoresFixedModalProps): ReactElement => { + const { user, isLoggedIn } = useAuthContext(); + const isMobile = useViewSize(ViewSize.MobileL); + const balance = user?.balance?.amount ?? 0; + const missing = Math.max(0, cost - balance); + const { data: prices } = useQuery( + coresPricesQueryOptions({ user, isLoggedIn }), + ); + const [selectedProduct, setSelectedProduct] = useState(); + const [isCheckoutOpen, setCheckoutOpen] = useState(!isMobile); + + useEffect(() => { + const recommended = getRecommendedCorePrice({ prices, minCores: missing }); + if (!recommended || selectedProduct) { + return; + } + setSelectedProduct({ + id: recommended.priceId, + value: recommended.metadata.coresValue ?? 0, + }); + }, [prices, missing, selectedProduct]); + + const pack = useMemo(() => { + const price = prices?.find( + ({ priceId }) => priceId === selectedProduct?.id, + ); + return price && selectedProduct + ? { cores: selectedProduct.value, price: price.price.formatted } + : undefined; + }, [prices, selectedProduct]); + + const context: BuyCoresContextData = { + paddle: {} as Paddle, + openCheckout: () => setCheckoutOpen(true), + amountNeeded: cost, + minCoresToBuy: missing, + selectedProduct, + setSelectedProduct, + activeStep: 'INTRO', + setActiveStep: noop, + origin: purpose === 'award' ? Origin.Award : Origin.BoostPost, + }; + + const onPlusClick = () => + document + .querySelector('[role="radiogroup"] [aria-checked="true"]') + ?.focus(); + + const pay = () => selectedProduct && onPay(selectedProduct); + + const options: ReactNode = ( +
    + + {purpose === 'boost' && ( +
    + +
    + )} + +
    + ); + + return ( + + + + + {isMobile && ( + + )} + + {isMobile ? ( + <> + + {isCheckoutOpen ? ( + + ) : ( + options + )} + + {!isCheckoutOpen && ( + + + + )} + + ) : ( + +
    + {options} +
    + +
    +
    +
    + )} +
    +
    + ); +}; + +export type ProcessingState = 'processing' | 'timeout'; + +/** Fixed copy of BuyCoresProcessing: a timeout stays here with Check again */ +export const BuyCoresProcessingFixed = ({ + state, + draftName, + onCheckAgain, + onRequestClose, +}: { + state: ProcessingState; + /** What the user was buying for, kept while the payment settles */ + draftName: string; + onCheckAgain: () => void; + onRequestClose: () => void; +}): ReactElement => ( + + + + {state === 'processing' ? ( + <> + + Processing your payment, please wait... + + + + ) : ( + <> + + Still processing… + + + Payments usually land in under a minute. {draftName} is saved, and + we pick it up as soon as your Cores arrive. + + + + + Check the status in your wallet + + + + )} + + +); diff --git a/packages/storybook/stories/cores-boost/flows/purchase/stage.tsx b/packages/storybook/stories/cores-boost/flows/purchase/stage.tsx new file mode 100644 index 00000000000..3ab91fcb352 --- /dev/null +++ b/packages/storybook/stories/cores-boost/flows/purchase/stage.tsx @@ -0,0 +1,40 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { PostPage, samPost } from '../../real/pages'; + +/** + * The page behind a flow, inside the production app shell. By default the + * real post page of Sam's article, where an award is given. + */ +export const Stage = ({ + children, + background = , + onRestart, + isDone, +}: { + children?: ReactNode; + /** What the app shows behind the modals */ + background?: ReactNode; + onRestart?: () => void; + isDone?: boolean; +}): ReactElement => ( + <> + {background} + {isDone && onRestart && ( + + )} + {children} + +); diff --git a/packages/storybook/stories/cores-boost/journeys.tsx b/packages/storybook/stories/cores-boost/journeys.tsx new file mode 100644 index 00000000000..4cef19b81d2 --- /dev/null +++ b/packages/storybook/stories/cores-boost/journeys.tsx @@ -0,0 +1,1347 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Step } from './board'; +import { Frame, Lane, Metric, Sticky, frameIcons } from './board'; + +// Every "today" step is production as of main f9d90efa3 (2026-11-03). +// Numbers are from Agent Smith (90 days, team excluded) and the post boost +// audit of 2026-11-01 (docs/boost/post-boost-audit-2026-11-01.md). + +export const frames = [ + { id: 'start', title: 'Read me' }, + { id: 'research', title: 'Research' }, + { id: 'first-cores', title: 'First Cores' }, + { id: 'give-award', title: 'Give an award' }, + { id: 'receive-award', title: 'Receive an award' }, + { id: 'wallet', title: 'Wallet and buying' }, + { id: 'first-boost', title: 'First boost' }, + { id: 'after-boost', title: 'After the boost' }, + { id: 'plan', title: 'Fix plan' }, +]; + +const Legend = (): ReactElement => ( +
    + How to read the board +
    + What breaks or leaks today. + + What we change, pinned to the step it changes. + + + Production numbers behind the step. + + + In-app feedback, paraphrased. + + + Can't ship from apps alone: needs daily-api or Njord. + +
    +
    + + Changed step + + + New step + + + Broken + + + Dead end + +
    +
    +); + +const ReadMe = (): ReactElement => ( + +

    + Every journey a user takes with Cores, as it works in production today + (red lane), and the flow we propose (green lane). The goal is that no + journey ends somewhere the user didn't mean to go, and that free Cores + lead to a first spend. +

    +

    + Three problems run through all of it: free Cores have no next step, + buying Cores interrupts the task the user was doing, and boost has no + home after launch. +

    + + } + > + + +); + +const Research = (): ReactElement => ( + +
    +
    + Earning and spending +
    + + + + +
    + + 5,172 users got their first Cores in 90 days: 4,842 quest rewards, 140 + awards, 37 purchases, 22 Plus giveback. + +
    +
    + Buying Cores +
    + + + + +
    + + Start buying by origin: wallet package 492, award 378, wallet CTA 252. + Checkout events carry the origin only since apps#7194, so per-origin + conversion starts now. + +
    +
    + Boost +
    + + + + +
    + + New boosters fell from about 23 a month to 3–11 after the minimum went + from 1k to 5k Cores a day (Nov 6, 2025). + +
    +
    +
    + Complaint themes +
    + + Cash out or more uses (8). "Let me pay for Plus with + Cores", "creators should be able to withdraw". + + + Awards (6). The notification doesn't say what it was for, + the message sometimes never arrives, no way to thank for gifted Cores. + + + Boost drains fast (5). Jun 26–30 cluster: "5k gone in 10 + minutes". A delivery incident. + + + Boost price and minimum (4). One user bought 100 Cores for €1 + thinking it was enough to boost, then asked for a refund. + + + Paid, no Cores (4). $13.20 through PayPal, nothing arrived. + Deleting a post mid-boost lost about 5k unspent Cores. + + + Wallet numbers (4). Balance isn't received minus spent; + streak restores are missing from Spent; +35 vs a 50 award. + + + Two payment tickets (the $13.20 one and a €1 refund) are marked + resolved with no reply. Someone should check them now. + + + Feedback only covers in-app tickets. App store reviews, Discord and + the support inbox weren't reachable. + +
    +
    + +); + +const firstCoresToday: Step[] = [ + { + surface: 'Widget', + title: 'Quest reward', + ui: ['# Read 3 posts', '~', '[Claim +20 Cores]'], + where: 'QuestCard.tsx · GettingStartedWidget.tsx', + notes: [ + { + tone: 'data', + text: '94% of new Cores holders got their first Cores here.', + }, + { + tone: 'pain', + text: 'Quest cards show Cores amounts without checking Cores access.', + }, + ], + }, + { + surface: 'System', + title: 'Header balance animates', + via: 'claim', + ui: ['(Core 20)', 'Tooltip: Wallet · 20 Cores'], + where: 'ProfileButton.tsx', + notes: [ + { + tone: 'pain', + text: 'Nothing says what Cores are or what to do with them.', + }, + ], + }, + { + surface: 'Page', + title: 'Wallet', + via: 'click the pill', + ui: [ + '# Core wallet', + 'Balance 20 · Received 20', + 'Quest reward +20', + '[Buy Cores]', + ], + where: '/wallet', + status: 'deadend', + notes: [ + { + tone: 'pain', + text: 'The only action is Buy Cores. No path to award, boost or restore a streak.', + }, + { + tone: 'data', + text: 'Only 7% of quest earners ever spend. 164 posters hold 1,000+ Cores and never boosted (304k Cores idle).', + }, + ], + }, +]; + +const firstCoresNoAccess: Step[] = [ + { + surface: 'Menu', + title: 'Sidebar stats and Cores shortcut', + ui: ['Cores wallet · 0'], + where: 'SidebarProfileStats.tsx · SidebarShortcutsDock.tsx', + notes: [ + { + tone: 'pain', + text: 'Shown with no access check, to users whose coresRole is None.', + }, + ], + }, + { + surface: 'Page', + title: 'Redirected home', + via: 'click', + ui: ['/wallet → /'], + status: 'deadend', + }, +]; + +const firstCoresFixed: Step[] = [ + { + surface: 'Widget', + title: 'Quest reward', + status: 'changed', + ui: ['# Read 3 posts', '~', '[Claim +20 Cores]'], + notes: [ + { + tone: 'fix', + text: 'Cores rewards and wallet links only render with useHasAccessToCores, sidebar stats and dock shortcut included.', + }, + ], + }, + { + surface: 'Sheet', + title: 'Your first Cores', + via: 'first claim only', + status: 'new', + ui: [ + '# You earned your first Cores', + 'Give an award to a creator', + 'Boost your post', + 'Restore a lost streak', + '[Show me] (Later)', + ], + notes: [ + { + tone: 'fix', + text: 'Shown once (ActionType). The primary option depends on the user: a poster sees Boost first, a reader sees Award first.', + }, + { + tone: 'fix', + text: 'Ship behind an experiment flag that defaults to control.', + }, + ], + }, + { + surface: 'Page', + title: 'Wallet with "Use your Cores"', + via: 'Show me', + status: 'changed', + ui: [ + '# Core wallet', + 'Balance 20', + '# Use your Cores', + 'Award · from 10 Cores', + 'Boost a post · from 5,000', + 'Streak restore', + '(Buy Cores)', + ], + notes: [ + { + tone: 'fix', + text: 'Each row says whether the balance covers it, and opens the real flow (the latest post for Boost, the feed for Award).', + }, + ], + }, + { + surface: 'Widget', + title: 'Nudge on own post', + via: 'later, on a post', + status: 'new', + ui: ['You have 1,200 Cores', 'Enough to boost this post', '[Boost]'], + notes: [ + { + tone: 'fix', + text: 'Only when the balance covers the smallest boost, so it never leads to a purchase wall.', + }, + ], + }, +]; + +const FirstCores = (): ReactElement => ( + + + + + +); + +const giveAwardToday: Step[] = [ + { + surface: 'Page', + title: 'Post, comment or profile', + ui: ['~', '~', '(Award)'], + where: 'AwardButton.tsx', + }, + { + surface: 'Modal', + title: 'Pick an award', + via: 'click Award', + ui: [ + '# Give an Award (Core 20 +)', + 'Coffee 50 · Cake 100 · …', + '[Buy Cores 50]', + ], + where: 'GiveAwardModal.tsx', + notes: [ + { + tone: 'pain', + text: 'Awards you can’t afford look the same as the ones you can, until you pick one.', + }, + ], + }, + { + surface: 'Modal', + title: 'Buy Cores', + via: 'not enough Cores', + ui: ['You need 30 more Cores', '100 · 300 · 600 · …', '[Buy]'], + where: 'BuyCoresModal.tsx', + notes: [ + { + tone: 'pain', + text: 'Copy is right for awards, but the "+" in this header does nothing.', + }, + ], + }, + { + surface: 'Checkout', + title: 'Paddle checkout', + via: 'pick a pack', + ui: ['Card · PayPal · Apple Pay', '[Pay]'], + where: 'usePaddlePayment.ts', + notes: [ + { + tone: 'data', + text: 'Across all origins, 388 started checkout and 115 completed (30%).', + }, + ], + }, + { + surface: 'Modal', + title: 'Processing', + via: 'paid', + ui: ['Processing your payment…', '~'], + where: 'BuyCoresProcessing', + notes: [ + { + tone: 'pain', + text: 'Gives up after 60s and sends the user to the wallet. The award is lost.', + }, + ], + }, + { + surface: 'Modal', + title: 'You got your Cores!', + via: 'webhook lands', + ui: ['# You got your Cores!', '[Got it]'], + }, + { + surface: 'Modal', + title: 'Back to the picker', + via: 'Got it', + status: 'broken', + ui: ['# Give an Award', 'Coffee · Cake · …', 'Nothing selected'], + notes: [ + { + tone: 'pain', + text: 'The picked award is gone. Pick again, write the note, then Send.', + }, + ], + }, +]; + +const giveAwardExtension: Step[] = [ + { + surface: 'Page', + title: 'Webapp /cores', + ui: ['/cores?origin=award&next=…', '[Buy]'], + where: 'GiveAwardModalContext.tsx:176', + }, + { + surface: 'Page', + title: 'Homepage', + via: 'Got it', + status: 'deadend', + ui: ['/'], + notes: [ + { + tone: 'pain', + text: 'Only post awards set next. Comment, user, Squad and plugin awards end on the homepage.', + }, + ], + }, +]; + +const giveAwardIos: Step[] = [ + { + surface: 'Modal', + title: 'Insufficient Cores', + status: 'deadend', + ui: ['(Insufficient Cores)'], + notes: [ + { + tone: 'pain', + text: 'iOS builds without in-app purchase get a disabled button with no reason or next step.', + }, + ], + }, +]; + +const giveAwardFixed: Step[] = [ + { + surface: 'Page', + title: 'Post, comment or profile', + ui: ['~', '~', '(Award)'], + }, + { + surface: 'Modal', + title: 'Pick an award', + via: 'click Award', + status: 'changed', + ui: [ + '# Give an Award (Core 20)', + 'Coffee 50 · +30 needed', + '[Get 30 Cores and send]', + ], + notes: [ + { + tone: 'fix', + text: 'Each award shows what’s missing, and the CTA names the next step.', + }, + ], + }, + { + surface: 'Modal', + title: 'Buy Cores, pack preselected', + via: 'CTA', + status: 'changed', + ui: ['Covers your award', '100 ✓ · 300 · 600', '[Buy 100 Cores]'], + notes: [ + { + tone: 'fix', + text: 'Preselect the smallest pack that covers the shortfall. The "+" stops being a no-op.', + }, + ], + }, + { + surface: 'Checkout', + title: 'Paddle checkout', + via: 'Buy', + ui: ['Card · PayPal · Apple Pay', '[Pay]'], + }, + { + surface: 'Modal', + title: 'Processing', + via: 'paid', + status: 'changed', + ui: ['Still processing…', '(Check again)'], + notes: [ + { + tone: 'fix', + text: 'On timeout, stay in the modal with Check again, and keep the award draft.', + }, + ], + }, + { + surface: 'Modal', + title: 'Note and send', + via: 'Cores land', + status: 'changed', + ui: ['Coffee · 50 Cores', 'Add a note…', '[Send award]'], + notes: [ + { + tone: 'fix', + text: 'Skip the picker: land on the note step with the award still selected.', + }, + ], + }, + { + surface: 'Modal', + title: 'Award sent', + via: 'Send', + ui: ['# Congrats!', 'Animated award'], + }, +]; + +const giveAwardFixedOther: Step[] = [ + { + surface: 'Page', + title: 'Back where you were', + status: 'changed', + ui: ['next = asPath', 'Award modal reopens'], + notes: [ + { + tone: 'fix', + text: 'Extension: every award type passes next (router.asPath) and reopens the award with the same pick.', + }, + { + tone: 'fix', + text: 'iOS without IAP: "Update the app to buy Cores", linked to the App Store.', + }, + ], + }, +]; + +const GiveAward = (): ReactElement => ( + + + + + + + +); + +const receiveToday: Step[] = [ + { + surface: 'System', + title: 'Someone sends an award', + ui: ['Coffee · 50 Cores', 'Fee taken: 15'], + where: 'award mutation · api.user-received-award', + }, + { + surface: 'Notification', + title: 'Award received', + via: 'push and bell', + ui: ['Ido gave you an Award', '(Say thanks)'], + where: 'user_received_award', + notes: [ + { + tone: 'voice', + text: '"The notification doesn’t say what it was for." The note sometimes never arrives.', + }, + { + tone: 'voice', + text: '"It said +35 Cores but the award was 50." The fee isn’t explained anywhere.', + }, + ], + }, + { + surface: 'Page', + title: 'Post or profile', + via: 'click', + ui: ['targetUrl from the server', '~'], + status: 'deadend', + notes: [ + { + tone: 'pain', + text: 'No link to the wallet. The header balance stays stale until the next boot refetch.', + }, + { + tone: 'data', + text: '110 of 544 recipients (20%) opened the wallet within 7 days.', + }, + ], + }, +]; + +const receiveFixed: Step[] = [ + { + surface: 'System', + title: 'Someone sends an award', + ui: ['Coffee · 50 Cores'], + }, + { + surface: 'Notification', + title: 'Award received, with context', + via: 'push and bell', + status: 'changed', + ui: [ + 'Ido gave you Coffee (+35 Cores)', + 'for “Why we moved to Bun”', + '“Great write-up!”', + '(Say thanks) (View in wallet)', + ], + notes: [ + { + tone: 'backend', + text: 'Notification copy needs the award name, net Cores, the post title and the note.', + }, + ], + }, + { + surface: 'System', + title: 'Balance refreshes', + via: 'on arrival', + status: 'new', + ui: ['(Core 55)', 'Pill animates like a quest claim'], + notes: [ + { + tone: 'fix', + text: 'Refresh user.balance when a Cores notification arrives instead of waiting for boot.', + }, + ], + }, + { + surface: 'Page', + title: 'Wallet, row highlighted', + via: 'View in wallet', + status: 'changed', + ui: [ + 'Ido · Coffee · +35', + '50 award, 15 creator fee', + '“Great write-up!” (Say thanks)', + '# Use your Cores', + ], + notes: [ + { + tone: 'fix', + text: 'Explain the fee on the row, allow thanks from the wallet, and show "Use your Cores" next.', + }, + { + tone: 'data', + text: 'Success metric: share of recipients who open the wallet in 7 days (20% today).', + }, + ], + }, +]; + +const ReceiveAward = (): ReactElement => ( + + + + +); + +const walletToday: Step[] = [ + { + surface: 'Menu', + title: 'Ways in', + ui: [ + 'Header pill · profile menu', + 'Sidebar · settings · You page', + 'Award modal pill (new tab)', + ], + where: 'walletUrl, 14 links', + }, + { + surface: 'Page', + title: 'Wallet', + via: 'click', + ui: [ + 'Balance · Purchased · Received · Spent', + 'Transaction history', + '[Buy Cores]', + ], + where: 'pages/wallet.tsx', + notes: [ + { + tone: 'pain', + text: 'Received is after fees and Spent before fees, and Spent leaves out streak restores, so the totals never add up.', + }, + { + tone: 'pain', + text: 'Balance is client state from boot and isn’t refetched on the page.', + }, + ], + }, + { + surface: 'Page', + title: '/cores', + via: 'Buy Cores', + ui: ['/cores?origin=wallet page cta', 'Packs + inline checkout'], + where: 'pages/cores/index.tsx', + notes: [ + { + tone: 'pain', + text: 'No next. The nav drawer, profile and settings "+" buttons don’t set one either.', + }, + ], + }, + { + surface: 'Checkout', + title: 'Checkout', + via: 'pick a pack', + ui: ['Mobile: /cores/payment?pid', '[Pay]'], + }, + { + surface: 'Modal', + title: 'You got your Cores!', + via: 'paid', + ui: ['[Got it]'], + notes: [ + { + tone: 'voice', + text: '"Paid $13.20, no Cores arrived." Webhook errors stay Pending and Paddle doesn’t retry.', + }, + ], + }, + { + surface: 'Page', + title: 'Homepage', + via: 'Got it', + status: 'deadend', + ui: ['/'], + notes: [ + { + tone: 'pain', + text: 'Bought from the wallet, landed on the feed.', + }, + ], + }, +]; + +const walletFixed: Step[] = [ + { + surface: 'Menu', + title: 'Ways in', + status: 'changed', + ui: ['Same links, access-gated', 'Award notification', 'Boost rows'], + }, + { + surface: 'Page', + title: 'Wallet', + via: 'click', + status: 'changed', + ui: [ + 'Balance (refetched)', + 'Purchased · Received · Spent · Fees', + '# Use your Cores', + 'History · Pending rows explained', + '[Buy Cores]', + ], + notes: [ + { + tone: 'fix', + text: 'Totals reconcile: show the fee line, count streak restores in Spent.', + }, + { + tone: 'fix', + text: 'Pending row: "Usually under a minute", plus a support link after 10 minutes.', + }, + ], + }, + { + surface: 'Page', + title: '/cores', + via: 'Buy Cores', + status: 'changed', + ui: ['/cores?origin=…&next=/wallet', 'Packs + inline checkout'], + notes: [ + { + tone: 'fix', + text: 'Every /cores link passes next (asPath). Streak flows switch from router.pathname to asPath.', + }, + ], + }, + { + surface: 'Checkout', + title: 'Checkout', + via: 'pick a pack', + ui: ['[Pay]'], + }, + { + surface: 'Page', + title: 'Back to the wallet', + via: 'Got it', + status: 'changed', + ui: ['Purchased +300 highlighted', '# Use your Cores'], + notes: [ + { + tone: 'backend', + text: 'Alert on purchases stuck in Processing for 10+ minutes, and let Paddle retry when the handler fails.', + }, + ], + }, +]; + +const Wallet = (): ReactElement => ( + + + + +); + +const firstBoostToday: Step[] = [ + { + surface: 'Page', + title: 'Own post', + ui: [ + '(Boost) in the header', + 'Strip: Boost to reach 10k more devs now!', + 'Menu: Boost post', + ], + where: 'BoostButton · BoostNewPostStrip · PostOptionButton', + notes: [ + { + tone: 'pain', + text: 'The strip has no button, it only points at the header. It never shows on article posts.', + }, + { + tone: 'data', + text: '3,954 dismissed the strip. 30 of them boosted.', + }, + { + tone: 'pain', + text: 'Gates differ: the menu offers Boost on Brief posts, and the analytics header skips canBoost.', + }, + ], + }, + { + surface: 'Modal', + title: 'Boost your post', + via: 'Boost', + ui: [ + 'Budget 5,000 / day', + 'Duration 7 days', + '35,000 Cores · est. reach', + '[Boost post for 35,000 Cores]', + ], + where: 'BoostPostModal.tsx · boost_settings', + notes: [ + { + tone: 'pain', + text: 'On a new post the CTA is disabled for up to 30s while reach loads, with no explanation.', + }, + { + tone: 'data', + text: 'The minimum is 5,000 Cores a day. 0.5% of eligible posters boosted in 90 days.', + }, + ], + }, + { + surface: 'Modal', + title: 'Buy Cores', + via: 'not enough Cores', + ui: ['You need 34,980 more Cores', '100 · 300 · 600 · …', '[Buy]'], + notes: [ + { + tone: 'pain', + text: 'Packs aren’t sized to the boost; the need parameter exists but nothing sets it.', + }, + { + tone: 'data', + text: '34 of the 39 buyers who never boosted bought 600 Cores or less.', + }, + ], + }, + { + surface: 'Checkout', + title: 'Checkout', + via: 'pick a pack', + ui: ['[Pay]'], + notes: [ + { + tone: 'data', + text: 'On their own post: 93 started checkout, 28 completed. Most errors carry no reason.', + }, + ], + }, + { + surface: 'Modal', + title: 'Back to the form', + via: 'Got it', + status: 'broken', + ui: ['Budget kept', '[Boost post for 35,000 Cores]'], + notes: [ + { + tone: 'pain', + text: 'The user has to press Boost again. 15 of 28 buyers boosted within a day.', + }, + ], + }, + { + surface: 'Modal', + title: 'Boost is live!', + via: 'Boost', + ui: ['# Boost is live!', '[Track performance]'], + }, +]; + +const firstBoostFixed: Step[] = [ + { + surface: 'Page', + title: 'Own post', + status: 'changed', + ui: [ + '(Boost) in the header', + 'Strip on every post type · [Boost]', + '“Taking off: boost it”', + ], + notes: [ + { + tone: 'fix', + text: 'Strip gets its own Boost button and shows on article posts. One gate (useCanBoostPost, not Brief) for every entry.', + }, + { + tone: 'fix', + text: '"This post is taking off" prompt on posts with traction (240 posts got 50+ upvotes from authors who never boosted).', + }, + ], + }, + { + surface: 'Modal', + title: 'Boost your post', + via: 'Boost', + status: 'changed', + ui: [ + 'Starter: 1 day · 2,000 Cores', + 'or custom budget', + 'Reading your post to estimate reach…', + '[Get 1,980 Cores and boost]', + ], + notes: [ + { + tone: 'fix', + text: 'Test a starter preset next to the sliders. The experiment flag defaults to control (today’s 5k).', + }, + { + tone: 'backend', + text: 'A lower minimum is a boost_settings change, so it goes in the same experiment.', + }, + ], + }, + { + surface: 'Modal', + title: 'Buy Cores, pack preselected', + via: 'CTA', + status: 'changed', + ui: ['Covers your boost', '3,000 ✓ · 5,000 · 10,000', '[Buy 3,000 Cores]'], + notes: [ + { + tone: 'fix', + text: 'Pass need, preselect the smallest pack covering the boost, and say "boost" in the copy, not "award".', + }, + ], + }, + { + surface: 'Checkout', + title: 'Checkout', + via: 'Buy', + ui: ['[Pay]'], + notes: [ + { + tone: 'fix', + text: 'Log the Paddle error reason (since apps#7065) and fix postal-code validation, the only named error.', + }, + ], + }, + { + surface: 'Modal', + title: 'Boost launches', + via: 'Cores land', + status: 'new', + ui: ['Boosting for 1 day · 2,000 Cores', '(Change)'], + notes: [ + { + tone: 'fix', + text: 'Start the campaign the user already configured, with a short confirm row instead of a second press.', + }, + ], + }, + { + surface: 'Modal', + title: 'Boost is live!', + via: 'auto', + status: 'changed', + ui: ['# Boost is live!', 'Find it in Boosts', '[See your boost]'], + }, +]; + +const FirstBoost = (): ReactElement => ( + + + + +); + +const afterBoostToday: Step[] = [ + { + surface: 'Modal', + title: 'Ads dashboard', + ui: [ + 'Spend · Impressions · Users', + '“Launch your first campaign”', + 'Active ads (all of them)', + ], + where: 'AdsDashboardModal.tsx', + notes: [ + { + tone: 'pain', + text: 'A modal reached only from menus. No page or URL. The empty-state copy shows when you do have campaigns.', + }, + ], + }, + { + surface: 'Modal', + title: 'Campaign view', + via: 'pick a campaign', + ui: [ + 'Progress · Ends in 3 days', + 'Spend · Impressions · Users', + '(Stop campaign)', + ], + where: 'BoostedViewModal.tsx', + notes: [ + { + tone: 'pain', + text: 'No way to extend. Stopping a squad boost says "Post boost canceled!".', + }, + ], + }, + { + surface: 'Notification', + title: 'Boost is live', + via: 'at 70% spend', + ui: ['Your boost is live'], + notes: [ + { + tone: 'pain', + text: 'Fires when 70% of the budget is spent, not when the boost starts.', + }, + ], + }, + { + surface: 'Notification', + title: 'Boost ended', + via: 'campaign ends', + ui: ['Your boost ended', '/notifications?c_id='], + }, + { + surface: 'Modal', + title: 'Boost again', + via: 'click', + ui: ['Boost your post', 'Today’s defaults, not the last budget'], + where: 'useCampaignByIdModal.ts', + notes: [ + { + tone: 'pain', + text: 'The audit’s P0 crash is fixed (apps#7185), but the form opens on the defaults, not the last budget.', + }, + { + tone: 'data', + text: '118 of 159 boosters boosted once. 138 haven’t boosted in 60+ days.', + }, + { + tone: 'voice', + text: '"Deleted my post mid-boost and lost 5k Cores." Under-delivery Jan–Jun left 187k Cores unrefunded.', + }, + ], + }, +]; + +const afterBoostFixed: Step[] = [ + { + surface: 'Page', + title: 'Boosts page', + status: 'new', + ui: ['# Boosts', 'Live (1) · Ended (4)', 'Totals: spend · reach'], + notes: [ + { + tone: 'fix', + text: 'The dashboard becomes a page with a URL, linked from the Boosting button, boost rows in the wallet and the profile menu.', + }, + ], + }, + { + surface: 'Page', + title: 'Campaign', + via: 'pick a campaign', + status: 'changed', + ui: [ + 'Progress · Ends in 3 days', + 'Impressions · Users · Clicks on post', + '[Extend] (Stop and refund 1,200)', + ], + notes: [ + { + tone: 'fix', + text: 'Show the refund amount before stopping. Toast copy follows the campaign type.', + }, + { + tone: 'backend', + text: 'Extend needs a campaign mutation in daily-api.', + }, + ], + }, + { + surface: 'Notification', + title: 'Boost is live', + via: 'on start', + status: 'changed', + ui: ['Your boost is live', '(See your boost)'], + notes: [ + { + tone: 'backend', + text: 'Move the trigger from 70% spend to campaign start.', + }, + ], + }, + { + surface: 'Notification', + title: 'Boost ended, with results', + via: 'campaign ends', + status: 'changed', + ui: [ + '5.7k impressions · 3.5k devs', + 'Unused 300 Cores refunded', + '[Boost again]', + ], + notes: [ + { + tone: 'backend', + text: 'Refund unspent Cores on completion and on post deletion; show it as a wallet row.', + }, + ], + }, + { + surface: 'Modal', + title: 'Boost again, prefilled', + via: 'Boost again', + status: 'changed', + ui: ['Last budget: 1 day · 2,000', '[Boost post for 2,000 Cores]'], + notes: [ + { + tone: 'fix', + text: 'Prefill the last budget (onBoostAgain works since apps#7185).', + }, + { + tone: 'fix', + text: 'Log stop, extend, dashboard opens and strip dismissals so the funnel doesn’t go dark after launch.', + }, + ], + }, +]; + +const AfterBoost = (): ReactElement => ( + + + + +); + +const planColumns: { + title: string; + lead: string; + tone: 'pain' | 'fix' | 'data' | 'backend'; + items: string[]; +}[] = [ + { + title: 'Now · bugs', + lead: 'Small apps changes that stop users getting lost.', + tone: 'pain', + items: [ + 'Every /cores link passes next; streak flows use asPath.', + 'Gate SidebarProfileStats and the Cores shortcut by Cores access.', + 'Keep the picked award after buying Cores; fix the dead "+".', + 'Squad stop toast, inverted dashboard copy, analytics header gate.', + 'Answer the two unreplied payment tickets.', + ], + }, + { + title: 'Next · flow', + lead: 'The silky part: no task is interrupted by a purchase.', + tone: 'fix', + items: [ + 'Preselect the pack that covers the need, for awards and boosts.', + 'Launch the configured boost after the purchase lands.', + 'Clickable boost strip on every post type, one gate everywhere.', + 'Award notification links to the wallet; balance refreshes on arrival.', + 'Wallet totals reconcile; "Use your Cores" on the wallet.', + 'Boosts page with a URL, Extend and refund preview.', + ], + }, + { + title: 'Test · experiments', + lead: 'Flags default to control.', + tone: 'data', + items: [ + 'Starter boost: 1 day · 2,000 Cores.', + '"Your first Cores" sheet after the first quest claim.', + '"This post is taking off" boost prompt.', + 'Balance nudge on own posts when it covers a boost.', + ], + }, + { + title: 'Backend', + lead: 'daily-api and Njord work behind the flow.', + tone: 'backend', + items: [ + 'Refund unspent Cores on completion and post deletion.', + 'Extend campaign mutation.', + '"Is live" on start; "ended" with results.', + 'Award notification with award, net Cores, post and note.', + 'Alert on Processing 10+ min; let Paddle retry on handler errors.', + ], + }, +]; + +const Plan = (): ReactElement => ( + +
    + {planColumns.map((column) => ( +
    + {column.title} + {column.lead} + {column.items.map((item, index) => ( + + {item} + + ))} +
    + ))} +
    + +); + +const BoardTitle = ({ children }: { children: string }): ReactElement => ( +

    {children}

    +); + +export const BoardContent = (): ReactElement => ( +
    +
    + + + +
    +
    +
    + Cores and wallet + + + + +
    +
    + Boost + + +
    +
    +
    +); diff --git a/packages/storybook/stories/cores-boost/real/catalog.ts b/packages/storybook/stories/cores-boost/real/catalog.ts new file mode 100644 index 00000000000..81e822f3606 --- /dev/null +++ b/packages/storybook/stories/cores-boost/real/catalog.ts @@ -0,0 +1,191 @@ +import type { Product } from '@dailydotdev/shared/src/graphql/njord'; +import type { ProductPricingPreview } from '@dailydotdev/shared/src/graphql/paddle'; +import { PlusPriceType } from '@dailydotdev/shared/src/lib/featureValues'; + +// Copied from production (the `products` and `pricingPreview(type: cores)` +// queries, en-US, 2026-11-05). Refresh both when the catalog changes. + +const award = ( + id: string, + name: string, + image: string, + value: number, + imageGlow: string, +): Product => ({ id, type: 'award', name, image, value, flags: { imageGlow } }); + +const media = (path: string): string => + `https://media.daily.dev/image/upload/${path}`; + +export const awards: Product[] = [ + award( + 'a39d7368-22d9-4e01-b961-077179ff992d', + 'Smiley Face', + media('s--Ft4p9rjO--/f_auto/v1743664345/public/happy'), + 10, + media('s--4sp12i70--/f_auto/v1743664360/public/HappyGlow'), + ), + award( + 'ef2e2633-c9b9-401a-a372-0edce9fa6329', + 'Laughing Face', + media('s--THFmDVKR--/f_auto/v1743664345/public/Lol'), + 25, + media('s--erb0_uML--/f_auto/v1743664360/public/LolGlow'), + ), + award( + '8aeb45ce-2e96-4dbf-826d-433c98047642', + 'Heart', + media('s--IPWhKcis--/f_auto/v1743664345/public/Love'), + 50, + media('s--zt3wqvzq--/f_auto/v1743664360/public/LoveGlow'), + ), + award( + '352f1e07-06af-4d5e-a843-b3e324a899a0', + 'Coffee Mug', + media('s--aenMzSPZ--/f_auto/v1743664347/public/Coffee'), + 75, + media('s--wxgfqZWc--/f_auto/v1743664360/public/CoffeeGlow'), + ), + award( + '1b536ed1-3ab0-4780-a179-d0387d4c6d72', + 'Star', + media('s--PdFJ22B8--/f_auto/v1743664345/public/Star'), + 100, + media('s--QIKos1Qp--/f_auto/v1743664360/public/StarGlow'), + ), + award( + '17432ba4-bba6-42f8-81dd-f325409e92bf', + 'Hot Dog', + media('s--zOtftlFL--/f_auto/v1743664345/public/hotDog'), + 125, + media('s--kCGxTta---/f_auto/v1743664360/public/hotDogGlow'), + ), + award( + '1ab1bc67-78d8-4e01-be2b-c262acc27053', + 'Pizza', + media('s--n35mhpLB--/f_auto/v1743664345/public/Pizza'), + 150, + media('s--lCdAUh_q--/f_auto/v1743664360/public/PizzaGlow'), + ), + award( + 'c0d239b9-72de-4975-a1ac-97668068a310', + 'Stash of Cash', + media('s--7SPVvVPK--/f_auto/v1743664345/public/Cash'), + 250, + media('s--f6ygbzrB--/f_auto/v1743664360/public/CashGlow'), + ), + award( + '8c0257d7-f4a3-4116-8215-21d21149bbfd', + 'Balloon Brackets', + media('s--eJMxq8lw--/f_auto/v1747300201/public/Curly%20Brackets'), + 300, + media('s--7J3yLxuN--/f_auto/v1747314539/public/Color%3Dglow'), + ), + award( + '0951d2c8-f045-425d-aa1d-0c72a153e939', + 'Terminal', + media('s--KBzEqo01--/f_auto/v1743664345/public/Terminal'), + 400, + media('s--O2LYk0w3--/f_auto/v1743664360/public/TerminalGlow'), + ), + award( + '7bd171f2-781f-400b-af45-1fc3bc02e7c0', + 'Rubber Duck', + media('s--Q65Srvfv--/f_auto/v1743664345/public/Duck'), + 600, + media('s--084HrC8k--/f_auto/v1743664360/public/DuckGlow'), + ), + award( + '5efc4400-de1d-4648-b619-de67c3175b75', + 'Robotic Bug', + media('s--wJzQYBN0--/f_auto/v1747300201/public/Bug'), + 750, + media('s--ZUtjIO4H--/f_auto/v1747314539/public/Color%3Dglow-2'), + ), + award( + '57c82004-4d6e-4a78-9010-636f1e84e74e', + 'Gaming Controller', + media('s--IMOkH2qD--/f_auto/v1747300201/public/Controller'), + 900, + media('s--ZtCbvpNe--/f_auto/v1747314539/public/Color%3Dglow-6'), + ), + award( + 'e9dab194-6d2c-409d-b422-3e51d5ed673c', + 'Keyboard', + media('s--ezX7VAyg--/f_auto/v1743664345/public/Keyboard'), + 1200, + media('s--NJ3uuIQw--/f_auto/v1743664360/public/KeyboardGlow'), + ), + award( + '9d2131e8-d121-457a-ba9d-9281fcb9b711', + 'Shady Hacker', + media('s--UvWxsXT5--/f_auto/v1747300201/public/Hacker'), + 1500, + media('s--2L2VAPyF--/f_auto/v1747314539/public/Color%3Dglow-5'), + ), + award( + '64af9875-b48f-4370-8bc9-a4b02b3936b8', + 'Metal Dinosaur', + media('s--k6nu1NSe--/f_auto/v1747300201/public/Dinosaur'), + 1800, + media('s--jdhFHO5---/f_auto/v1747314539/public/Color%3Dglow-1'), + ), + award( + '99366312-6f97-4c99-b640-8cca7fe1638f', + 'Virtual Doom', + media('s--1oNsDDV5--/f_auto/v1747300201/public/Doom'), + 2200, + media('s--aeYEfeBa--/f_auto/v1747314539/public/Color%3Dglow-4'), + ), + award( + 'ad90f69b-d685-40a4-b611-057517ef91ca', + 'Funky Llama', + media('s--YD728PdI--/f_auto/v1747300201/public/Llm'), + 2700, + media('s--lqq1uAbj--/f_auto/v1747314539/public/Color%3Dglow-3'), + ), + award( + 'ae8d3e70-0358-440d-93ba-9a76fd1dc4fd', + '"This is fine" Dog', + media('s--eXw4JiHs--/f_auto/v1743664345/public/fineDog'), + 5000, + media('s--NZ-1Is9I--/f_auto/v1743664360/public/fineDogGlow'), + ), +]; + +const pack = ( + priceId: string, + coresValue: number, + amount: number, +): ProductPricingPreview => ({ + priceId, + metadata: { + appsId: '' as never, + title: `${coresValue.toLocaleString('en-US')} Cores`, + idMap: { paddle: priceId, ios: `cores_${coresValue}` }, + coresValue, + }, + price: { + amount, + formatted: amount.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + }), + }, + currency: { code: 'USD', symbol: '$' }, + duration: PlusPriceType.OneTime, + coresGiveback: null, +}); + +export const corePacks: ProductPricingPreview[] = [ + pack('pri_01jk3bq7kdb6ng2ceazwrv0r5x', 100, 1.1), + pack('pri_01jngmgcx8ja85j86aqyt0sjy1', 300, 3.3), + pack('pri_01jngmh2x357gkh4sw90d4wgrk', 600, 6.6), + pack('pri_01jngmk5kmt0kzvnzfxg4g6xs4', 1200, 13.2), + pack('pri_01jngmp3azj5ravkd0e60bk99e', 2400, 26.4), + pack('pri_01jngmpkb807bnn3xzmsx1psx3', 5000, 55), + pack('pri_01jngmq70abqc01gjvmyq94n05', 8000, 88), + pack('pri_01jngmr33nbypw09q5gmt46bnq', 12000, 132), + pack('pri_01jngmrre6r9n1pya0788b2pye', 25000, 275), + pack('pri_01k06qyepdm3v83bd765byq2xz', 50000, 550), + pack('pri_01k06r0djybd9b9yvyh8rsfj71', 100000, 1100), +]; diff --git a/packages/storybook/stories/cores-boost/real/handlers.ts b/packages/storybook/stories/cores-boost/real/handlers.ts new file mode 100644 index 00000000000..af9eeab12ed --- /dev/null +++ b/packages/storybook/stories/cores-boost/real/handlers.ts @@ -0,0 +1,268 @@ +import { delay, graphql, HttpResponse } from 'msw'; +import type { UserTransaction } from '@dailydotdev/shared/src/graphql/njord'; +import { + ProductType, + UserTransactionStatus, + UserTransactionType, +} from '@dailydotdev/shared/src/graphql/njord'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; +import { fallbackImages } from '@dailydotdev/shared/src/lib/config'; +import { awards, corePacks } from './catalog'; +import { viewer } from './providers'; +import { + boostNotifications, + homeFeedHandlers, + notificationsHandlers, + postPageHandlers, +} from './pages'; + +// The production GraphQL operations these screens run, answered by name. +// Row shapes follow production: awards pay the receiver 70% (`valueIncFees`), +// quest rewards come from the `system` user with a "Quest reward: …" note. + +const NETWORK_MS = 400; + +const system: UserShortProfile = { + id: 'system', + name: 'daily.dev', + username: 'system', + image: 'https://cdn.daily.dev/assets/maskable_icon.png', + permalink: 'https://app.daily.dev/system', +} as UserShortProfile; + +const me = viewer as unknown as UserShortProfile; + +const person = (id: string, name: string, username: string) => + ({ + id, + name, + username, + image: fallbackImages.avatar, + permalink: `https://app.daily.dev/${username}`, + }) as UserShortProfile; + +const sam = person('sb-sam', 'Sam Rivera', 'samrivera'); +const noa = person('sb-noa', 'Noa Levi', 'noalevi'); + +const awardNamed = (name: string) => + awards.find((product) => product.name === name); + +const daysAgo = (days: number): Date => + new Date(Date.now() - days * 24 * 60 * 60 * 1000); + +const row = ( + id: string, + days: number, + fields: Partial, +): UserTransaction => ({ + id, + status: UserTransactionStatus.Success, + receiver: me, + value: 0, + valueIncFees: 0, + flags: {}, + balance: { amount: 0 }, + createdAt: daysAgo(days), + ...fields, +}); + +const questReward = (id: string, days: number, quest: string, value: number) => + row(id, days, { + sender: system, + value, + valueIncFees: value, + referenceType: `quest_reward:${id}` as UserTransactionType, + flags: { note: `Quest reward: ${quest}` }, + }); + +/** A new user: Cores from quests only, never spent */ +export const questOnlyHistory: UserTransaction[] = [ + questReward('q1', 0, 'Adventure time', 5), + questReward('q2', 1, 'Friendly neighbor', 5), + questReward('q3', 2, 'Squad Collector', 15), + questReward('q4', 4, 'Chairman of the Guild', 5), +]; + +/** A creator who receives, sends and buys */ +export const activeHistory: UserTransaction[] = [ + row('a1', 0, { + sender: sam, + product: awardNamed('Heart'), + value: 50, + valueIncFees: 35, + referenceType: 'post' as UserTransactionType, + flags: { note: 'Great write-up, thanks!' }, + }), + questReward('a2', 0, 'Adventure time', 5), + row('a3', 1, { + sender: me, + receiver: noa, + product: awardNamed('Pizza'), + value: 150, + valueIncFees: 105, + referenceType: 'comment' as UserTransactionType, + flags: { note: 'Slice coming up!' }, + }), + row('a4', 2, { + sender: me, + receiver: system, + product: { + id: '6f1e1a2f-3f3a-4f2f-9c1e-8b1f6ef34a02', + type: ProductType.StreakFreeze, + name: 'Streak freeze 3-pack', + image: + 'https://media.daily.dev/image/upload/s--p6OHIH91--/f_auto,q_auto/v1784706019/webapp/streak-freeze-cube-square', + value: 400, + }, + value: 400, + valueIncFees: 280, + flags: { note: 'Streak freeze purchase' }, + }), + row('a5', 3, { value: 600, valueIncFees: 600 }), + row('a6', 5, { + sender: noa, + product: awardNamed('Smiley Face'), + value: 10, + valueIncFees: 7, + referenceType: UserTransactionType.User, + flags: { note: 'Thanks for the help in the squad' }, + }), + questReward('a7', 6, 'Squad Collector', 15), +]; + +const summaryOf = (history: UserTransaction[]) => + history.reduce( + (summary, transaction) => { + if (!transaction.sender && !transaction.product) { + return { ...summary, purchased: summary.purchased + transaction.value }; + } + if (transaction.sender?.id === me.id) { + return { ...summary, spent: summary.spent + transaction.value }; + } + return { + ...summary, + received: summary.received + transaction.valueIncFees, + }; + }, + { purchased: 0, received: 0, spent: 0 }, + ); + +/** Daily reach grows with the budget; production only estimates your own posts */ +const reachFor = (budget: number) => ({ + min: Math.round(budget * 0.18), + max: Math.round(budget * 0.26), +}); + +export interface CoresHandlersOptions { + /** The viewer's balance before anything is spent */ + balance?: number; + history?: UserTransaction[]; + /** Squad wallet offered in the boost modal, for paid squad seats */ + squadWallet?: { name: string; balance: number } | null; +} + +export const coresHandlers = ({ + balance = 0, + history = [], + squadWallet = null, +}: CoresHandlersOptions = {}) => [ + graphql.query('products', () => + HttpResponse.json({ + data: { + products: { + edges: awards.map((node) => ({ node })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }), + ), + graphql.query('PricingPreview', async () => { + await delay(NETWORK_MS); + return HttpResponse.json({ data: { pricingPreview: corePacks } }); + }), + graphql.query('TransactionSummary', async () => { + await delay(NETWORK_MS); + return HttpResponse.json({ + data: { transactionSummary: summaryOf(history) }, + }); + }), + graphql.query('transactions', async () => { + await delay(NETWORK_MS); + return HttpResponse.json({ + data: { + transactions: { + edges: history.map((node) => ({ node })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + }), + graphql.mutation('award', async ({ variables }) => { + await delay(NETWORK_MS); + const product = awards.find(({ id }) => id === variables.productId); + return HttpResponse.json({ + data: { + award: { + transactionId: 'sb-award', + balance: { amount: balance - (product?.value ?? 0) }, + }, + }, + }); + }), + graphql.query('DailyCampaignReachEstimate', async ({ variables }) => { + await delay(NETWORK_MS); + return HttpResponse.json({ + data: { dailyCampaignReachEstimate: reachFor(Number(variables.budget)) }, + }); + }), + graphql.mutation('StartCampaign', async ({ variables }) => { + await delay(NETWORK_MS); + const spend = Number(variables.budget) * Number(variables.duration); + return HttpResponse.json({ + data: { + startCampaign: { + transactionId: 'sb-boost', + referenceId: 'sb-campaign', + walletSourceId: null, + balance: { amount: balance - spend }, + }, + }, + }); + }), + graphql.query('SquadWallet', () => + HttpResponse.json({ + data: squadWallet + ? { + source: { + id: 'sb-squad', + name: squadWallet.name, + currentMember: { seat: true }, + }, + squadSubscription: { + plan: 'startups', + coresBalance: squadWallet.balance, + }, + } + : { + source: { id: 'sb-squad', name: '', currentMember: null }, + squadSubscription: { plan: null, coresBalance: 0 }, + }, + }), + ), + // daily.dev for Startups promos: none dismissed, the launch cashback on + graphql.query('CompletedUserActions', () => + HttpResponse.json({ data: { actions: [] } }), + ), + graphql.mutation('CompleteAction', () => + HttpResponse.json({ data: { completeAction: { _: true } } }), + ), + graphql.query('StartupsCashbackPromo', () => + HttpResponse.json({ + data: { startupsCashbackPromo: { percentage: 500, months: 12 } }, + }), + ), + // The real pages behind the screens: the home feed and the post page + ...homeFeedHandlers, + ...postPageHandlers, + ...notificationsHandlers(boostNotifications()), +]; diff --git a/packages/storybook/stories/cores-boost/real/pages.tsx b/packages/storybook/stories/cores-boost/real/pages.tsx new file mode 100644 index 00000000000..38ac191436c --- /dev/null +++ b/packages/storybook/stories/cores-boost/real/pages.tsx @@ -0,0 +1,453 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { graphql, http, HttpResponse } from 'msw'; +import { FeedLayoutProvider } from '@dailydotdev/shared/src/contexts/FeedContext'; +import { ActivePostContextProvider } from '@dailydotdev/shared/src/contexts/ActivePostContext'; +import { POST_CONTENT_MAP } from '@dailydotdev/shared/src/components/post/postContentMap'; +import ArticlePostModal from '@dailydotdev/shared/src/components/modals/ArticlePostModal'; +import { PostPosition } from '@dailydotdev/shared/src/hooks/usePostModalNavigation'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import type { Notification } from '@dailydotdev/shared/src/graphql/notifications'; +import type { UserQuest } from '@dailydotdev/shared/src/graphql/quests'; +import { QuestStatus } from '@dailydotdev/shared/src/graphql/quests'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { getPostByIdKey } from '@dailydotdev/shared/src/lib/query'; +import { cloudinaryPostImageCoverPlaceholder } from '@dailydotdev/shared/src/lib/image'; +import { useQueryClient } from '@tanstack/react-query'; +import MainFeedPage from '../../../../webapp/components/layouts/MainFeedPage'; +import DailyQuestsPageComponent from '../../../../webapp/pages/daily-quests'; +import NotificationsPageComponent from '../../../../webapp/pages/notifications'; +import { + DEFAULT_PICKED_TAGS, + getPreviewPosts, +} from '../../components/onboarding/feed-preview/previewData'; +import { heroAd } from '../../features/feed/feedHero.mocks'; +import postPages from '../../history/postPages.json'; +import { useRoute, viewer } from './providers'; + +// Real production pages the Cores and boost moments happen on, mounted inside +// CoresProviders' production MainLayout, with real posts behind them. + +const noop = (): void => undefined; + +/** This week's real posts, as the home feed's query returns them */ +export const feedPosts = getPreviewPosts(DEFAULT_PICKED_TAGS).map( + ({ post }) => post, +); + +const page = (edges: T[]) => ({ + pageInfo: { hasNextPage: false, endCursor: null }, + edges, +}); + +/** The home feed's queries and its ad slot, answered with real posts */ +export const homeFeedHandlers = [ + graphql.query('FeedV2', () => + HttpResponse.json({ + data: { + page: page( + feedPosts.map((post) => ({ + node: { __typename: 'FeedPostItem', feedMeta: null, post }, + })), + ), + }, + }), + ), + graphql.query(/^(Anonymous)?Feed$/, () => + HttpResponse.json({ + data: { page: page(feedPosts.map((node) => ({ node }))) }, + }), + ), + http.get(/\/v1\/a(\?.*)?$/, () => HttpResponse.json([heroAd])), +]; + +/** The webapp home page: MainFeedPage in FeedLayout, as pages/index.tsx */ +export const HomeFeedPage = (): ReactElement => { + useRoute('/'); + return ( + + + + ); +}; + +// A real article as POST_BY_ID_QUERY returned it from api.daily.dev, the base +// for the stories' own posts (same shape, different title and author). +const baseArticle = (postPages as unknown as Record).tbbNjYj52; + +const authorOf = (user: { + id: string; + name: string; + username: string; + image: string; + permalink: string; +}) => ({ + id: user.id, + name: user.name, + username: user.username, + image: user.image, + permalink: user.permalink, +}); + +export const realArticle = (overrides: Partial): Post => + ({ + ...baseArticle, + numAwards: 0, + numComments: 0, + numUpvotes: 0, + numReposts: 0, + featuredAward: null, + userState: undefined, + ...overrides, + }) as Post; + +/** Dana's own article: the post the award lands on and the nudge shows on */ +export const bunPost = realArticle({ + id: 'sb-bun-post', + title: 'Why we moved to Bun', + titleHtml: undefined, + image: cloudinaryPostImageCoverPlaceholder, + summary: + 'Our API went from Node 20 to Bun in six weeks. Cold starts dropped by two thirds, the test suite runs four times faster, and we deleted most of our build tooling. Here is what broke along the way and what we would do differently.', + tags: ['bun', 'nodejs', 'javascript', 'performance'], + createdAt: new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString(), + numUpvotes: 54, + numComments: 7, + author: authorOf(viewer as never), + permalink: 'https://app.daily.dev/posts/sb-bun-post', + commentsPermalink: 'https://app.daily.dev/posts/sb-bun-post', +} as Partial); + +/** One of the stories' own posts as a real article by the viewer */ +export const ownArticle = (post: Post, isNew: boolean): Post => + realArticle({ + id: post.id, + title: post.title, + titleHtml: undefined, + image: post.image ?? cloudinaryPostImageCoverPlaceholder, + summary: + 'Remote caching took our CI from 22 minutes to under 11 across the monorepo. How we set up the cache, what we stopped rebuilding, and the two places it fought us.', + tags: post.tags?.length ? post.tags : ['ci', 'devops', 'monorepo'], + createdAt: new Date( + Date.now() - (isNew ? 2 : 26 * 60) * 60 * 1000, + ).toISOString(), + numUpvotes: isNew ? 0 : 54, + numComments: isNew ? 0 : 6, + author: authorOf(viewer as never), + permalink: `https://app.daily.dev/posts/${post.id}`, + commentsPermalink: `https://app.daily.dev/posts/${post.id}`, + } as Partial); + +/** Sam Rivera's article, the post an award is given on */ +export const samPost = realArticle({ + numUpvotes: 85, + numComments: 12, + author: authorOf({ + id: 'sb-sam', + name: 'Sam Rivera', + username: 'samrivera', + image: viewer.image, + permalink: 'https://app.daily.dev/samrivera', + }), +} as Partial); + +/** What a post page asks for besides the post itself */ +export const postPageHandlers = [ + graphql.query('PostComments', () => + HttpResponse.json({ data: { postComments: page([]) } }), + ), + graphql.query(/^(RelatedPosts|FurtherReading|PostCodeSnippets)$/, () => + HttpResponse.json({ data: {} }), + ), +]; + +/** The post as its page query cached it, which the header's Boost waits on */ +const useSeedPost = (post: Post): void => { + const client = useQueryClient(); + useState(() => client.setQueryData(getPostByIdKey(post.id), { post })); +}; + +/** + * Puts `banner` into the post page where production renders the boost strip: + * after the post's header row, above its title. + */ +const BannerSlot = ({ + host, + banner, +}: { + host: HTMLElement | null; + banner?: ReactNode; +}): ReactElement | null => { + const [slot, setSlot] = useState(null); + + useEffect(() => { + if (!host || !banner) { + return undefined; + } + let element: HTMLElement | null = null; + const place = () => { + const title = host.querySelector('h1'); + if (!title || element?.isConnected) { + return; + } + element = document.createElement('div'); + element.className = 'mb-4'; + title.before(element); + setSlot(element); + }; + place(); + const observer = new MutationObserver(place); + observer.observe(host, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + element?.remove(); + }; + }, [host, banner]); + + return slot && banner ? createPortal(banner, slot) : null; +}; + +/** The webapp post page (pages/posts/[id]), with an optional boost strip */ +export const PostPage = ({ + post, + banner, + children, +}: { + post: Post; + /** Rendered where production puts the boost strip */ + banner?: ReactNode; + /** Modals or popups over the page */ + children?: ReactNode; +}): ReactElement => { + useSeedPost(post); + useRoute(`/posts/${post.id}`); + const Content = POST_CONTENT_MAP[post.type] ?? POST_CONTENT_MAP.article; + const host = useRef(null); + const [hostElement, setHostElement] = useState(null); + useEffect(() => setHostElement(host.current), []); + + return ( +
    + + + + + {children} +
    + ); +}; + +/** A post opened from the home feed: the real post modal over the feed */ +export const PostModalOverFeed = ({ + post, + children, +}: { + post: Post; + children?: ReactNode; +}): ReactElement => { + useSeedPost(post); + return ( + <> + + {post.type === PostType.Article && ( + + )} + {children} + + ); +}; + +/** The webapp daily quests page (pages/daily-quests) */ +export const DailyQuestsPage = (): ReactElement => ; + +/** The webapp notifications page (pages/notifications) */ +export const NotificationsPage = (): ReactElement => { + useRoute('/notifications'); + return ; +}; + +export const notificationsHandlers = (notifications: Notification[]) => [ + graphql.mutation('ReadNotifications', () => + HttpResponse.json({ data: { readNotifications: { _: true } } }), + ), + graphql.query('Notifications', () => + HttpResponse.json({ + data: { + notifications: page(notifications.map((node) => ({ node }))), + }, + }), + ), +]; + +/** Fired when a story's quest is claimed, from the sidebar or the page */ +export const QUEST_CLAIMED_EVENT = 'sb-quest-claimed'; + +/** + * The quest dashboard with one daily quest. Claiming it marks it claimed, as + * production's ClaimQuestReward answers. + */ +export const questHandlers = (quest: UserQuest, isClaimed = false) => { + let claimed = isClaimed; + const current = (): UserQuest => + claimed + ? { + ...quest, + status: QuestStatus.Claimed, + claimable: false, + claimedAt: new Date(), + } + : quest; + const buckets = () => ({ + level: { level: 4, totalXp: 1230, xpInLevel: 230, xpToNextLevel: 400 }, + daily: { regular: [current()], plus: [] }, + weekly: { regular: [], plus: [] }, + milestone: [], + intro: [], + }); + + return [ + graphql.query('QuestDashboard', () => + HttpResponse.json({ + data: { + questDashboard: { + ...buckets(), + currentStreak: 3, + longestStreak: 12, + }, + }, + }), + ), + graphql.mutation('ClaimQuestReward', () => { + claimed = true; + window.dispatchEvent(new Event(QUEST_CLAIMED_EVENT)); + return HttpResponse.json({ data: { claimQuestReward: buckets() } }); + }), + ]; +}; + +/** + * Draws `children` in place of the first element of the real page that + * `find` returns, hiding that element: a story's own version of one row or + * strip, inside the production page around it. + */ +export const SwapInPage = ({ + find, + children, +}: { + find: () => HTMLElement | null; + children: ReactNode; +}): ReactElement | null => { + const [slot, setSlot] = useState(null); + + useEffect(() => { + let element: HTMLElement | null = null; + let target: HTMLElement | null = null; + const place = () => { + if (element?.isConnected) { + return; + } + target = find(); + if (!target) { + return; + } + element = document.createElement('div'); + target.before(element); + target.style.display = 'none'; + setSlot(element); + }; + place(); + const observer = new MutationObserver(place); + observer.observe(document.body, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + element?.remove(); + if (target) { + target.style.display = ''; + } + }; + // `find` is a fixed lookup per mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return slot ? createPortal(children, slot) : null; +}; + +/** The production notification row whose text includes `text` */ +export const findNotificationRow = (text: string) => (): HTMLElement | null => + [...document.querySelectorAll('div.min-h-14.py-3')].find((row) => + row.textContent?.includes(text), + ) ?? null; + +const viewerAvatar = { + type: 'user', + image: viewer.image, + name: viewer.name, + targetUrl: viewer.permalink, + referenceId: viewer.id, +}; + +/** + * The boost notifications as production sends them today + * (generateCampaignPostNotification): DailyDev icon, your avatar, a title + * only, plus an older reply so the page isn't a single row. + */ +export const boostNotifications = (): Notification[] => + [ + { + id: 'sb-boost-completed', + type: 'campaign_post_completed', + icon: 'DailyDev', + title: '

    Your post boost has ended

    ', + avatars: [viewerAvatar], + attachments: [], + referenceId: 'sb-campaign-ended', + targetUrl: '/boosts', + createdAt: new Date(Date.now() - 60 * 60 * 1000), + }, + { + id: 'sb-boost-milestone', + type: 'campaign_post_first_milestone', + icon: 'DailyDev', + title: "

    Your post boost is 70% done. See how it's performing

    ", + avatars: [viewerAvatar], + attachments: [], + referenceId: 'sb-campaign-live', + targetUrl: '/boosts', + createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000), + }, + { + id: 'sb-boost-reply', + type: 'comment_reply', + icon: 'Comment', + title: '

    Noa Levi replied to your comment

    ', + description: 'Same here, the cache hit rate was the whole story for us.', + avatars: [{ ...viewerAvatar, name: 'Noa Levi', referenceId: 'sb-noa' }], + attachments: [], + referenceId: 'sb-reply', + targetUrl: '/', + createdAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + }, + ] as unknown as Notification[]; diff --git a/packages/storybook/stories/cores-boost/real/providers.tsx b/packages/storybook/stories/cores-boost/real/providers.tsx new file mode 100644 index 00000000000..1c3235e4f95 --- /dev/null +++ b/packages/storybook/stories/cores-boost/real/providers.tsx @@ -0,0 +1,208 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fn, mocked } from 'storybook/test'; +import mockedRouter, { useRouter } from 'next/router'; +import { BootDataProvider } from '@dailydotdev/shared/src/contexts/BootProvider'; +import { ActiveFeedContext } from '@dailydotdev/shared/src/contexts'; +import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import { BOOT_QUERY_KEY } from '@dailydotdev/shared/src/contexts/common'; +import MainLayout from '@dailydotdev/shared/src/components/MainLayout'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import { CoresRole } from '@dailydotdev/shared/src/lib/user'; +import { fallbackImages } from '@dailydotdev/shared/src/lib/config'; +import { defaultBootData, getBootMock } from '../../../mock/boot'; +import { + FeaturesReadyContext, + GrowthBookContext, + useFeaturesReadyContext, + useGrowthBookContext, +} from '../../../mock/GrowthBookProvider'; +import { ProductionFlags } from '../../features/gist-feed/ProductionFlags'; + +export const noop = (): void => undefined; + +// Production GrowthBook values for a non-team user (read from a production +// boot, 2026-11-05). The team rule lowers the boost minimum to 1,000, so +// anyone checking production from a team account sees a different slider. +export const productionFlags: Record = { + cores: true, + post_boost_ads: true, + cores_pricing_ids: 'cores_default', + // daily.dev for Startups promos in the boost form and Buy Cores (#7296) + startups_checkout: true, + boost_settings: { + min: 5000, + max: 100000, + step: 1000, + default_cores: 10000, + default_days: 5, + }, +}; + +export const viewer: LoggedUser = { + id: 'sb-viewer', + username: 'devdana', + name: 'Dev Dana', + image: fallbackImages.avatar, + providers: ['github'], + email: 'dana@example.com', + infoConfirmed: true, + premium: false, + createdAt: '2025-03-02T10:00:00.000Z', + permalink: 'https://app.daily.dev/devdana', + coresRole: CoresRole.Creator, + balance: { amount: 0 }, +}; + +/** MainLayout holds its chrome, and the home feed its cards, until + * GrowthBook reports ready and settled */ +const GrowthBookReady = ({ children }: { children: ReactNode }) => { + const { growthbook } = useGrowthBookContext(); + const featuresReady = useFeaturesReadyContext(); + + return ( + + + {children} + + + ); +}; + +const pageTitles: Record = { + '/': 'Home', + '/wallet': 'Core wallet', + '/boosts': 'Boosts', + '/cores': 'Get More Cores', + '/daily-quests': 'Daily quests', + '/notifications': 'Notifications', +}; + +const RouteContext = createContext<(path: string) => void>(() => undefined); + +/** Moves the production shell to the page a screen is on */ +export const useRoute = (path: string): void => { + const setPath = useContext(RouteContext); + useEffect(() => setPath(path), [path, setPath]); +}; + +export interface CoresProvidersProps { + children: ReactNode; + /** The viewer's Core balance */ + balance?: number; + coresRole?: CoresRole; + isPlus?: boolean; + /** The route the app is on, which the sidebar marks as active */ + activePage?: string; +} + +/** + * The production app around a screen, as the webapp mounts a page: a real + * boot (BootDataProvider, so auth, settings and the dark theme come from it), + * production flags, and MainLayout with its sidebar and header. + */ +export const CoresProviders = ({ + children, + balance = 0, + coresRole = CoresRole.Creator, + isPlus = false, + activePage = '/wallet', +}: CoresProvidersProps): ReactElement => { + // MainLayout paints once the router is ready, and reads the page from it. + // The sidebar's Recent list records the page under document.title, and + // skips post pages as production does. + // A walkthrough moves between pages: each screen names its route + // (useRoute), and the shell re-renders on it. + const [path, setPath] = useState(activePage); + const routed = useRef(); + if (routed.current !== path) { + routed.current = path; + const isPost = path.startsWith('/posts/'); + const route = isPost ? '/posts/[id]' : path; + document.title = `${pageTitles[path] ?? 'daily.dev'} | daily.dev`; + mocked(useRouter).mockReturnValue({ + ...mockedRouter, + isReady: true, + route, + pathname: route, + asPath: path, + query: {}, + } as never); + } + const [bootData] = useState(() => ({ + ...getBootMock(), + user: { + ...defaultBootData.user, + ...viewer, + coresRole, + isPlus, + balance: { amount: balance }, + }, + settings: { ...defaultBootData.settings, theme: 'darcula' }, + })); + const [queryClient] = useState(() => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + }, + }, + }); + client.setQueryData(BOOT_QUERY_KEY, bootData); + // Boot refetches through the global mock, which answers as the fixture + // user; pinning the query keeps this viewer and balance + client.getQueryCache().subscribe(({ query }) => { + const booted = query.state.data as { user?: { id: string } } | undefined; + if ( + query.queryHash === JSON.stringify(BOOT_QUERY_KEY) && + booted?.user?.id !== bootData.user.id + ) { + client.setQueryData(BOOT_QUERY_KEY, bootData); + } + }); + return client; + }); + // react-modal binds to #__next, so screens mount after it is in the DOM + const [isMounted, setIsMounted] = useState(false); + useEffect(() => setIsMounted(true), []); + + return ( + + + + + +
    + + + {isMounted && children} + + +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/packages/storybook/stories/cores-boost/real/screens.tsx b/packages/storybook/stories/cores-boost/real/screens.tsx new file mode 100644 index 00000000000..684411c32e2 --- /dev/null +++ b/packages/storybook/stories/cores-boost/real/screens.tsx @@ -0,0 +1,138 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useState } from 'react'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import GiveAwardModal from '@dailydotdev/shared/src/components/modals/award/GiveAwardModal'; +import { BuyCoresModal } from '@dailydotdev/shared/src/components/modals/award/BuyCoresModal'; +import { BoostPostModal } from '@dailydotdev/shared/src/features/boost/BoostPostModal'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { fallbackImages } from '@dailydotdev/shared/src/lib/config'; +import { cloudinaryPostImageCoverPlaceholder } from '@dailydotdev/shared/src/lib/image'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { awards } from './catalog'; + +// Production components, mounted as the app mounts them. Only the data and +// the providers around them are stand-ins (see providers.tsx, handlers.ts). + +/** Reopens a closed modal, so a reviewer can walk the flow again */ +const Reopen = ({ + label, + children, +}: { + label: string; + children: (close: () => void) => ReactNode; +}): ReactElement => { + // react-modal binds to #__next, so open once that element is in the DOM + const [isOpen, setIsOpen] = useState(false); + useEffect(() => setIsOpen(true), []); + + return ( +
    + {!isOpen && ( + + )} + {isOpen && children(() => setIsOpen(false))} +
    + ); +}; + +const receiver = { + id: 'sb-sam', + name: 'Sam Rivera', + username: 'samrivera', + image: fallbackImages.avatar, +}; + +export const GiveAwardScreen = ({ + numAwards = 0, +}: { + numAwards?: number; +}): ReactElement => ( + + {(close) => ( + + )} + +); + +export const BuyCoresScreen = ({ + origin = Origin.Award, + awardName, + minCoresToBuy, +}: { + origin?: Origin; + awardName?: string; + minCoresToBuy?: number; +}): ReactElement => ( + + {(close) => ( + award.name === awardName)} + minCoresToBuy={minCoresToBuy} + /> + )} + +); + +const createdAt = '2026-11-01T09:00:00.000Z'; + +export const boostablePost = { + id: 'sb-boost-post', + type: PostType.Article, + title: 'How we cut our CI time in half with remote caching', + image: cloudinaryPostImageCoverPlaceholder, + createdAt, + tags: ['ci', 'devops', 'monorepo'], + source: { + id: 'sb-user-source', + type: SourceType.User, + name: 'Dev Dana', + handle: 'devdana', + image: fallbackImages.avatar, + }, + flags: {}, +} as unknown as Post; + +export const squadPost = { + ...boostablePost, + id: 'sb-squad-post', + source: { + id: 'sb-squad', + type: SourceType.Squad, + name: 'Platform Guild', + handle: 'platformguild', + image: fallbackImages.avatar, + }, +} as unknown as Post; + +export const BoostPostScreen = ({ + post = boostablePost, +}: { + post?: Post; +}): ReactElement => ( + + {(close) => ( + + )} + +); diff --git a/packages/storybook/stories/cores-boost/redesign/AfterBoost.stories.tsx b/packages/storybook/stories/cores-boost/redesign/AfterBoost.stories.tsx new file mode 100644 index 00000000000..7b441ddf6ae --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/AfterBoost.stories.tsx @@ -0,0 +1,294 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import type { Campaign } from '@dailydotdev/shared/src/graphql/campaigns'; +import { CoresProviders } from '../real/providers'; +import { coresHandlers } from '../real/handlers'; +import { + BoostEndedNotification, + BoostLiveNotification, + NotificationsColumn, +} from '../flows/after-boost/BoostNotifications'; +import { + durationOf, + endedCampaign, + liveCampaign, +} from '../flows/after-boost/fixtures'; +import { BoostsPage, CampaignPage } from './after-boost/BoostsPage'; +import type { Screens } from './after-boost/BoostAgainModal'; +import { BoostAgainModal } from './after-boost/BoostAgainModal'; + +// The fixed "After the boost" flow (Flows/6) in the latest design line: the +// squads page frame, hero cover widget rows and the Patchy art. Same +// content, controls and click path as the fixed flow. + +const BALANCE = 5000; + +const meta: Meta = { + title: 'Cores & Boost/Redesign/6. After the boost', + parameters: { + layout: 'fullscreen', + msw: { handlers: coresHandlers({ balance: BALANCE }) }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const lastBoostOf = (campaign: Campaign) => { + const days = durationOf(campaign); + + return { + days, + coresPerDay: Math.round((campaign.flags.budget ?? 0) / days), + }; +}; + +/** Boost again from the ended notification, over the notifications page */ +const BoostAgainScreen = ({ + onSeeBoost, + initialScreen, +}: { + onSeeBoost?: () => void; + initialScreen?: Screens; +}): ReactElement => { + const [isOpen, setIsOpen] = useState(false); + useEffect(() => setIsOpen(true), []); + + return ( + <> + + setIsOpen(true)} + /> + + {isOpen && ( + setIsOpen(false)} + onSeeBoost={() => { + setIsOpen(false); + onSeeBoost?.(); + }} + /> + )} + + ); +}; + +enum Screen { + Boosts = 'Boosts page', + Campaign = 'Campaign', + Live = 'Boost is live', + Ended = 'Boost ended', + Again = 'Boost again', +} + +const screens = Object.values(Screen); + +const WalkthroughStory = (): ReactElement => { + const [screen, setScreen] = useState(Screen.Boosts); + const [campaign, setCampaign] = useState(liveCampaign); + const index = screens.indexOf(screen); + const openCampaign = (next: Campaign) => { + setCampaign(next); + setScreen(Screen.Campaign); + }; + const goTo = (next: Screen) => { + if (next === Screen.Campaign) { + setCampaign(liveCampaign); + } + setScreen(next); + }; + + return ( + <> + {screen === Screen.Boosts && } + {screen === Screen.Campaign && ( + setScreen(Screen.Boosts)} + onStop={() => goTo(Screen.Live)} + onExtend={() => goTo(Screen.Live)} + onBoostAgain={() => setScreen(Screen.Again)} + /> + )} + {screen === Screen.Live && ( + + openCampaign(liveCampaign)} + /> + + )} + {screen === Screen.Ended && ( + + openCampaign(endedCampaign)} + onBoostAgain={() => setScreen(Screen.Again)} + /> + + )} + {screen === Screen.Again && ( + setScreen(Screen.Boosts)} /> + )} + + + ); +}; + +export const Walkthrough: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'The redesigned screens in the fixed flow’s order: open the live campaign, Extend, See your boost, then Boost again from the ended notification. The bar at the bottom jumps to any step.', + }, + }, + }, +}; + +export const BoostsPageStory: Story = { + name: '1 · Boosts page', + render: () => , + parameters: { + docs: { + description: { + story: + 'Squads page frame: one bordered card with a Patchy title band, squad-style Live/Ended tabs and reading-list rows (cover right, spend and impressions under the title), with the totals and Docs/Terms as widgets beside it.', + }, + }, + }, +}; + +export const BoostsPageEmpty: Story = { + name: '1 · Boosts page, nothing live', + render: () => , + parameters: { + docs: { + description: { + story: + 'The empty tab uses CharmEmptyState with production’s copy and Learn more about boosting link in place of the gray Boost icon.', + }, + }, + }, +}; + +export const CampaignLive: Story = { + name: '2 · Campaign', + render: () => , + parameters: { + docs: { + description: { + story: + 'The campaign card leads with the post, groups budget, progress and dates in one tinted block, and shows the four stats as compact tiles above Extend and Stop and refund.', + }, + }, + }, +}; + +export const CampaignEnded: Story = { + name: '2 · Campaign, ended', + render: () => , + parameters: { + docs: { + description: { + story: + 'An ended campaign keeps the same layout, and Boost again becomes the primary button.', + }, + }, + }, +}; + +export const LiveNotification: Story = { + name: '3 · Boost is live', + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Same as the Flows page: the notification is unchanged.', + }, + }, + }, +}; + +export const EndedNotification: Story = { + name: '4 · Boost ended, with results', + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Same as the Flows page: the notification is unchanged.', + }, + }, + }, +}; + +export const BoostAgain: Story = { + name: '5 · Boost again, prefilled', + render: () => , + parameters: { + docs: { + description: { + story: 'Same form as the Flows page: prefilled with the last budget.', + }, + }, + }, +}; + +export const BoostIsLive: Story = { + name: '6 · Boost is live!', + render: () => , + parameters: { + docs: { + description: { + story: + 'The success screen trades the stock cover for the Patchy boost art, with See your boost primary and Learn more about boosting as a quiet second button.', + }, + }, + }, +}; diff --git a/packages/storybook/stories/cores-boost/redesign/Compare.stories.tsx b/packages/storybook/stories/cores-boost/redesign/Compare.stories.tsx new file mode 100644 index 00000000000..3936dc78d71 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/Compare.stories.tsx @@ -0,0 +1,231 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +// Today's design (the Flows pages: production plus the flow fixes) next to the +// redesign, screen by screen, as screenshots of the real stories: dark mode, +// 1440×900. Live frames took a minute to fill a page. After changing a +// screen, shoot it again; each image links to its live story. + +const screenshots = import.meta.glob('../assets/compare/*.jpg', { + eager: true, + import: 'default', +}); + +const screenshotOf = (id: string): string | undefined => + screenshots[`../assets/compare/${id.replace('cores-boost-', '')}.jpg`]; + +/** A walkthrough step (`walkthrough@step`) links to its walkthrough */ +const storyOf = (id: string): string => id.split('@')[0]; + +type Pair = { + name: string; + /** Flows story id, `walkthrough@step` for a walkthrough's step, or null + * when there is no screen to compare */ + today: string | null; + redesign: string; +}; + +const flows = (flow: string, story: string) => + `cores-boost-flows-${flow}--${story}`; +const redesign = (flow: string, story: string) => + `cores-boost-redesign-${flow}--${story}`; + +const pairsFor = ( + flow: string, + rows: [name: string, today: string | null, redesign: string][], +): Pair[] => + rows.map(([name, today, next]) => ({ + name, + today: + today && + (today.includes('--') + ? `cores-boost-flows-${today}` + : flows(flow, today)), + redesign: redesign(flow, next), + })); + +/** A story's screenshot, linking to the live story */ +const StoryShot = ({ id }: { id: string }): ReactElement => ( + + {id} + +); + +const Column = ({ + label, + id, +}: { + label: string; + id: string | null; +}): ReactElement => ( +
    + {label} + {id ? ( + + ) : ( +
    + Unchanged production screen, not part of the flow pages +
    + )} +
    +); + +const ComparePage = ({ pairs }: { pairs: Pair[] }): ReactElement => ( +
    + {pairs.map((pair) => ( +
    +

    {pair.name}

    +
    + + +
    +
    + ))} +
    +); + +const meta: Meta = { + title: 'Cores & Boost/Redesign/0. Today vs redesign', + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const FirstCores: Story = { + name: '1. First Cores', + render: () => ( + + ), +}; + +export const GiveAward: Story = { + name: '2. Give an award', + render: () => ( + + ), +}; + +export const ReceiveAward: Story = { + name: '3. Receive an award', + render: () => ( + + ), +}; + +export const WalletAndBuying: Story = { + name: '4. Wallet and buying', + render: () => ( + + ), +}; + +export const FirstBoost: Story = { + name: '5. First boost', + render: () => ( + + ), +}; + +export const AfterBoost: Story = { + name: '6. After the boost', + render: () => ( + + ), +}; diff --git a/packages/storybook/stories/cores-boost/redesign/FirstBoost.stories.tsx b/packages/storybook/stories/cores-boost/redesign/FirstBoost.stories.tsx new file mode 100644 index 00000000000..f571d0742e1 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/FirstBoost.stories.tsx @@ -0,0 +1,289 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoresProviders, noop } from '../real/providers'; +import { coresHandlers } from '../real/handlers'; +import { boostablePost } from '../real/screens'; +import { Stage } from '../flows/purchase/stage'; +import type { OwnPostState } from '../flows/purchase/BoostFixed'; +import { + BoostPostFixedModal, + starterBoost, +} from '../flows/purchase/BoostFixed'; +import { + BoostLaunchingRedesign, + BoostLiveRedesign, + OwnPostRedesign, +} from './purchase/BoostRedesign'; +import { + BuyCoresProcessingRedesign, + BuyCoresRedesignModal, +} from './purchase/BuyCoresRedesign'; + +// "5. First boost" from the Flows pages, redrawn in the squads hub line. +// Same steps, labels and actions; only the look changes. + +const balance = 20; + +/** Just published and untagged, so production is still reading it */ +const newPost = { + ...boostablePost, + id: 'sb-new-post', + createdAt: new Date().toISOString(), + tags: [], +} as Post; + +type Step = 'post' | 'form' | 'buy' | 'processing' | 'launching' | 'live'; + +const WalkthroughFlow = (): ReactElement => { + const { user, updateUser } = useAuthContext(); + const [step, setStep] = useState('post'); + const [budget, setBudget] = useState(starterBoost); + const total = budget.coresPerDay * budget.totalDays; + const toPost = () => setStep('post'); + const onLaunched = useCallback(() => { + if (user) { + updateUser({ + ...user, + balance: { amount: user.balance.amount - total }, + }); + } + setStep('live'); + }, [total, updateUser, user]); + + return ( + { + if (user) { + updateUser({ ...user, balance: { amount: balance } }); + } + setStep('form'); + }} + /> + } + > + {step === 'form' && ( + { + setBudget(next); + setStep('buy'); + }} + onBoost={(next) => { + setBudget(next); + setStep('launching'); + }} + onRequestClose={toPost} + /> + )} + {step === 'buy' && ( + { + setStep('processing'); + setTimeout(() => { + if (user) { + updateUser({ + ...user, + balance: { amount: user.balance.amount + pack.value }, + }); + } + setStep('launching'); + }, 1500); + }} + onRequestClose={() => setStep('form')} + /> + )} + {step === 'processing' && ( + + )} + {step === 'launching' && ( + setStep('form')} + onRequestClose={toPost} + /> + )} + {step === 'live' && ( + + )} + + ); +}; + +/** Your own post behind the boost modals, as the strip leaves it */ +const OwnPostBackdrop = (): ReactElement => ( + undefined} /> +); + +const meta: Meta = { + title: 'Cores & Boost/Redesign/5. First boost', + parameters: { + layout: 'fullscreen', + msw: { handlers: coresHandlers({ balance }) }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const story = ( + description: string, + render: () => ReactElement, + startBalance = balance, +): Story => ({ + parameters: { + msw: { handlers: coresHandlers({ balance: startBalance }) }, + docs: { description: { story: description } }, + }, + render: () => ( + + {render()} + + ), +}); + +const OwnPost = ({ state }: { state: OwnPostState }) => ( + + } + /> +); + +export const Walkthrough = story( + 'The redesigned flow with 20 Cores: Boost from the strip, take the starter preset, buy the pack that covers it, and the boost starts by itself.', + () => , +); + +export const OwnPostNew = story( + 'The strip becomes a rounded card with the Patchy rocket in its own tile on the left, so the copy never sits on the art.', + () => , +); + +export const OwnPostTakingOff = story( + 'The "taking off" strip uses the same card; on phones a trending tile replaces the art.', + () => , +); + +export const BoostForm = story('Same form as the Flows page.', () => ( + }> + + +)); + +export const BoostFormCustom = story( + 'Same form as the Flows page, on Custom.', + () => ( + }> + + + ), +); + +export const BoostFormReading = story( + 'Same form as the Flows page, while the post is being read.', + () => ( + }> + + + ), +); + +export const BuyCores = story( + 'The same redesigned Buy Cores as the award flow, with a boost tile leading the "what it is for" card.', + () => ( + }> + + + ), +); + +export const Processing = story( + 'A glowing Core heads the processing screen.', + () => ( + }> + + + ), +); + +export const BoostLaunches = story( + 'The Patchy rocket heads the launch screen, the boost is a row with Change, and a progress bar counts down.', + () => ( + }> + + + ), + 2420, +); + +export const BoostIsLive = story( + 'The rocket art replaces the success cover image, with a check badge on its edge.', + () => ( + }> + + + ), + 420, +); + +// The story index reads names statically, not through the helper +Walkthrough.storyName = 'Walkthrough'; +OwnPostNew.storyName = '1 · Own post'; +OwnPostTakingOff.storyName = '1 · Own post · taking off'; +BoostForm.storyName = '2 · Boost your post'; +BoostFormCustom.storyName = '2 · Boost your post · custom'; +BoostFormReading.storyName = '2 · Boost your post · reading the post'; +BuyCores.storyName = '3 · Buy Cores, pack preselected'; +Processing.storyName = '4 · Processing'; +BoostLaunches.storyName = '5 · Boost launches'; +BoostIsLive.storyName = '6 · Boost is live!'; diff --git a/packages/storybook/stories/cores-boost/redesign/FirstCores.stories.tsx b/packages/storybook/stories/cores-boost/redesign/FirstCores.stories.tsx new file mode 100644 index 00000000000..8104083c093 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/FirstCores.stories.tsx @@ -0,0 +1,224 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoresRole } from '@dailydotdev/shared/src/lib/user'; +import { CoresProviders } from '../real/providers'; +import { Shell } from '../flows/cores/Shell'; +import { FlowStepper } from '../flows/cores/FlowStepper'; +import type { FirstCoresPersona } from '../flows/cores/FirstCoresSheet'; +import { GatedQuestCard } from '../flows/cores/GatedEntryPoints'; +import { + firstQuestReward, + readQuest, + walletHandlers, +} from '../flows/cores/fixtures'; +import { findQuestCard } from '../flows/cores/screens'; +import { questHandlers, SwapInPage } from '../real/pages'; +import { FirstCoresSheet } from './cores/Moments'; +import { Wallet } from './cores/Wallet'; +import { OwnPostScreen, QuestScreen } from './cores/Screens'; + +// "First Cores", Flows/1 in the new look: the same screens and click path, +// on the squads hub frame with the character art. + +const FIRST_REWARD = 20; +const NUDGE_BALANCE = 5400; + +type Args = { persona: FirstCoresPersona; hasCoresAccess: boolean }; + +const meta: Meta = { + title: 'Cores & Boost/Redesign/1. First Cores', + parameters: { layout: 'fullscreen' }, + args: { persona: 'reader', hasCoresAccess: true }, + argTypes: { + persona: { + control: { type: 'inline-radio' }, + options: ['reader', 'poster'], + description: 'Posters see Boost first, readers see Award first', + }, + hasCoresAccess: { + name: 'Cores access', + control: { type: 'boolean' }, + description: 'Without it (coresRole None) Cores rewards are hidden', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const handlers = walletHandlers(FIRST_REWARD, [firstQuestReward]); + +const steps = [ + 'Quest reward', + 'Your first Cores', + 'Wallet', + 'Nudge on own post', +]; + +const WalkthroughFlow = ({ + persona, +}: { + persona: FirstCoresPersona; +}): ReactElement => { + const { user, updateUser } = useAuthContext(); + const [step, setStep] = useState(0); + + useEffect(() => { + const expected = [0, FIRST_REWARD, FIRST_REWARD, NUDGE_BALANCE][step]; + + if (user && step > 0 && user.balance.amount !== expected) { + updateUser({ ...user, balance: { amount: expected } }); + } + }, [step, user, updateUser]); + + return ( + + {step <= 1 && ( + setStep(1)} /> + )} + {step === 1 && ( + setStep(2)} + onLater={() => setStep(2)} + /> + )} + {step === 2 && ( + { + if (use === 'boost') { + setStep(3); + } + }} + /> + )} + {step === 3 && } + + + ); +}; + +export const Walkthrough: Story = { + name: 'Walkthrough', + parameters: { + msw: { handlers }, + docs: { + description: { + story: + 'Claim, Show me, then Boost your post: the same path as Flows/1, in the new look.', + }, + }, + }, + render: ({ persona }) => ( + + + + ), +}; + +export const QuestReward: Story = { + name: '1 · Quest reward', + parameters: { + msw: { handlers }, + docs: { + description: { + story: + 'The quest card sits in a rounded popover with a shadow, like the hub widgets; toggle Cores access to see the gated version.', + }, + }, + }, + render: ({ hasCoresAccess }) => ( + + + + {!hasCoresAccess && ( + + + + )} + + + ), +}; + +export const YourFirstCores: Story = { + name: '2 · Your first Cores', + parameters: { + msw: { handlers: [...questHandlers(readQuest, true), ...handlers] }, + docs: { + description: { + story: + 'The character art leads, the amount sits on it as a chip, and the uses are fixed-height rows with a coverage pill.', + }, + }, + }, + render: ({ persona }) => ( + + + + undefined} + onLater={() => undefined} + /> + + + ), +}; + +export const WalletUseYourCores: Story = { + name: '3 · Wallet with "Use your Cores"', + parameters: { + msw: { handlers }, + docs: { + description: { + story: + 'The wallet in the squads hub layout: balance hero with the art, totals in one strip, Use your Cores as tiles, buying and help as widgets.', + }, + }, + }, + render: () => ( + + + + + + ), +}; + +export const NudgeOnOwnPost: Story = { + name: '4 · Nudge on own post', + parameters: { + msw: { handlers }, + docs: { + description: { + story: + 'A quieter card: Patchy on the boost rocket at the left, the balance on two lines, a Small Boost button.', + }, + }, + }, + render: () => ( + + + + + + ), +}; diff --git a/packages/storybook/stories/cores-boost/redesign/GiveAward.stories.tsx b/packages/storybook/stories/cores-boost/redesign/GiveAward.stories.tsx new file mode 100644 index 00000000000..17644fc778f --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/GiveAward.stories.tsx @@ -0,0 +1,253 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { Product } from '@dailydotdev/shared/src/graphql/njord'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoresProviders, noop } from '../real/providers'; +import { coresHandlers } from '../real/handlers'; +import { awards } from '../real/catalog'; +import { Stage } from '../flows/purchase/stage'; +import { + AwardNoteRedesign, + AwardPickerRedesign, + AwardSentRedesign, +} from './purchase/AwardRedesign'; +import { + BuyCoresProcessingRedesign, + BuyCoresRedesignModal, +} from './purchase/BuyCoresRedesign'; + +// "2. Give an award" from the Flows pages, redrawn in the squads hub line. +// Same steps, labels and actions; only the look changes. + +const balance = 20; +const coffee = awards.find(({ name }) => name === 'Coffee Mug') as Product; + +type Step = + | 'pick' + | 'buy' + | 'processing' + | 'timeout' + | 'note' + | 'sent' + | 'done'; + +const WalkthroughFlow = (): ReactElement => { + const { user, updateUser } = useAuthContext(); + const [step, setStep] = useState('pick'); + const [product, setProduct] = useState(coffee); + const [bought, setBought] = useState(0); + const [hasTimedOut, setTimedOut] = useState(false); + const close = () => setStep('done'); + + const settle = useCallback( + (cores: number) => { + const delay = hasTimedOut ? 1200 : 2000; + setTimeout(() => { + // The first try shows the timeout state, Check again lands the Cores + if (!hasTimedOut) { + setTimedOut(true); + setStep('timeout'); + return; + } + if (user) { + updateUser({ ...user, balance: { amount: balance + cores } }); + } + setStep('note'); + }, delay); + }, + [hasTimedOut, updateUser, user], + ); + + return ( + { + if (user) { + updateUser({ ...user, balance: { amount: balance } }); + } + setTimedOut(false); + setStep('pick'); + }} + > + {step === 'pick' && ( + { + setProduct(picked); + setStep('buy'); + }} + onPicked={(picked) => { + setProduct(picked); + setStep('note'); + }} + onRequestClose={close} + /> + )} + {step === 'buy' && ( + { + setBought(pack.value); + setStep('processing'); + settle(pack.value); + }} + onRequestClose={() => setStep('pick')} + /> + )} + {(step === 'processing' || step === 'timeout') && ( + { + setStep('processing'); + settle(bought); + }} + onRequestClose={close} + /> + )} + {step === 'note' && ( + setStep('pick')} + onSent={() => setStep('sent')} + onRequestClose={close} + /> + )} + {step === 'sent' && ( + + )} + + ); +}; + +const meta: Meta = { + title: 'Cores & Boost/Redesign/2. Give an award', + parameters: { + layout: 'fullscreen', + msw: { handlers: coresHandlers({ balance }) }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const story = ( + description: string, + render: () => ReactElement, + startBalance = balance, +): Story => ({ + parameters: { + msw: { handlers: coresHandlers({ balance: startBalance }) }, + docs: { description: { story: description } }, + }, + render: () => ( + + {render()} + + ), +}); + +export const Walkthrough = story( + 'The redesigned flow with 20 Cores: pick Coffee Mug, buy the pack that covers it, wait out a slow payment, then send the award.', + () => , +); + +export const PickAnAward = story( + 'The receiver leads the sheet, awards are quiet bordered tiles with the shortfall in a corner chip, and a summary row carries the pick above the CTA.', + () => ( + + + + ), +); + +export const BuyCores = story( + 'A card names what the Cores are for and ticks once a pack covers it, packs are calm rows, and the friendship art replaces the two-hands video beside the checkout.', + () => ( + + + + ), +); + +export const Processing = story( + 'A glowing Core heads the screen, the saved award shows as a row, and the wallet link is a tertiary button.', + () => ( + + + + ), +); + +export const NoteAndSend = story( + 'A check chip confirms the Cores, the award sits in a bordered card with its glow image and price, and the note follows.', + () => ( + + + + ), + balance + 100, +); + +export const AwardSent = story( + 'The friendship art leads the success screen, with the award glowing over its edge and the Cores in a chip.', + () => ( + + + + ), + balance + 25, +); + +export const OldIos = story( + 'Same redesigned picker; on builds without in-app purchase the CTA links to the App Store.', + () => ( + + + + ), +); + +// The story index reads names statically, not through the helper +Walkthrough.storyName = 'Walkthrough'; +PickAnAward.storyName = '2 · Pick an award'; +BuyCores.storyName = '3 · Buy Cores, pack preselected'; +Processing.storyName = '5 · Processing'; +NoteAndSend.storyName = '6 · Note and send'; +AwardSent.storyName = '7 · Award sent'; +OldIos.storyName = '8 · Back where you were · old iOS'; diff --git a/packages/storybook/stories/cores-boost/redesign/ReceiveAward.stories.tsx b/packages/storybook/stories/cores-boost/redesign/ReceiveAward.stories.tsx new file mode 100644 index 00000000000..738636e66b3 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/ReceiveAward.stories.tsx @@ -0,0 +1,181 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoresProviders } from '../real/providers'; +import { Shell, useAnimateCores } from '../flows/cores/Shell'; +import { FlowStepper } from '../flows/cores/FlowStepper'; +import { + idoAward, + receiveHistory, + walletHandlers, +} from '../flows/cores/fixtures'; +import { Wallet } from './cores/Wallet'; +import { AwardNotificationPopup, NotificationsScreen } from './cores/Screens'; +import { bunPost, PostModalOverFeed } from '../real/pages'; + +// "Receive an award", Flows/3 in the new look. + +const BEFORE = 20; +const AFTER = BEFORE + idoAward.valueIncFees; +const HOME = '/'; + +const meta: Meta = { + title: 'Cores & Boost/Redesign/3. Receive an award', + parameters: { + layout: 'fullscreen', + msw: { handlers: walletHandlers(AFTER, receiveHistory) }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** The award lands while you browse: popup, and the balance counts up */ +const AwardArrives = ({ onOpen }: { onOpen?: () => void }): ReactElement => { + const { user, updateUser } = useAuthContext(); + const animateCores = useAnimateCores(); + const [isPopupOpen, setIsPopupOpen] = useState(false); + const [arrivals, setArrivals] = useState(0); + + const arrive = useCallback(() => { + setIsPopupOpen(true); + animateCores(idoAward.valueIncFees); + }, [animateCores]); + + useEffect(() => { + const timer = window.setTimeout(arrive, 800); + return () => window.clearTimeout(timer); + // Once per arrival; `arrive` changes as the balance does + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [arrivals]); + + return ( + <> + + {isPopupOpen && ( + setIsPopupOpen(false)} + /> + )} + + + ); +}; + +const steps = ['Award arrives', 'Notifications', 'Wallet']; + +const WalkthroughFlow = (): ReactElement => { + const { user, updateUser } = useAuthContext(); + const [step, setStep] = useState(0); + + useEffect(() => { + if (step > 0 && user && user.balance.amount !== AFTER) { + updateUser({ ...user, balance: { amount: AFTER } }); + } + }, [step, user, updateUser]); + + return ( + + {step === 0 && setStep(2)} />} + {step === 1 && setStep(2)} />} + {step === 2 && } + + + ); +}; + +export const Walkthrough: Story = { + name: 'Walkthrough', + parameters: { + docs: { + description: { + story: + 'The award arrives on your post: open the popup, or step to the bell list and use View in wallet. Same path as Flows/3.', + }, + }, + }, + render: () => ( + + + + ), +}; + +export const AwardReceivedWithContext: Story = { + name: '2 · Award received, with context', + parameters: { + docs: { + description: { + story: + 'The award rides on the sender’s avatar, the net Cores is a green pill, the note reads as a chat bubble, and View in wallet is a Float button next to Say thanks.', + }, + }, + }, + render: () => ( + + + + + + + ), +}; + +export const BalanceRefreshes: Story = { + name: '3 · Balance refreshes', + parameters: { + docs: { + description: { + story: + 'The popup itself shows the balance counting up to its new value, so the refresh is visible wherever the header has no Cores pill.', + }, + }, + }, + render: () => ( + + + + + + ), +}; + +export const WalletRowHighlighted: Story = { + name: '4 · Wallet, row highlighted', + parameters: { + docs: { + description: { + story: + 'The award row is marked with a purple edge on a raised background; the fee is a caption line and the note a chat bubble.', + }, + }, + }, + render: () => ( + + + + + + ), +}; diff --git a/packages/storybook/stories/cores-boost/redesign/WalletAndBuying.stories.tsx b/packages/storybook/stories/cores-boost/redesign/WalletAndBuying.stories.tsx new file mode 100644 index 00000000000..e00a18bbe51 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/WalletAndBuying.stories.tsx @@ -0,0 +1,215 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { RequestKey } from '@dailydotdev/shared/src/lib/query'; +import { useQueryClient } from '@tanstack/react-query'; +import { NotificationsScreen } from './cores/Screens'; +import { CoresProviders } from '../real/providers'; +import { activeHistory } from '../real/handlers'; +import { Shell } from '../flows/cores/Shell'; +import { FlowStepper } from '../flows/cores/FlowStepper'; +import { + afterPurchaseHistory, + idoAward, + landedPurchase, + stuckPurchase, + walletHandlers, + walletHistory, +} from '../flows/cores/fixtures'; +import { Wallet } from './cores/Wallet'; +import { CoresPage } from './cores/CoresPage'; +import { CoresPurchasedModal } from './cores/Moments'; + +// "Wallet and buying Cores", Flows/4 in the new look. + +const BALANCE = 1275; +const PACK = landedPurchase.value; + +const meta: Meta = { + title: 'Cores & Boost/Redesign/4. Wallet and buying', + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +const steps = ['Wallet', '/cores', 'Checkout', 'Back to the wallet']; + +const beforePurchase = [idoAward, ...activeHistory]; +let walkthroughHistory = beforePurchase; + +const WalkthroughFlow = (): ReactElement => { + const { user, updateUser } = useAuthContext(); + const queryClient = useQueryClient(); + const [step, setStep] = useState(0); + const isPaid = step === 3; + + useEffect(() => { + walkthroughHistory = isPaid + ? [landedPurchase, ...beforePurchase] + : beforePurchase; + queryClient.invalidateQueries({ queryKey: [RequestKey.Transactions] }); + }, [isPaid, queryClient]); + + useEffect(() => { + const expected = isPaid ? BALANCE + PACK : BALANCE; + + if (user && user.balance.amount !== expected) { + updateUser({ ...user, balance: { amount: expected } }); + } + }, [isPaid, user, updateUser]); + + return ( + + {step === 0 && setStep(1)} />} + {(step === 1 || step === 2) && ( + setStep(2)} /> + )} + {step === 2 && ( + setStep(3)} /> + )} + {step === 3 && } + + + ); +}; + +export const Walkthrough: Story = { + name: 'Walkthrough', + parameters: { + msw: { + handlers: walletHandlers(BALANCE, () => walkthroughHistory), + }, + docs: { + description: { + story: + 'Buy Cores, Pay for 300 Cores, Got it: back in the wallet with the new Cores showing. Same path as Flows/4.', + }, + }, + }, + render: () => ( + + + + ), +}; + +export const WaysIn: Story = { + name: '1 · Ways in', + parameters: { + msw: { handlers: walletHandlers(BALANCE, walletHistory) }, + docs: { + description: { + story: + 'The same gated wallet links; the award notification row is the new card with View in wallet as a Float button.', + }, + }, + }, + render: () => ( + + + + + + ), +}; + +type WalletArgs = { isPendingLong: boolean }; + +let walletStoryHistory = walletHistory; + +export const WalletScreen: StoryObj = { + name: '2 · Wallet', + args: { isPendingLong: false }, + argTypes: { + isPendingLong: { + name: 'Pending for 10+ minutes', + control: { type: 'boolean' }, + }, + }, + parameters: { + msw: { handlers: walletHandlers(BALANCE, () => walletStoryHistory) }, + docs: { + description: { + story: + 'Squads hub layout: one card (balance hero, totals strip, Use your Cores tiles, history rows with amount pills) and widgets for packs, Plus giveback, Docs and Terms. A pending purchase pulses in the bun color.', + }, + }, + }, + render: ({ isPendingLong }) => { + walletStoryHistory = isPendingLong + ? [stuckPurchase, ...walletHistory.slice(1)] + : walletHistory; + + return ( + + + + + + ); + }, +}; + +export const Cores: Story = { + name: '3 · /cores', + parameters: { + msw: { handlers: walletHandlers(BALANCE, walletHistory) }, + docs: { + description: { + story: + 'Packs as tiles in the hub card, the balance as a chip, and the character art in place of the hands video, holding the selected pack and the checkout.', + }, + }, + }, + render: () => ( + + + + + + ), +}; + +export const YouGotYourCores: Story = { + name: '4 · You got your Cores!', + parameters: { + msw: { handlers: walletHandlers(BALANCE, walletHistory) }, + docs: { + description: { + story: + 'The success modal leads with the character art and the amount as a chip; same copy and Got it.', + }, + }, + }, + render: () => ( + + + + undefined} /> + + + ), +}; + +export const BackToTheWallet: Story = { + name: '5 · Back to the wallet', + parameters: { + msw: { handlers: walletHandlers(BALANCE + PACK, afterPurchaseHistory) }, + docs: { + description: { + story: + 'Back in the wallet, the Purchased cell shows +300 in green and the purchase tops the history.', + }, + }, + }, + render: () => ( + + + + + + ), +}; diff --git a/packages/storybook/stories/cores-boost/redesign/after-boost/BoostAgainModal.tsx b/packages/storybook/stories/cores-boost/redesign/after-boost/BoostAgainModal.tsx new file mode 100644 index 00000000000..4551890d3bf --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/after-boost/BoostAgainModal.tsx @@ -0,0 +1,437 @@ +import type { ReactElement } from 'react'; +import dynamic from 'next/dynamic'; +import React, { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import type { ModalProps } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CoreIcon, PlusIcon } from '@dailydotdev/shared/src/components/icons'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; +import { BuyCoresModal } from '@dailydotdev/shared/src/components/modals/award/BuyCoresModal'; +import { usePostImage } from '@dailydotdev/shared/src/hooks/post/usePostImage'; +import { useCampaignMutation } from '@dailydotdev/shared/src/features/boost/useCampaignMutation'; +import { useBoostWallet } from '@dailydotdev/shared/src/features/boost/useBoostWallet'; +import { BoostWalletPicker } from '@dailydotdev/shared/src/features/boost/BoostWalletPicker'; +import { + boostDocsLink, + walletUrl, +} from '@dailydotdev/shared/src/lib/constants'; +import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { + CampaignType, + CampaignWallet, +} from '@dailydotdev/shared/src/graphql/campaigns'; +import { usePostBoostEstimation } from '@dailydotdev/shared/src/features/boost/usePostBoostEstimation'; +import { updatePostCache } from '@dailydotdev/shared/src/lib/query'; +import { useFeaturesReadyContext } from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { boostSettingsFeature } from '@dailydotdev/shared/src/lib/featureManagement'; +import { + getBoostErrorExtra, + useBoostLogging, +} from '@dailydotdev/shared/src/features/boost/useBoostLogging'; +import { BeforeIcon } from '@dailydotdev/shared/src/components/icons/Before'; +import stripArt from '../../assets/boost-strip-patchy-v2.png'; + +// The fixed flow's Boost again modal (flows/after-boost) as is, with the +// redesign's success screen carrying the Patchy art. + +const Slider = dynamic( + () => + import('@dailydotdev/shared/src/components/fields/Slider').then( + (mod) => mod.Slider, + ), + { ssr: false }, +); + +export interface LastBoost { + coresPerDay: number; + days: number; +} + +interface BoostPostModalProps extends ModalProps { + post: Post; + origin?: Origin; + lastBoost: LastBoost; + /** The success screen links to the boost's page on /boosts */ + onSeeBoost?: () => void; + /** Opens on a later screen, so a story can show the success */ + initialScreen?: Screens; +} + +const SCREENS = { + FORM: 'FORM', + BUY_CORES: 'BUY_CORES', + SUCCESS: 'SUCCESS', +} as const; + +export type Screens = keyof typeof SCREENS; + +export function BoostAgainModal({ + post, + origin = Origin.BoostAgain, + lastBoost, + onSeeBoost, + initialScreen = SCREENS.FORM, + ...props +}: BoostPostModalProps): ReactElement { + const { user } = useAuthContext(); + const client = useQueryClient(); + const { getFeatureValue } = useFeaturesReadyContext(); + const [activeScreen, setActiveScreen] = useState(initialScreen); + const boostSettings = getFeatureValue(boostSettingsFeature); + const [coresPerDay, setCoresPerDay] = React.useState(lastBoost.coresPerDay); + const [totalDays, setTotalDays] = React.useState(lastBoost.days); + const minCoresPerDay = Math.min(boostSettings.min, lastBoost.coresPerDay); + const [estimate, setEstimate] = useState({ coresPerDay, totalDays }); + const [updateEstimate] = useDebounceFn(setEstimate, 400); + const totalSpendInt = coresPerDay * totalDays; + const totalSpend = largeNumberFormat(totalSpendInt); + const { squadWallet, wallet, setWallet, balance } = useBoostWallet( + post.source?.type === SourceType.Squad ? post.source.id : undefined, + ); + const { estimatedReach, canBoost, isLoading } = usePostBoostEstimation({ + post, + query: { budget: estimate.coresPerDay }, + }); + const { logBoostEvent, logBuyCores } = useBoostLogging({ + type: CampaignType.Post, + referenceId: post.id, + origin, + }); + const { onStartBoost: onBoostPost } = useCampaignMutation({ + onBoostSuccess: (data, vars) => { + logBoostEvent(LogEvent.CompleteBoost, vars, { + campaign_id: data.referenceId, + }); + + if (data.referenceId) { + updatePostCache(client, vars.value, (old) => ({ + flags: { ...old.flags, campaignId: data.referenceId }, + })); + } + + setActiveScreen(SCREENS.SUCCESS); + }, + onBoostError: (error, vars) => + logBoostEvent(LogEvent.ErrorBoost, vars, getBoostErrorExtra(error)), + }); + const image = usePostImage(post); + + const onBuyCores = () => { + logBuyCores(); + setActiveScreen(SCREENS.BUY_CORES); + }; + + const onButtonClick = () => { + const hasEnoughBalance = balance >= totalSpendInt; + + logBoostEvent( + LogEvent.StartBoost, + { budget: coresPerDay, duration: totalDays }, + { has_enough_balance: hasEnoughBalance }, + ); + + if (!hasEnoughBalance) { + // Cores are bought for the user, not for the squad wallet + return wallet === CampaignWallet.User ? onBuyCores() : undefined; + } + + return onBoostPost({ + duration: totalDays, + budget: coresPerDay, + value: post.id, + type: CampaignType.Post, + wallet, + }); + }; + + if (activeScreen === SCREENS.BUY_CORES) { + const missingCores = Math.max( + 0, + totalSpendInt - (user?.balance.amount ?? 0), + ); + + return ( + setActiveScreen(SCREENS.FORM)} + onRequestClose={() => setActiveScreen(SCREENS.FORM)} + amountNeededCopy={ + missingCores > 0 ? ( + + You need{' '} + {missingCores.toLocaleString()} more Cores to + boost the post. + + ) : undefined + } + origin={Origin.BoostPost} + /> + ); + } + + if (activeScreen === SCREENS.SUCCESS) { + return ( + + +
    + +
    + + Boost is live! + + + Your post is now live and reaching more developers! + +
    + + +
    +
    +
    + ); + } + + // just to avoid any edge case where the min, for some reason is greater than max + const maxReach = Math.max(estimatedReach.min, estimatedReach.max); + + const potentialReach = (() => { + if (isLoading || !canBoost) { + return ; + } + + const min = largeNumberFormat(estimatedReach.min); + const max = largeNumberFormat(maxReach); + + return `${min} - ${max}`; + })(); + + const onRequestClose: ModalProps['onRequestClose'] = (event) => { + logBoostEvent(LogEvent.CloseBoost, { + budget: coresPerDay, + duration: totalDays, + }); + props.onRequestClose?.(event); + }; + + return ( + + + + Boost your post + +
    + +
    +
    + + + + Give your content the spotlight it deserves. Our auto-targeting engine + ensures your post gets shown to the developers most likely to care. + + Learn more + + +
    +
    + + {post.title ?? post.sharedPost?.title} + + {image && } +
    +
    + + {totalSpend} Cores over {totalDays}{' '} + {totalDays === 1 ? 'day' : 'days'} + + + Total spend + + + {potentialReach} + + + Estimated daily reach + +
    +
    +
    + + + Last boost:{' '} + {(lastBoost.coresPerDay * lastBoost.days).toLocaleString('en-US')}{' '} + Cores over {lastBoost.days} {lastBoost.days === 1 ? 'day' : 'days'} + +
    +
    + + Budget + + + + + {largeNumberFormat(coresPerDay || 0)} Cores per day + + + { + updateEstimate((state) => ({ ...state, coresPerDay: value })); + setCoresPerDay(value); + }} + /> + + Duration + + + {totalDays} {totalDays === 1 ? 'day' : 'days'} + + setTotalDays(value)} + /> +
    + {squadWallet && ( + + )} +
    + + + + + ); +} + +export default BoostAgainModal; diff --git a/packages/storybook/stories/cores-boost/redesign/after-boost/BoostsPage.tsx b/packages/storybook/stories/cores-boost/redesign/after-boost/BoostsPage.tsx new file mode 100644 index 00000000000..3f550b22137 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/after-boost/BoostsPage.tsx @@ -0,0 +1,359 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import { SquadWidget } from '@dailydotdev/shared/src/features/squads/components/widgets/SquadWidget'; +import { + SquadDirectoryNavbar, + SquadDirectoryNavbarItem, +} from '@dailydotdev/shared/src/components/squads/layout/SquadDirectoryNavbar'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + ArrowIcon, + CoreIcon, + OpenLinkIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { BeforeIcon } from '@dailydotdev/shared/src/components/icons/Before'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; +import { DateFormat } from '@dailydotdev/shared/src/components/utilities/DateFormat'; +import { TimeFormatType } from '@dailydotdev/shared/src/lib/dateFormat'; +import { + CharmEmptyState, + CharmEmptyStatePlacement, +} from '@dailydotdev/shared/src/components/charm/CharmEmptyState'; +import { cloudinaryCharmNoPosts } from '@dailydotdev/shared/src/lib/image'; +import { boostDocsLink } from '@dailydotdev/shared/src/lib/constants'; +import { getPostPath } from '@dailydotdev/shared/src/lib/links'; +import { BoostStatus } from '@dailydotdev/shared/src/features/boost/CampaignListItem'; +import { getAbsoluteDifferenceInDays } from '@dailydotdev/shared/src/features/boost/utils'; +import { + formatDataTileValue, + largeNumberFormat, +} from '@dailydotdev/shared/src/lib/numberFormat'; +import type { Campaign } from '@dailydotdev/shared/src/graphql/campaigns'; +import { CampaignState } from '@dailydotdev/shared/src/graphql/campaigns'; +import { + allCampaigns, + campaignTotals, + unspentOf, +} from '../../flows/after-boost/fixtures'; +import stripArt from '../../assets/boost-strip-patchy-v2.png'; +import { HelpWidget, SquadFrame, StatsGrid } from './kit'; + +// /boosts and /boosts/[id] in the squads page frame: the list and the +// campaign are the bordered card, the totals and help are widgets beside it. + +const remainingDaysOf = (campaign: Campaign): number | undefined => + campaign.state === CampaignState.Active + ? getAbsoluteDifferenceInDays(new Date(campaign.endedAt), new Date()) + : undefined; + +/** A row in the hero cover "Reading list" widget's shape */ +const CampaignRow = ({ + campaign, + onClick, +}: { + campaign: Campaign; + onClick?: () => void; +}): ReactElement => { + const { post, flags } = campaign; + + return ( +
  • + +
  • + ); +}; + +/** The card's title band, with the Patchy boost art on the far side */ +const TitleBand = ({ children }: { children: React.ReactNode }) => ( +
    + +
    + {children} +
    +
    +); + +const ResultsWidget = () => ( + + + +); + +enum BoostsTab { + Live = 'Live', + Ended = 'Ended', +} + +interface BoostsPageProps { + campaigns?: Campaign[]; + onOpenCampaign?: (campaign: Campaign) => void; +} + +export function BoostsPage({ + campaigns = allCampaigns, + onOpenCampaign, +}: BoostsPageProps): ReactElement { + const live = campaigns.filter(({ state }) => state === CampaignState.Active); + const ended = campaigns.filter(({ state }) => state !== CampaignState.Active); + const [tab, setTab] = useState( + live.length ? BoostsTab.Live : BoostsTab.Ended, + ); + const tabs = [ + { name: BoostsTab.Live, list: live }, + { name: BoostsTab.Ended, list: ended }, + ]; + const active = tabs.find(({ name }) => name === tab); + + return ( + +
    + +
    + + + } + > + +

    Boosts

    +
    +
    + +
    +
    + + {tabs.map(({ name, list }) => ( + setTab(name)} + /> + ))} + +
    + {active.list.length ? ( +
      + {active.list.map((campaign) => ( + onOpenCampaign?.(campaign)} + /> + ))} +
    + ) : ( + + )} +
    + ); +} + +interface CampaignPageProps { + campaign: Campaign; + onBack?: () => void; + onStop?: () => void; + onExtend?: () => void; + onBoostAgain?: () => void; +} + +export function CampaignPage({ + campaign, + onBack, + onStop, + onExtend, + onBoostAgain, +}: CampaignPageProps): ReactElement { + const { post, flags } = campaign; + const isActive = campaign.state === CampaignState.Active; + const date = useMemo(() => { + const startedAt = new Date(campaign.createdAt); + const endedAt = new Date(campaign.endedAt); + const totalDays = getAbsoluteDifferenceInDays(endedAt, startedAt); + + return { + endsIn: isActive + ? getAbsoluteDifferenceInDays(endedAt, new Date()) + : totalDays, + startedIn: getAbsoluteDifferenceInDays(new Date(), startedAt), + totalDays, + }; + }, [campaign, isActive]); + const percentage = isActive ? (date.startedIn / date.totalDays) * 100 : 100; + const endsIn = (() => { + if (date.endsIn === 0) { + return 'Ends in less than a day'; + } + + if (date.endsIn === 1) { + return 'Ends tomorrow'; + } + + return `Ends in ${date.endsIn} days`; + })(); + + return ( + }> + +
    +
    +
    + Summary + + + {formatDataTileValue(flags.budget ?? 0)} + + · {date.totalDays} {date.totalDays === 1 ? 'day' : 'days'} + + +
    + + + + Started{' '} + + + {isActive && {endsIn}} + +
    + + {isActive ? ( +
    + + +
    + ) : ( + + )} +
    + + ); +} diff --git a/packages/storybook/stories/cores-boost/redesign/after-boost/kit.tsx b/packages/storybook/stories/cores-boost/redesign/after-boost/kit.tsx new file mode 100644 index 00000000000..5c55db8a93f --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/after-boost/kit.tsx @@ -0,0 +1,143 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { SquadWidget } from '@dailydotdev/shared/src/features/squads/components/widgets/SquadWidget'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { + ArrowIcon, + CoreIcon, + DocsIcon, + FeedbackIcon, + InfoIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { formatDataTileValue } from '@dailydotdev/shared/src/lib/numberFormat'; +import { boostDashboardInfo } from '@dailydotdev/shared/src/features/boost/common'; +import { + boostDocsLink, + creatorsTermsOfService, +} from '@dailydotdev/shared/src/lib/constants'; +import { clicksInfo } from '../../flows/after-boost/CampaignView'; + +// The squads page frame (`SquadPageLayout`, `SquadDiscoverFrame`): the page is +// one bordered card from laptop, with a w-80 column of `SquadWidget`s beside +// it. Below laptop the card loses its border and the widgets follow it. + +export const SquadFrame = ({ + children, + aside, +}: { + children: ReactNode; + aside: ReactNode; +}): ReactElement => ( +
    +
    + {children} +
    + +
    +); + +interface StatProps { + label: string; + value: number; + info: string; + icon?: ReactNode; +} + +/** `DataTile`'s label, info tooltip and value, in the widget's compact size */ +export const StatTile = ({ + label, + value, + info, + icon, +}: StatProps): ReactElement => ( +
    + + {label} + + + + + + + + {icon} + {formatDataTileValue(value)} + +
    +); + +export interface Totals { + spend: number; + impressions: number; + users: number; + clicks: number; +} + +export const StatsGrid = ({ + totals, + className, +}: { + totals: Totals; + className?: string; +}): ReactElement => ( +
    + } + /> + + + +
    +); + +const HelpRow = ({ + href, + icon, + label, +}: { + href: string; + icon: ReactNode; + label: string; +}): ReactElement => ( + + + {icon} + + {label} + + +); + +/** Docs and Terms, the links production keeps in the page's side widget */ +export const HelpWidget = (): ReactElement => ( + +
    + } label="Docs" /> + } + label="Terms" + /> +
    +
    +); diff --git a/packages/storybook/stories/cores-boost/redesign/cores/CoresPage.tsx b/packages/storybook/stories/cores-boost/redesign/cores/CoresPage.tsx new file mode 100644 index 00000000000..3189f8164fb --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/cores/CoresPage.tsx @@ -0,0 +1,133 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { CoreFAQ } from '@dailydotdev/shared/src/components/cores/CoreFAQ'; +import { CoreOptions } from '@dailydotdev/shared/src/components/modals/award/BuyCoresModal'; +import { + Typography, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { BuyCoresContextProvider } from '@dailydotdev/shared/src/contexts/BuyCoresContext/BuyCoresContext'; +import { useBuyCoresContext } from '@dailydotdev/shared/src/contexts/BuyCoresContext/types'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { coresPricesQueryOptions } from '@dailydotdev/shared/src/graphql/njord'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { useRoute } from '../../real/providers'; +import { friendshipArt, SquadFrame } from './frame'; + +// /cores in the wallet's squads hub frame: production's page as is ("Get More +// Cores", the balance and the pack list from CoreOptions, then the FAQ) in the +// main card, and the character art in place of the hands video in the side +// column, which also holds the checkout once a pack is picked. + +const usePrices = () => { + const { user, isLoggedIn } = useAuthContext(); + return useQuery(coresPricesQueryOptions({ user, isLoggedIn })).data; +}; + +/** Opens the page on a pack, as a pid from the wallet does */ +const InitialPack = ({ cores }: { cores: number }): null => { + const prices = usePrices(); + const { setSelectedProduct } = useBuyCoresContext(); + + useEffect(() => { + const price = prices?.find(({ metadata }) => metadata.coresValue === cores); + if (price) { + setSelectedProduct({ id: price.priceId, value: cores }); + } + }, [prices, cores, setSelectedProduct]); + + return null; +}; + +const Checkout = ({ + onPay, +}: { + onPay?: (cores: number) => void; +}): ReactElement => { + const prices = usePrices(); + const { selectedProduct } = useBuyCoresContext(); + const selectedPrice = prices?.find( + ({ priceId }) => priceId === selectedProduct?.id, + ); + + return ( +
    + Friends sharing a Core with Patchy + {selectedProduct && selectedPrice && ( +
    + + + + + {formatCoresCurrency(selectedProduct.value)} Cores + + + {selectedPrice.price.formatted} + + + + + + Storybook: Paddle's checkout opens here in production + +
    + )} +
    + ); +}; + +export const CoresPage = ({ + initialPack, + onPay, +}: { + /** Cores value of the pack the page opens with (a pid from the wallet) */ + initialPack?: number; + /** Storybook stand-in for the Paddle checkout */ + onPay?: (cores: number) => void; +}): ReactElement => { + useRoute('/cores'); + + return ( + + {!!initialPack && } +
    + }> + + Get More Cores + + } + showCoresAtCheckout + /> + + +
    +
    + ); +}; diff --git a/packages/storybook/stories/cores-boost/redesign/cores/Moments.tsx b/packages/storybook/stories/cores-boost/redesign/cores/Moments.tsx new file mode 100644 index 00000000000..7235b3e8b9f --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/cores/Moments.tsx @@ -0,0 +1,167 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { ModalClose } from '@dailydotdev/shared/src/components/modals/common/ModalClose'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import type { FirstCoresPersona } from '../../flows/cores/FirstCoresSheet'; +import type { CoresUse } from './UseYourCores'; +import { CoresUseRow } from './UseYourCores'; +import { friendshipArt } from './frame'; + +// The two "you have Cores now" moments, on one shared card: the character art +// on top with the amount sitting on it, then the copy and the actions. +// Same copy and buttons as Flows: Show me / Later, and Got it. + +const ArtHeader = ({ + amount, + onClose, +}: { + amount: string; + onClose?: () => void; +}): ReactElement => ( +
    + Friends sharing a Core with Patchy +
    + {onClose && ( + + )} + + + {amount} + +
    +); + +const MomentModal = ({ + amount, + title, + body, + children, + onRequestClose, +}: { + amount: string; + title: string; + body: string; + children: ReactNode; + onRequestClose: () => void; +}): ReactElement => ( + + +
    +
    +

    {title}

    +

    {body}

    +
    + {children} +
    +
    +); + +const orderFor: Record = { + poster: ['boost', 'award', 'streak'], + reader: ['award', 'boost', 'streak'], +}; + +export const FirstCoresSheet = ({ + amount, + persona, + onShowMe, + onLater, +}: { + amount: number; + persona: FirstCoresPersona; + onShowMe: () => void; + onLater: () => void; +}): ReactElement => { + const [primary, ...rest] = orderFor[persona]; + + return ( + +
      + + {rest.map((use) => ( + + ))} +
    +
    + + +
    +
    + ); +}; + +export const CoresPurchasedModal = ({ + amount, + onGotIt, +}: { + amount: number; + onGotIt: () => void; +}): ReactElement => ( + + + +); diff --git a/packages/storybook/stories/cores-boost/redesign/cores/Screens.tsx b/packages/storybook/stories/cores-boost/redesign/cores/Screens.tsx new file mode 100644 index 00000000000..00433b7feb7 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/cores/Screens.tsx @@ -0,0 +1,283 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + ProfileImageSize, + ProfilePicture, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { + Image, + ImageType, +} from '@dailydotdev/shared/src/components/image/Image'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { CoreIcon, MiniCloseIcon } from '@dailydotdev/shared/src/components/icons'; +import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { NotificationSayThanksButton } from '@dailydotdev/shared/src/components/notifications/NotificationSayThanksButton'; +import { publishTimeRelativeShort } from '@dailydotdev/shared/src/lib/dateFormat'; +import { walletUrl } from '@dailydotdev/shared/src/lib/constants'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { + bunPost, + findNotificationRow, + NotificationsPage, + PostPage, + SwapInPage, +} from '../../real/pages'; +import { coffee, ido, idoAward, ownPost } from '../../flows/cores/fixtures'; +import { stripArt } from './frame'; + +// The Cores flow screens in the new look: quest popover, the nudge strip on +// your own post, and the award notification (bell row and real-time popup). +// Same copy, data and buttons as the Flows screens. + +/** The real daily quests page, as in the Flows (the quest card is production) */ +export { QuestScreen } from '../../flows/cores/screens'; + +/** On your own post, once the balance covers the smallest boost */ +export const NudgeStrip = ({ + balance, + onBoost, + onClose, + className, +}: { + balance: number; + onBoost: () => void; + onClose?: () => void; + className?: string; +}): ReactElement => ( +
    + + Patchy riding a boost rocket + + + + + You have{' '} + + {formatCoresCurrency(balance)} Cores, + + + + enough to boost this post + + + +
    +); + +/** Your own post on the real post page, with the nudge where the strip goes */ +export const OwnPostScreen = ({ + balance, + onBoost, + showNudge = true, +}: { + balance: number; + onBoost?: () => void; + showNudge?: boolean; +}): ReactElement => { + const [isDismissed, setIsDismissed] = useState(false); + + return ( + onBoost?.()} + onClose={() => setIsDismissed(true)} + /> + ) + } + /> + ); +}; + +const net = idoAward.valueIncFees; + +const CoresPill = ({ amount }: { amount: number }): ReactElement => ( + + +{formatCoresCurrency(amount)} Cores + +); + +/** The sender's avatar with the award they sent pinned to it */ +const AwardLead = ({ size = 'size-10' }: { size?: string }): ReactElement => ( + + + + {coffee?.name} + + +); + +export const ViewInWalletButton = ({ + onClick, +}: { + onClick?: () => void; +}): ReactElement => ( + +); + +export const AwardNotificationRow = ({ + isUnread = true, + createdAt = idoAward.createdAt, + onViewWallet, +}: { + isUnread?: boolean; + createdAt?: Date; + onViewWallet?: () => void; +}): ReactElement => ( +
    + {isUnread && ( + + )} + +
    +

    + {ido.name} gave you {coffee?.name}{' '} + +

    + + “{idoAward.flags.note}” + + + {ownPost.title} + · + + + + + + + + +
    + {`Cover +
    +); + +/** The real-time popup, placed inline unless `isFloating` */ +export const AwardNotificationPopup = ({ + isFloating, + onClick, + onClose, +}: { + isFloating?: boolean; + onClick?: () => void; + onClose?: () => void; +}): ReactElement => ( +
    + +
    +); + +/** The real notifications page, with the award row in the new look */ +export const NotificationsScreen = ({ + onViewWallet, +}: { + onViewWallet?: () => void; +}): ReactElement => ( + <> + + + + + +); diff --git a/packages/storybook/stories/cores-boost/redesign/cores/TransactionRow.tsx b/packages/storybook/stories/cores-boost/redesign/cores/TransactionRow.tsx new file mode 100644 index 00000000000..b86fca63f66 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/cores/TransactionRow.tsx @@ -0,0 +1,166 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { + CoreIcon, + CreditCardIcon, + InfoIcon, + MinusIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { UserImageProps } from '@dailydotdev/shared/src/components/ProfilePicture'; +import { + ProfileImageSize, + ProfilePicture, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { DateFormat } from '@dailydotdev/shared/src/components/utilities/DateFormat'; +import { TimeFormatType } from '@dailydotdev/shared/src/lib/dateFormat'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { TransactionItemType } from '@dailydotdev/shared/src/lib/transaction'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; +import { ProfileLink } from '@dailydotdev/shared/src/components/profile/ProfileLink'; +import { ProfileTooltip } from '@dailydotdev/shared/src/components/profile/ProfileTooltip'; + +// The wallet's transaction row in the reading-list row shape: who first in +// bold, what and when in caption, the amount as a pill on the right. Same +// fields as the Flows row (label, Squad, date, fee details, note, Say thanks). + +export type TransactionRowProps = { + type: TransactionItemType; + user: UserImageProps; + profileUser?: UserShortProfile; + date: Date; + amount: number; + label: ReactNode; + extraLabel?: ReactNode; + note?: ReactNode; + details?: ReactNode; + action?: ReactNode; + isPending?: boolean; + isHighlighted?: boolean; +}; + +const typeBadge: Record = { + receive: ( + + + + ), + send: ( + + + + ), + purchase: ( + + + + ), + unknown: ( + + + + ), +}; + +export const TransactionRow = ({ + type, + user, + profileUser, + date, + amount, + label, + extraLabel, + note, + details, + action, + isPending, + isHighlighted, +}: TransactionRowProps): ReactElement => { + const itemRef = useRef(null); + + useEffect(() => { + if (isHighlighted) { + itemRef.current?.scrollIntoView({ block: 'center' }); + } + }, [isHighlighted]); + + const linkToProfile = (children: ReactElement): ReactElement => { + if (!profileUser) { + return children; + } + + return ( + + + {children} + + + ); + }; + + return ( +
  • + + {linkToProfile( + , + )} + + {typeBadge[type]} + + +
    + {linkToProfile( + {user.name}, + )} + + {label} + {extraLabel && ( + <> + · + {extraLabel} + + )} + · + + + {!!details && ( + + {isPending && ( + + )} + {details} + + )} + {!!note && ( + + {note} + + )} + {action && {action}} +
    + + + + {amount === 0 && 'Free'} + {amount !== 0 && + `${amount > 0 ? '+' : ''}${formatCoresCurrency(amount)}`} + + +
  • + ); +}; diff --git a/packages/storybook/stories/cores-boost/redesign/cores/UseYourCores.tsx b/packages/storybook/stories/cores-boost/redesign/cores/UseYourCores.tsx new file mode 100644 index 00000000000..5f1c61ec743 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/cores/UseYourCores.tsx @@ -0,0 +1,229 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + ArrowIcon, + CoreIcon, + ReadingStreakIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { awards } from '../../real/catalog'; +import { minAward, minBoost } from '../../flows/cores/fixtures'; +import type { CoresUse } from '../../flows/cores/UseYourCores'; + +// "Use your Cores" in the new look: tiles in the wallet, compact rows in the +// first Cores sheet. Same three uses, prices and coverage as the Flows copy. + +export type { CoresUse }; + +interface UseDefinition { + title: string; + description: string; + icon: ReactNode; + tint: string; + from: number | null; +} + +const heart = awards.find(({ name }) => name === 'Heart'); + +const uses: Record = { + award: { + title: 'Give an award', + description: 'Thank a creator for a post or a comment', + icon: Award, + tint: 'bg-overlay-float-cabbage', + from: minAward, + }, + boost: { + title: 'Boost your post', + description: 'Show your post to more developers', + icon: ( + + ), + tint: 'bg-overlay-float-blueCheese', + from: minBoost, + }, + streak: { + title: 'Restore a lost streak', + description: 'Bring back a streak the day after it breaks', + icon: ( + + ), + tint: 'bg-overlay-float-bacon', + from: null, + }, +}; + +const Coverage = ({ + balance, + from, +}: { + balance: number; + from: number | null; +}): ReactElement => { + if (from === null) { + return ( + + Price varies + + ); + } + + if (balance >= from) { + return ( + + You have enough + + ); + } + + return ( + + {formatCoresCurrency(from - balance)} more needed + + ); +}; + +const FromPrice = ({ from }: { from: number | null }): ReactElement | null => + from === null ? null : ( + + From + + {formatCoresCurrency(from)} + + ); + +const UseIcon = ({ use }: { use: CoresUse }): ReactElement => ( + + {uses[use].icon} + +); + +export const CoresUseTile = ({ + use, + balance, + onClick, +}: { + use: CoresUse; + balance: number; + onClick?: () => void; +}): ReactElement => { + const { title, description, from } = uses[use]; + + return ( +
  • + +
  • + ); +}; + +export const UseYourCoresTiles = ({ + balance, + onUse, +}: { + balance: number; + onUse?: (use: CoresUse) => void; +}): ReactElement => ( + <> +
      + {(['award', 'boost', 'streak'] as CoresUse[]).map((use) => ( + onUse?.(use)} + /> + ))} +
    +
      + {(['award', 'boost', 'streak'] as CoresUse[]).map((use) => ( + onUse?.(use)} + /> + ))} +
    + +); + +/** One fixed-height row, for the first Cores sheet */ +export const CoresUseRow = ({ + use, + balance, + isPrimary, + onClick, +}: { + use: CoresUse; + balance: number; + isPrimary?: boolean; + onClick?: () => void; +}): ReactElement => { + const { title, from } = uses[use]; + + return ( +
  • + +
  • + ); +}; diff --git a/packages/storybook/stories/cores-boost/redesign/cores/Wallet.tsx b/packages/storybook/stories/cores-boost/redesign/cores/Wallet.tsx new file mode 100644 index 00000000000..6d4bb7e604d --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/cores/Wallet.tsx @@ -0,0 +1,561 @@ +import type { ReactElement, ReactNode } from 'react'; +import { useRoute } from '../../real/providers'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + coresDocsLink, + creatorsTermsOfService, + webappUrl, +} from '@dailydotdev/shared/src/lib/constants'; +import { + ArrowIcon, + CoreIcon, + CreditCardIcon, + DocsIcon, + FeedbackIcon, + InfoIcon, + MinusIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import { Origin, TargetId } from '@dailydotdev/shared/src/lib/log'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { + getTransactionLabel, + getTransactionType, +} from '@dailydotdev/shared/src/lib/transaction'; +import { + generateQueryKey, + RequestKey, + StaleTime, +} from '@dailydotdev/shared/src/lib/query'; +import type { UserTransaction } from '@dailydotdev/shared/src/graphql/njord'; +import { + coresPricesQueryOptions, + getTransactions, + getTransactionSummary, + ProductType, + UserTransactionStatus, + UserTransactionType, +} from '@dailydotdev/shared/src/graphql/njord'; +import { NotificationSayThanksButton } from '@dailydotdev/shared/src/components/notifications/NotificationSayThanksButton'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { ShellPage } from '@dailydotdev/shared/src/components/shell/ShellPageContext'; +import { useIsPhone } from '@dailydotdev/shared/src/hooks/useViewSize'; +import { useLaptopLayout } from '@dailydotdev/shared/src/hooks/layout/useLaptopLayout'; +import { getPathnameWithQuery } from '@dailydotdev/shared/src/lib'; +import { SquadWidget } from '@dailydotdev/shared/src/features/squads/components/widgets/SquadWidget'; +import { UpgradeToPlus } from '@dailydotdev/shared/src/components/UpgradeToPlus'; +import { usePlusSubscription } from '@dailydotdev/shared/src/hooks/usePlusSubscription'; +import { + CharmEmptyState, + CharmEmptyStatePlacement, +} from '@dailydotdev/shared/src/components/charm/CharmEmptyState'; +import { cloudinaryCharmReadLater } from '@dailydotdev/shared/src/lib/image'; +import type { CoresUse } from './UseYourCores'; +import { UseYourCoresTiles } from './UseYourCores'; +import { TransactionRow } from './TransactionRow'; +import { CardSection, friendshipArt, SquadFrame } from './frame'; + +// The wallet in the squads hub layout. Same content as the Flows wallet: +// balance and the four totals (with their tooltips), Use your Cores, the +// transaction history, Buy Cores in the header, the 100/300/600 packs and +// See more options, the Plus giveback, Docs and Terms. The page now reads as +// one card (balance → uses → history) with the buying and help as widgets. + +const PENDING_SUPPORT_MS = 10 * 60 * 1000; + +const isAwardFromPerson = (transaction: UserTransaction): boolean => + !!transaction.sender && + transaction.sender.id !== 'system' && + transaction.product?.type !== ProductType.StreakFreeze && + !!transaction.product; + +const getDetails = ( + transaction: UserTransaction, + type: ReturnType, +): ReactNode => { + if (transaction.status === UserTransactionStatus.Processing) { + const age = Date.now() - new Date(transaction.createdAt).getTime(); + + if (age < PENDING_SUPPORT_MS) { + return 'Payment received. Your Cores usually land in under a minute.'; + } + + return ( + <> + Taking longer than usual.{' '} + + Contact support + + + ); + } + + if (type === 'receive' && isAwardFromPerson(transaction)) { + const fee = transaction.value - transaction.valueIncFees; + + return `${formatCoresCurrency(transaction.value)} Cores award · ${formatCoresCurrency(fee)} Cores fee`; + } + + return undefined; +}; + +const getNote = (transaction: UserTransaction): string | undefined => { + const { note } = transaction.flags; + + if ( + !note || + !isAwardFromPerson(transaction) || + transaction.referenceType === UserTransactionType.PostBoost + ) { + return undefined; + } + + return `“${note}”`; +}; + +const coresLink = (params: Record): string => + getPathnameWithQuery( + `${webappUrl}cores`, + new URLSearchParams({ next: '/wallet', ...params }), + ); + +interface Total { + key: string; + title: string; + description: string; + icon: ReactNode; + value: number; + change?: number; +} + +const TotalCell = ({ + total, + isLoading, +}: { + total: Total; + isLoading?: boolean; +}): ReactElement => ( +
    + + + {total.icon} + {total.title} + + + + {isLoading ? ( + + ) : ( + + + {formatCoresCurrency(total.value)} + + {!!total.change && ( + + +{formatCoresCurrency(total.change)} + + )} + + )} +
    +); + +const dot = (className: string, icon: ReactNode): ReactNode => ( + + {icon} + +); + +/** Balance hero: the number first, the four totals in one strip under it */ +const BalanceHero = ({ + balance, + totals, + isLoading, +}: { + balance: number; + totals: Total[]; + isLoading: boolean; +}): ReactElement => ( +
    +
    +
    + + + Balance + + + + + + + {formatCoresCurrency(balance)} + + +
    + Friends sharing a Core with Patchy +
    +
    + {totals.map((total) => ( + + ))} +
    +
    +); + +const PacksWidget = (): ReactElement => { + const { user, isLoggedIn } = useAuthContext(); + const { data: prices } = useQuery( + coresPricesQueryOptions({ user, isLoggedIn }), + ); + const packs = (prices ?? []).filter((item) => + [100, 300, 600].includes(item.metadata.coresValue ?? 0), + ); + + return ( + +

    + Stock up on Cores to engage, reward, and unlock more on daily.dev +

    + + +
    + ); +}; + +const GivebackWidget = (): ReactElement | null => { + const { isPlus } = usePlusSubscription(); + + if (isPlus) { + return null; + } + + return ( + +

    + Every Plus payment, renewals included, gives you 50% back as Cores. +

    + +
    + ); +}; + +const LinkRow = ({ + href, + icon, + label, +}: { + href: string; + icon: ReactNode; + label: string; +}): ReactElement => ( +
  • + + {icon} + {label} + + +
  • +); + +const HelpWidget = (): ReactElement => ( + +
      + } + label="Docs" + /> + } + label="Terms" + /> +
    +
    +); + +export interface WalletProps { + /** Transaction a notification or a purchase points at */ + highlightId?: string; + /** Cores a purchase just added, shown on the Purchased total */ + purchasedChange?: number; + onBuyCores?: () => void; + onUse?: (use: CoresUse) => void; +} + +export const Wallet = ({ + highlightId, + purchasedChange, + onBuyCores, + onUse, +}: WalletProps): ReactElement | null => { + useRoute('/wallet'); + const { user, isLoggedIn } = useAuthContext(); + const isLaptopLayout = useLaptopLayout(); + const isPhone = useIsPhone(); + + const { data: transactionSummary, isPending: isPendingSummary } = useQuery({ + queryKey: generateQueryKey(RequestKey.Transactions, user, 'summary'), + queryFn: getTransactionSummary, + enabled: isLoggedIn, + staleTime: StaleTime.Default, + }); + const summary = transactionSummary as + | (typeof transactionSummary & { fees?: number }) + | undefined; + + const { data: transactions, isPending: isPendingTransactions } = useQuery({ + queryKey: generateQueryKey(RequestKey.Transactions, user, 'list', { + first: 20, + }), + queryFn: () => getTransactions({ first: 20 }), + enabled: isLoggedIn, + staleTime: StaleTime.Default, + }); + + // Every /cores link on the page leads to the story's /cores step + const onLinkClick = useCallback( + (event: React.MouseEvent) => { + const link = (event.target as HTMLElement).closest('a'); + + if (!/cores(\?|$)/.test(link?.getAttribute('href') ?? '')) { + return; + } + event.preventDefault(); + onBuyCores?.(); + }, + [onBuyCores], + ); + + if (!user) { + return null; + } + + const buyCoresButton = ( + + ); + + const totals: Total[] = [ + { + key: 'purchased', + title: 'Purchased', + description: 'Amount of cores you have purchased', + icon: dot( + 'bg-action-bookmark-float text-accent-bun-default', + , + ), + value: summary?.purchased || 0, + change: purchasedChange, + }, + { + key: 'received', + title: 'Received', + description: 'Cores you have received, after fees', + icon: dot( + 'bg-action-upvote-float text-accent-avocado-default', + , + ), + value: summary?.received || 0, + }, + { + key: 'fees', + title: 'Fees', + description: 'The share of awards you received that daily.dev keeps', + icon: dot( + 'bg-surface-float text-text-tertiary', + , + ), + value: summary?.fees || 0, + }, + { + key: 'spent', + title: 'Spent', + description: 'Cores you have spent on awards, boosts and streak restores', + icon: dot( + 'bg-action-downvote-float text-accent-ketchup-default', + , + ), + value: summary?.spent || 0, + }, + ]; + + const edges = transactions?.edges ?? []; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
    + {isLaptopLayout && ( + {buyCoresButton} + )} + {isPhone && ( + + )} + + + + + + } + > + {!isLaptopLayout && !isPhone && ( +
    +

    Core wallet

    + {buyCoresButton} +
    + )} + + + + +
    +

    + Transaction history +

    + {!isPendingTransactions && !edges.length && ( + + )} +
      + {edges.map(({ node: transaction }) => { + const type = getTransactionType({ transaction, user }); + const transactionUser = + type === 'receive' + ? (transaction.sender ?? transaction.receiver) + : transaction.receiver; + const canThank = + type === 'receive' && isAwardFromPerson(transaction); + + return ( + + ) + } + isHighlighted={transaction.id === highlightId} + /> + ); + })} +
    +
    +
    +
    + ); +}; diff --git a/packages/storybook/stories/cores-boost/redesign/cores/frame.tsx b/packages/storybook/stories/cores-boost/redesign/cores/frame.tsx new file mode 100644 index 00000000000..543ff389a75 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/cores/frame.tsx @@ -0,0 +1,66 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { useViewSizeClient, ViewSize } from '@dailydotdev/shared/src/hooks'; +import friendshipArt from '../../assets/cores-checkout-friendship.png'; +import stripArt from '../../assets/boost-strip-patchy-v2.png'; + +export { friendshipArt, stripArt }; + +// The squads hub frame (SquadDiscoverFrame in features/squads): the main +// column is one bordered card and, from laptop, a column of SquadWidgets +// beside it. Below laptop the widgets follow the card. + +export const SquadFrame = ({ + children, + widgets, +}: { + children: ReactNode; + widgets?: ReactNode; +}): ReactElement => { + const isLaptop = useViewSizeClient(ViewSize.Laptop); + + return ( +
    +
    + {children} +
    + {widgets && isLaptop && ( + + )} + {widgets && isLaptop === false && ( +
    + {widgets} +
    + )} +
    + ); +}; + +/** A section of the main card, split from the next by a hairline */ +export const CardSection = ({ + title, + action, + children, + className, +}: { + title?: ReactNode; + action?: ReactNode; + children: ReactNode; + className?: string; +}): ReactElement => ( +
    + {(title || action) && ( +
    + {title &&

    {title}

    } + {action} +
    + )} + {children} +
    +); diff --git a/packages/storybook/stories/cores-boost/redesign/purchase/AwardRedesign.tsx b/packages/storybook/stories/cores-boost/redesign/purchase/AwardRedesign.tsx new file mode 100644 index 00000000000..72930da2c44 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/purchase/AwardRedesign.tsx @@ -0,0 +1,439 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { ModalKind } from '@dailydotdev/shared/src/components/modals/common/types'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons/Core'; +import { VIcon } from '@dailydotdev/shared/src/components/icons/V'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import MarkdownInput from '@dailydotdev/shared/src/components/fields/MarkdownInput'; +import { BuyCreditsButton } from '@dailydotdev/shared/src/components/credit/BuyCreditsButton'; +import { AwardFeesNote } from '@dailydotdev/shared/src/components/cores/AwardFeesNote'; +import { CoresBalanceNote } from '@dailydotdev/shared/src/components/cores/CoresBalanceNote'; +import { maxNoteLength } from '@dailydotdev/shared/src/contexts/GiveAwardModalContext'; +import type { Product } from '@dailydotdev/shared/src/graphql/njord'; +import { + award, + getProductsQueryOptions, +} from '@dailydotdev/shared/src/graphql/njord'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + useViewSize, + ViewSize, +} from '@dailydotdev/shared/src/hooks/useViewSize'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { appStoreUrl } from '@dailydotdev/shared/src/lib/constants'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { awardReceiver } from '../../flows/purchase/AwardFixed'; +import friendshipArt from '../../assets/cores-checkout-friendship.png'; +import { CoreAmount } from './kit'; + +// The award screens of flows/purchase/AwardFixed.tsx in the redesign's line. +// Same controls, copy and states; new: the receiver leads the sheet, award +// tiles are quiet cards that mark the shortfall in a corner chip, and a +// summary card carries the pick, the balance and the next step. + +const receiverName = awardReceiver.name; + +const AwardTile = ({ + item, + balance, + isSelected, + onClick, +}: { + item: Product; + balance: number; + isSelected: boolean; + onClick: () => void; +}): ReactElement => { + const missing = item.value - balance; + + return ( + + ); +}; + +export interface AwardPickerRedesignProps { + initialAward?: string; + canPurchaseCores?: boolean; + onBuyCores: (product: Product) => void; + onPicked: (product: Product) => void; + onRequestClose: () => void; +} + +export const AwardPickerRedesign = ({ + initialAward, + canPurchaseCores = true, + onBuyCores, + onPicked, + onRequestClose, +}: AwardPickerRedesignProps): ReactElement => { + const { user } = useAuthContext(); + const isMobile = useViewSize(ViewSize.MobileL); + const balance = user?.balance?.amount ?? 0; + const { data: awards } = useQuery(getProductsQueryOptions()); + const [picked, setPicked] = useState(initialAward); + const product = awards?.edges + .map(({ node }) => node) + .find(({ name }) => name === picked); + const missing = product ? product.value - balance : 0; + + return ( + + + product && onBuyCores(product)} + origin={Origin.Award} + /> + {isMobile ? ( + + ) : null} + + +
    + Award user +
    + + Give an Award + + + Show your appreciation! Pick an Award to send to{' '} + + {receiverName} + + ! + +
    +
    +
    + {awards?.edges?.map(({ node: item }) => ( + { + if (item.value > balance) { + setPicked(item.name); + return; + } + onPicked(item); + }} + /> + ))} +
    +
    + {!!product && missing > 0 && ( + + {canPurchaseCores ? ( + + ) : ( + + )} +
    + + +
    +
    + )} +
    + ); +}; + +export const AwardNoteRedesign = ({ + product, + coresAdded, + onBack, + onSent, + onRequestClose, +}: { + product: Product; + coresAdded?: number; + onBack: () => void; + onSent: () => void; + onRequestClose: () => void; +}): ReactElement => { + const { updateUser, user } = useAuthContext(); + const isMobile = useViewSize(ViewSize.MobileL); + const [note, setNote] = useState(''); + const { mutate: awardMutation, isPending } = useMutation({ + mutationFn: award, + onSuccess: async () => { + if (user) { + await updateUser({ + ...user, + balance: { amount: user.balance.amount - product.value }, + }); + } + onSent(); + }, + }); + + return ( + + + + + + + + ); +}; + +/** The success screen, led by the friendship art the award stands for */ +export const AwardSentRedesign = ({ + product, + onRequestClose, +}: { + product: Product; + onRequestClose: () => void; +}): ReactElement => ( + + +
    + + {product.name} +
    +
    + + + +
    + + Congrats! Your Award has been sent to {receiverName} + + + Thanks for supporting the community! + +
    + +
    +
    +
    +); diff --git a/packages/storybook/stories/cores-boost/redesign/purchase/BoostRedesign.tsx b/packages/storybook/stories/cores-boost/redesign/purchase/BoostRedesign.tsx new file mode 100644 index 00000000000..ee1775473ed --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/purchase/BoostRedesign.tsx @@ -0,0 +1,658 @@ +import type { ReactElement, ReactNode } from 'react'; +import dynamic from 'next/dynamic'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { ownArticle, PostPage } from '../../real/pages'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons/Core'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; +import { MiniCloseIcon } from '@dailydotdev/shared/src/components/icons/MiniClose'; +import { TrendingIcon } from '@dailydotdev/shared/src/components/icons/Trending'; +import { VIcon } from '@dailydotdev/shared/src/components/icons/V'; +import { TimerIcon } from '@dailydotdev/shared/src/components/icons/Timer'; +import { EyeIcon } from '@dailydotdev/shared/src/components/icons/Eye'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { ModalClose } from '@dailydotdev/shared/src/components/modals/common/ModalClose'; +import { BoostWalletPicker } from '@dailydotdev/shared/src/features/boost/BoostWalletPicker'; +import { StartupsBoostPromo } from '@dailydotdev/shared/src/features/squads/components/promos/StartupsPromo'; +import { useBoostWallet } from '@dailydotdev/shared/src/features/boost/useBoostWallet'; +import { usePostBoostEstimation } from '@dailydotdev/shared/src/features/boost/usePostBoostEstimation'; +import { useFeaturesReadyContext } from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { boostSettingsFeature } from '@dailydotdev/shared/src/lib/featureManagement'; +import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { CampaignWallet } from '@dailydotdev/shared/src/graphql/campaigns'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import { + boostDocsLink, + walletUrl, +} from '@dailydotdev/shared/src/lib/constants'; +import type { OwnPostState } from '../../flows/purchase/BoostFixed'; +import { starterBoost } from '../../flows/purchase/BoostFixed'; +import stripArt from '../../assets/boost-strip-patchy-v2.png'; +import { Row } from './kit'; + +const Slider = dynamic( + () => + import('@dailydotdev/shared/src/components/fields/Slider').then( + (mod) => mod.Slider, + ), + { ssr: false }, +); + +// The first-boost screens of flows/purchase/BoostFixed.tsx in the redesign's +// line. Same entries, presets, sliders, copy and actions; new: the strip is a +// card with the Patchy art on its left, the form reads as stat tiles and +// radio cards, and the launch and live screens share the rocket art. + +type Budget = typeof starterBoost; + +/** The Patchy rocket, cropped to its left side where the art lives */ +const RocketArt = ({ className }: { className?: string }) => ( +
    + +
    +); + +const Strip = ({ + children, + onBoost, + onDismiss, +}: { + children: ReactNode; + onBoost: () => void; + onDismiss?: () => void; +}): ReactElement => ( +
    + + + + + + {children} + + + {onDismiss && ( +
    +); + +/** Your own article on the real post page, the strip where production puts it */ +export const OwnPostRedesign = ({ + post, + state, + onBoost, +}: { + post: Post; + state: OwnPostState; + onBoost: () => void; +}): ReactElement => { + const [isDismissed, setDismissed] = useState(false); + + return ( + setDismissed(true)}> + Boost to reach + 10k more devs + now! + + ) : ( + setDismissed(true)}> + This post is taking off. + Boost it while developers are paying attention. + + )) + } + /> + ); +}; + +type BudgetMode = 'starter' | 'custom'; + +const BudgetCard = ({ + isSelected, + title, + description, + onClick, +}: { + isSelected: boolean; + title: string; + description: string; + onClick: () => void; +}): ReactElement => ( + +); + +const StatTile = ({ + icon, + value, + label, +}: { + icon: ReactNode; + value: ReactNode; + label: string; +}): ReactElement => ( +
    + {icon} + + {value} + + + {label} + +
    +); + +export interface BoostPostRedesignModalProps { + post: Post; + initialMode?: BudgetMode; + onBuyCores: (budget: Budget) => void; + onBoost: (budget: Budget) => void; + onRequestClose: () => void; +} + +export const BoostPostRedesignModal = ({ + post, + initialMode = 'starter', + onBuyCores, + onBoost, + onRequestClose, +}: BoostPostRedesignModalProps): ReactElement => { + const { user } = useAuthContext(); + const { getFeatureValue } = useFeaturesReadyContext(); + const boostSettings = getFeatureValue(boostSettingsFeature); + const [mode, setMode] = useState(initialMode); + const [custom, setCustom] = useState({ + coresPerDay: boostSettings.default_cores, + totalDays: boostSettings.default_days, + }); + const budget = mode === 'starter' ? starterBoost : custom; + const [estimate, setEstimate] = useState(budget.coresPerDay); + const [updateEstimate] = useDebounceFn(setEstimate, 400); + const totalSpendInt = budget.coresPerDay * budget.totalDays; + const totalSpend = largeNumberFormat(totalSpendInt); + const squadId = + post.source?.type === SourceType.Squad ? post.source.id : undefined; + const { squadWallet, wallet, setWallet, balance } = useBoostWallet(squadId); + const { estimatedReach, canBoost, isLoading } = usePostBoostEstimation({ + post, + query: { budget: estimate }, + }); + const missing = totalSpendInt - balance; + + useEffect(() => { + updateEstimate(budget.coresPerDay); + }, [budget.coresPerDay, updateEstimate]); + + const potentialReach = (() => { + if (!canBoost || isLoading) { + return ; + } + + const min = largeNumberFormat(estimatedReach.min); + const max = largeNumberFormat( + Math.max(estimatedReach.min, estimatedReach.max), + ); + + return `${min} - ${max}`; + })(); + + const onButtonClick = () => { + if (missing > 0) { + return wallet === CampaignWallet.User ? onBuyCores(budget) : undefined; + } + + return onBoost(budget); + }; + + return ( + + + + Boost your post + +
    + +
    +
    + + +
    + + ) + } + /> + + Give your content the spotlight it deserves. Our auto-targeting + engine ensures your post gets shown to the developers most likely to + care. + + Learn more + + +
    +
    +
    + } + value={ + <> + {totalSpend} Cores over {budget.totalDays}{' '} + {budget.totalDays === 1 ? 'day' : 'days'} + + } + label="Total spend" + /> + } + value={potentialReach} + label="Estimated daily reach" + /> +
    + {!canBoost && ( + + + Reading your post to estimate reach… + + )} +
    +
    + + Budget + +
    + setMode('starter')} + /> + setMode('custom')} + /> +
    + {mode === 'custom' && ( +
    +
    +
    + + Budget + + + + {largeNumberFormat(custom.coresPerDay || 0)} Cores per day + +
    + + setCustom((state) => ({ ...state, coresPerDay: value })) + } + /> +
    +
    +
    + + Duration + + + + {custom.totalDays} days + +
    + + setCustom((state) => ({ ...state, totalDays: value })) + } + /> +
    +
    + )} +
    + {squadWallet && ( + + )} + +
    + + + + + ); +}; + +const launchSeconds = 3; + +/** The boost the user set up starts by itself once the Cores land */ +export const BoostLaunchingRedesign = ({ + post, + budget, + onLaunched, + onChange, + onRequestClose, + isPaused, +}: { + post: Post; + budget: Budget; + onLaunched: () => void; + onChange: () => void; + onRequestClose: () => void; + isPaused?: boolean; +}): ReactElement => { + const [secondsLeft, setSecondsLeft] = useState(launchSeconds); + + useEffect(() => { + if (isPaused) { + return undefined; + } + if (secondsLeft === 0) { + onLaunched(); + return undefined; + } + const timer = setTimeout(() => setSecondsLeft((left) => left - 1), 1000); + return () => clearTimeout(timer); + }, [secondsLeft, onLaunched, isPaused]); + + const total = budget.coresPerDay * budget.totalDays; + const progress = ((launchSeconds - secondsLeft) / launchSeconds) * 100; + + return ( + + + +
    + + Your Cores are in. Starting your boost… + + + ) + } + title={ + <> + Boosting for {budget.totalDays}{' '} + {budget.totalDays === 1 ? 'day' : 'days'} ·{' '} + {total.toLocaleString()} Cores + + } + meta={{post.title}} + trailing={ + + } + /> +
    +
    +
    +
    + + Starts in {secondsLeft}s + +
    +
    + + + ); +}; + +/** The success modal, with the rocket art in place of the cover image */ +export const BoostLiveRedesign = ({ + onSeeBoost, + onRequestClose, +}: { + onSeeBoost: () => void; + onRequestClose: () => void; +}): ReactElement => ( + + +
    + + + + + +
    +
    + + Boost is live! + + + Your post is now reaching more developers. Find it any time in Boosts. + + + +
    +
    +
    +); diff --git a/packages/storybook/stories/cores-boost/redesign/purchase/BuyCoresRedesign.tsx b/packages/storybook/stories/cores-boost/redesign/purchase/BuyCoresRedesign.tsx new file mode 100644 index 00000000000..19057f9dce7 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/purchase/BuyCoresRedesign.tsx @@ -0,0 +1,402 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { ModalBody } from '@dailydotdev/shared/src/components/modals/common/ModalBody'; +import { ModalKind } from '@dailydotdev/shared/src/components/modals/common/types'; +import { BuyCreditsButton } from '@dailydotdev/shared/src/components/credit/BuyCreditsButton'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { getRecommendedCorePrice } from '@dailydotdev/shared/src/components/cores/recommendedCorePrice'; +import type { CoreProductOption } from '@dailydotdev/shared/src/contexts/BuyCoresContext/types'; +import { coresPricesQueryOptions } from '@dailydotdev/shared/src/graphql/njord'; +import type { Product } from '@dailydotdev/shared/src/graphql/njord'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + useViewSize, + ViewSize, +} from '@dailydotdev/shared/src/hooks/useViewSize'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; +import { walletUrl } from '@dailydotdev/shared/src/lib/constants'; +import { + coreImage, + getCoreCurrencyImage, +} from '@dailydotdev/shared/src/lib/image'; +import type { CoresPurpose } from '../../flows/purchase/BuyCoresFixed'; +import { NeededCopy } from '../../flows/purchase/BuyCoresFixed'; +import friendshipArt from '../../assets/cores-checkout-friendship.png'; +import { CoreHalo, Row } from './kit'; +import { StartupsBuyCoresPromo } from '@dailydotdev/shared/src/features/squads/components/promos/StartupsPromo'; + +// BuyCoresFixedModal (flows/purchase/BuyCoresFixed.tsx) in the redesign's +// line. Same packs, preselection, copy and actions; new: a "what it's for" +// card leads, packs are calm rows that say whether they cover it, and the +// friendship art replaces the two-hands video beside the checkout. + +/** Stands in for the Paddle iframe, which needs a client token */ +const CheckoutStandIn = ({ + pack, + onPay, +}: { + pack?: { cores: number; price: string }; + onPay: () => void; +}): ReactElement => ( +
    + + Paddle checkout opens here in production + + + } + title={pack ? `${formatCoresCurrency(pack.cores)} Cores` : 'Pick a pack'} + meta={pack?.price} + /> + +
    +); + +const PackList = ({ + minCores, + selected, + onSelect, +}: { + minCores: number; + selected?: CoreProductOption; + onSelect: (pack: CoreProductOption) => void; +}): ReactElement => { + const { user, isLoggedIn } = useAuthContext(); + const { data: prices, isPending } = useQuery( + coresPricesQueryOptions({ user, isLoggedIn }), + ); + const recommended = getRecommendedCorePrice({ prices, minCores }); + + return ( +
      + {isPending && + Array.from({ length: 6 }).map((_, index) => ( +
    • + ))} + {prices?.map((price) => { + const cores = price.metadata.coresValue ?? 0; + const isSelected = selected?.id === price.priceId; + const isRecommended = price.priceId === recommended?.priceId; + const isNotEnough = !!minCores && cores < minCores; + + return ( +
    • + +
    • + ); + })} +
    + ); +}; + +/** The friendship art that stood in for the checkout video */ +const FriendshipPanel = ({ children }: { children: ReactNode }) => ( +
    +
    + Friends passing a Core +
    +
    +
    {children}
    +
    +); + +export interface BuyCoresRedesignModalProps { + purpose: CoresPurpose; + cost: number; + product?: Product; + onPay: (pack: CoreProductOption) => void; + onRequestClose: () => void; +} + +export const BuyCoresRedesignModal = ({ + purpose, + cost, + product, + onPay, + onRequestClose, +}: BuyCoresRedesignModalProps): ReactElement => { + const { user, isLoggedIn } = useAuthContext(); + const isMobile = useViewSize(ViewSize.MobileL); + const balance = user?.balance?.amount ?? 0; + const missing = Math.max(0, cost - balance); + const { data: prices } = useQuery( + coresPricesQueryOptions({ user, isLoggedIn }), + ); + const [selected, setSelected] = useState(); + const [isCheckoutOpen, setCheckoutOpen] = useState(!isMobile); + const origin = purpose === 'award' ? Origin.Award : Origin.BoostPost; + + useEffect(() => { + const recommended = getRecommendedCorePrice({ prices, minCores: missing }); + if (!recommended || selected) { + return; + } + setSelected({ + id: recommended.priceId, + value: recommended.metadata.coresValue ?? 0, + }); + }, [prices, missing, selected]); + + const pack = useMemo(() => { + const price = prices?.find(({ priceId }) => priceId === selected?.id); + return price && selected + ? { cores: selected.value, price: price.price.formatted } + : undefined; + }, [prices, selected]); + + const onPlusClick = () => + document + .querySelector('[role="radiogroup"] [aria-checked="true"]') + ?.focus(); + + const pay = () => selected && onPay(selected); + + const options = ( +
    + + {purpose === 'boost' && } +
    + + Choose how many Cores to buy + + +
    +
    + ); + + return ( + + + + {isMobile && ( + + )} + + {isMobile ? ( + <> + + {isCheckoutOpen ? ( + <> + Friends passing a Core + + + ) : ( + options + )} + + {!isCheckoutOpen && ( + + + + )} + + ) : ( + +
    +
    {options}
    + + + +
    +
    + )} +
    + ); +}; + +export type ProcessingState = 'processing' | 'timeout'; + +/** BuyCoresProcessingFixed in the redesign's line */ +export const BuyCoresProcessingRedesign = ({ + state, + draftName, + draftImage, + onCheckAgain, + onRequestClose, +}: { + state: ProcessingState; + draftName: string; + /** The award or post the payment is for, shown with the draft */ + draftImage?: string; + onCheckAgain: () => void; + onRequestClose: () => void; +}): ReactElement => ( + + + + {state === 'processing' ? ( + <> + + Processing your payment, please wait... + + + + ) : ( + <> + + Still processing… + + + Payments usually land in under a minute. {draftName} is saved, and + we pick it up as soon as your Cores arrive. + + {draftImage && ( + + } + title={draftName} + meta="Saved, waiting for your Cores" + /> + )} + + + + )} + + +); diff --git a/packages/storybook/stories/cores-boost/redesign/purchase/kit.tsx b/packages/storybook/stories/cores-boost/redesign/purchase/kit.tsx new file mode 100644 index 00000000000..fc23dc4fa94 --- /dev/null +++ b/packages/storybook/stories/cores-boost/redesign/purchase/kit.tsx @@ -0,0 +1,102 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons/Core'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils'; + +// The redesign's few shared pieces, in the squads hub's line: rounded-16 +// bordered cards with Callout bold titles (SquadWidget), rows like the hero +// cover Reading list widget (title, caption meta, 48px visual on the side). + +/** A SquadWidget-style section: bordered card, Callout bold title */ +export const Card = ({ + title, + action, + children, + className, +}: { + title?: ReactNode; + action?: ReactNode; + children: ReactNode; + className?: string; +}): ReactElement => ( +
    + {(title || action) && ( +
    + + {title} + + {action} +
    + )} + {children} +
    +); + +/** A Reading list style row: title, caption meta, a visual on the side */ +export const Row = ({ + title, + meta, + visual, + trailing, + className, +}: { + title: ReactNode; + meta?: ReactNode; + visual?: ReactNode; + trailing?: ReactNode; + className?: string; +}): ReactElement => ( +
    + {visual &&
    {visual}
    } +
    + + {title} + + {meta && ( + + {meta} + + )} +
    + {trailing} +
    +); + +/** A Core amount with the Core mark, for chips and meta lines */ +export const CoreAmount = ({ + value, + className, +}: { + value: number; + className?: string; +}): ReactElement => ( + + + {formatCoresCurrency(value)} + +); + +/** The glowing Core badge that heads the status screens */ +export const CoreHalo = ({ children }: { children?: ReactNode }) => ( +
    +
    + + {children} +
    +);