+
+
+
+ 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
+
+
+
+);
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
+
+ }
+ onClick={onBoost}
+ >
+ Boost
+
+ }
+ onClick={onClose}
+ />
+
+
+ );
+}
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: (
+
+
+
+
+ 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
+
+
+
+
+ )}
+
+
+ );
+};
+
+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.
+
+
+ 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 => (
+
+
',
+ 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 (
+
+
+
+
+ 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
+
+
+
+ {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 }) => (
+
+
+
+
+);
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 (
+
+
+ {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 => (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/** The success screen, led by the friendship art the award stands for */
+export const AwardSentRedesign = ({
+ product,
+ onRequestClose,
+}: {
+ product: Product;
+ onRequestClose: () => void;
+}): ReactElement => (
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 }) => (
+
+
+ )
+ }
+ />
+
+ 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
+
+
+