diff --git a/packages/storybook/stories/features/giveback/GivebackActionCatalog.stories.tsx b/packages/storybook/stories/features/giveback/GivebackActionCatalog.stories.tsx new file mode 100644 index 00000000000..8a3384a72fd --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackActionCatalog.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackActionCatalog } from '@dailydotdev/shared/src/features/giveback/components/GivebackActionCatalog'; +import { mockActions, withGiveback } from './giveback.mocks'; + +// The "Take action" grid: paid growth actions with category filter chips and a +// "Show more" expand, plus the voluntary "love" actions. Each card opens the +// submission modal. Reads actions + categories from the actions query. +const meta: Meta = { + title: 'Features/Giveback/Action catalog', + component: GivebackActionCatalog, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Filter by category, open a card to launch the submission modal. Shows the full catalog and an empty state.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [withGiveback()], +}; + +export const SingleCategory: Story = { + parameters: { + docs: { description: { story: 'Only content actions — no category chips.' } }, + }, + decorators: [ + withGiveback({ + actions: mockActions().filter((a) => a.categoryId === 'cat-content'), + categories: [{ id: 'cat-content', title: 'Content' }], + }), + ], +}; + +export const Empty: Story = { + decorators: [withGiveback({ actions: [], categories: [] })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx b/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx new file mode 100644 index 00000000000..4c9b28a7191 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackActionSubmissionModal } from '@dailydotdev/shared/src/features/giveback/components/GivebackActionSubmissionModal'; +import type { ContributionAction } from '@dailydotdev/shared/src/features/giveback/types'; +import { withGiveback } from './giveback.mocks'; + +// The proof-submission modal opened from an action card. The form adapts to the +// action's `evidence`: a link field, a screenshot upload, and/or a note. Love +// actions skip the reward and just say thanks. +const meta: Meta = { + title: 'Features/Giveback/Submission modal', + component: GivebackActionSubmissionModal, + args: { onClose: () => undefined }, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'The pop-up that collects proof for an action. Variants show the link-only, screenshot, full (link + screenshot + note), and the love-action (no reward) layouts.', + }, + }, + }, + decorators: [withGiveback()], +}; + +export default meta; + +type Story = StoryObj; + +const makeAction = ( + overrides: Partial, +): ContributionAction => ({ + id: 'a-modal', + categoryId: 'cat-content', + title: 'Post about daily.dev on X', + description: 'A quick post about what you like helps more developers find us.', + points: 120, + evidence: { url: { required: true } }, + metadata: { + platform: 'x', + instructions: + 'Write a short post about what you like in daily.dev.\nInclude a link to daily.dev so people can find it.\nCopy the link to your post and paste it below.', + externalUrl: 'https://x.com/compose/post', + isLoveAction: false, + }, + cooldownSeconds: null, + maxPerUser: null, + userCooldownEndsAt: null, + userCompletions: 0, + latestUserSubmission: null, + ...overrides, +}); + +export const LinkOnly: Story = { + parameters: { + docs: { + description: { + story: + 'Branded action: platform logo, the ask, the reward, a numbered how-to, and a "Go to X" button — then the proof link field.', + }, + }, + }, + args: { action: makeAction({}) }, +}; + +export const Screenshot: Story = { + args: { + action: makeAction({ + title: 'Host a daily.dev meetup', + description: 'Bring developers together in person around daily.dev.', + points: 250, + evidence: { screenshot: { required: true } }, + metadata: { + platform: 'event', + instructions: 'Upload a photo from the meetup.', + externalUrl: null, + isLoveAction: false, + }, + }), + }, +}; + +export const FullProof: Story = { + args: { + action: makeAction({ + title: 'Speak about daily.dev at an event', + points: 200, + evidence: { + url: { required: true }, + screenshot: { required: false }, + note: { required: false }, + }, + }), + }, +}; + +export const LoveAction: Story = { + parameters: { + docs: { description: { story: 'A voluntary thank-you — no reward attached.' } }, + }, + args: { + action: makeAction({ + title: 'Leave us a kind word', + points: 0, + evidence: { note: { required: true } }, + metadata: { + platform: null, + instructions: null, + externalUrl: null, + isLoveAction: true, + }, + }), + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx new file mode 100644 index 00000000000..626fe922a14 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFaq } from '@dailydotdev/shared/src/features/giveback/components/GivebackFaq'; +import { withGiveback } from './giveback.mocks'; + +// The building blocks of the FAQ tab. The campaign's "why" headline now lives in +// the page hero, so this is the FAQ on its own. +const meta: Meta = { + title: 'Features/Giveback/Campaign pieces', + parameters: { layout: 'padded' }, + decorators: [withGiveback({ selectedCauseIds: ['c-oss', 'c-access', 'c-docs'] })], +}; + +export default meta; + +type Story = StoryObj; + +export const Faq: Story = { + render: () => , +}; diff --git a/packages/storybook/stories/features/giveback/GivebackCauseCard.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCauseCard.stories.tsx new file mode 100644 index 00000000000..ad2fb18614a --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCauseCard.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackCauseCard } from '@dailydotdev/shared/src/features/giveback/components/GivebackCauseCard'; +import { mockCauses } from './giveback.mocks'; + +const cause = mockCauses()[0]; + +// The rich, selectable cause card used in the onboarding picker and the "more +// causes to explore" grid: emblem, name, category, full description (clamped), +// a "learn more" link to the cause site, and the select tick. +const meta: Meta = { + title: 'Features/Giveback/Cause card', + component: GivebackCauseCard, + args: { cause, index: 0, onToggle: () => undefined }, + parameters: { layout: 'padded' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Unselected: Story = { args: { selected: false } }; + +export const Selected: Story = { args: { selected: true } }; + +export const Interactive: Story = { + parameters: { + docs: { description: { story: 'Click the card to toggle selection.' } }, + }, + render: (args) => { + const [selected, setSelected] = useState(false); + return ( + setSelected((value) => !value)} + /> + ); + }, +}; + +export const WithoutLinkOrDescription: Story = { + args: { + selected: false, + cause: { ...cause, url: null, description: null }, + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackCauseSelection.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCauseSelection.stories.tsx new file mode 100644 index 00000000000..9a97f144375 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCauseSelection.stories.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackCauseSelection } from '@dailydotdev/shared/src/features/giveback/components/GivebackCauseSelection'; +import { mockCauses, withGiveback } from './giveback.mocks'; + +// The cause picker grid + category filter chips. Prop-driven: pass the causes, +// the selected ids set, and a toggle handler. These stories wire an interactive +// selection so you can click cards and filter. +const meta: Meta = { + title: 'Features/Giveback/Cause selection', + component: GivebackCauseSelection, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Interactive: click cards to select, use the category chips to filter. Covers the loaded grid, a pre-selected state, the loading skeleton, and the empty state.', + }, + }, + }, + decorators: [withGiveback()], +}; + +export default meta; + +type Story = StoryObj; + +const Interactive = ({ + preset = [], + isLoading = false, + empty = false, +}: { + preset?: string[]; + isLoading?: boolean; + empty?: boolean; +}) => { + const [selectedIds, setSelectedIds] = useState>( + () => new Set(preset), + ); + return ( +
+ + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }) + } + /> +
+ ); +}; + +export const Default: Story = { render: () => }; + +export const WithPreselected: Story = { + render: () => , +}; + +export const Loading: Story = { render: () => }; + +export const Empty: Story = { render: () => }; diff --git a/packages/storybook/stories/features/giveback/GivebackCausesPanel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCausesPanel.stories.tsx new file mode 100644 index 00000000000..7565f4c0dd6 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCausesPanel.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackCausesPanel } from '@dailydotdev/shared/src/features/giveback/components/GivebackCausesPanel'; +import { withGiveback } from './giveback.mocks'; + +// The "Causes" management tab: every cause as a pickable card, the ones you +// already back up top, everything else below. Toggle cards to see the "Save +// changes" bar appear (it only shows when the working set differs from what's +// saved). +const meta: Meta = { + title: 'Features/Giveback/Causes tab', + component: GivebackCausesPanel, + parameters: { layout: 'padded' }, +}; + +export default meta; + +type Story = StoryObj; + +export const WithSelection: Story = { + decorators: [ + withGiveback({ selectedCauseIds: ['c-oss', 'c-access', 'c-docs'] }), + ], +}; + +export const NothingPicked: Story = { + parameters: { + docs: { + description: { + story: 'Empty "Your causes" state, with everything available below.', + }, + }, + }, + decorators: [withGiveback({ selectedCauseIds: [] })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackContributionSummary.stories.tsx b/packages/storybook/stories/features/giveback/GivebackContributionSummary.stories.tsx new file mode 100644 index 00000000000..aaca2c60d7a --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackContributionSummary.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackContributionSummary } from '@dailydotdev/shared/src/features/giveback/components/GivebackContributionSummary'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The personal recap above the action catalog: square avatar + level badge, +// amount unlocked for your causes, actions taken, and the next reward. Reads +// your points, reward tiers and completed actions. +const meta: Meta = { + title: 'Features/Giveback/Contribution summary', + component: GivebackContributionSummary, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Personal progress recap. The "to go" figure is green; the avatar is a rounded square with a level badge. Shown at a few point levels.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 320 }) })], +}; + +export const FreshContributor: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 0 }) })], +}; + +export const AllRewardsUnlocked: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 2500 }) })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackFilterChip.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFilterChip.stories.tsx new file mode 100644 index 00000000000..16cdb97081b --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackFilterChip.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFilterChip } from '@dailydotdev/shared/src/features/giveback/components/GivebackFilterChip'; + +// The tag-style category filter used across the cause picker, the Causes tab, +// and the action catalog. Bordered surface by default, brand fill when active. +const meta: Meta = { + title: 'Features/Giveback/Filter chip', + component: GivebackFilterChip, + parameters: { layout: 'padded' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const States: Story = { + render: () => ( +
+ undefined} /> + undefined} + /> +
+ ), +}; + +export const FilterRow: Story = { + render: () => { + const filters = ['All', 'Open source', 'Education', 'Climate', 'Accessibility']; + const [active, setActive] = useState('All'); + return ( +
+ {filters.map((label) => ( + setActive(label)} + /> + ))} +
+ ); + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackFundingSummary.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFundingSummary.stories.tsx new file mode 100644 index 00000000000..fe437b27977 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackFundingSummary.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFundingSummary } from '@dailydotdev/shared/src/features/giveback/components/GivebackFundingSummary'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The hero funding meter: raised vs goal, percent funded, backers — the +// crowdfunding "pledge panel". Drives the cover progress bar. +const meta: Meta = { + title: 'Features/Giveback/Funding summary', + component: GivebackFundingSummary, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Live funding meter shown on the page cover. Seeded from the contribution overview query. Try the states below: partial funding, near-goal, fully funded, empty (nothing pledged yet) and the loading skeleton (no goal configured).', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const PartiallyFunded: Story = { + decorators: [withGiveback({ status: mockStatus() })], +}; + +export const NearGoal: Story = { + decorators: [ + withGiveback({ + status: mockStatus({ currentCyclePoints: 11400, contributorsCount: 2600 }), + }), + ], +}; + +export const FullyFunded: Story = { + decorators: [ + withGiveback({ + status: mockStatus({ + currentCyclePoints: 12000, + contributorsCount: 3120, + }), + }), + ], +}; + +export const Empty: Story = { + parameters: { + docs: { + description: { + story: 'Nothing pledged yet — leads with the goal and a shimmering track.', + }, + }, + }, + decorators: [ + withGiveback({ + status: mockStatus({ currentCyclePoints: 0, contributorsCount: 0 }), + }), + ], +}; + +export const LoadingSkeleton: Story = { + parameters: { + docs: { + description: { + story: 'No goal configured / data still loading — quiet skeleton.', + }, + }, + }, + decorators: [ + withGiveback({ status: mockStatus({ currentCycleTargetPoints: 0 }) }), + ], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx new file mode 100644 index 00000000000..efca3ec30a0 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFunnel } from '@dailydotdev/shared/src/features/giveback/components/GivebackFunnel'; +import { mockCauses, withGiveback } from './giveback.mocks'; + +// The full-screen warm-up funnel shown once before the campaign: intro (with the +// floating explainer video) → how it works → pick causes → impact. `selection` +// is the cause-picker state; here it's an interactive mock so you can toggle +// causes and walk all four steps. Use the toolbar light/dark switch too. +const meta: Meta = { + title: 'Features/Giveback/Funnel', + component: GivebackFunnel, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Forced once for everyone, then replayable from a "How it works" button. The default story is the forced run (no close); the Replayable story shows the dismissible variant. Click through the CTAs to see the staggered step animations and the milestone track.', + }, + }, + }, + decorators: [withGiveback()], +}; + +export default meta; + +type Story = StoryObj; + +const useMockSelection = (preset: string[] = []) => { + const [selectedIds, setSelectedIds] = useState>( + () => new Set(preset), + ); + const toggleCause = (id: string) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + return { + causes: mockCauses(), + isLoading: false, + selectedIds, + selectedCount: selectedIds.size, + hasSavedCauses: false, + isSaving: false, + save: async () => true, + toggleCause, + toggleAndSave: toggleCause, + }; +}; + +export const Forced: Story = { + render: () => { + // Nothing pre-selected — matches a brand-new user picking causes for the + // first time. Click through to the finale to see the value-prop step. + const selection = useMockSelection(); + return undefined} />; + }, +}; + +export const ForcedWithCauses: Story = { + parameters: { + docs: { + description: { + story: + 'Causes already picked, so the finale shows the personalised gratitude line. Walk Got it → Sounds good → Continue → Let’s start.', + }, + }, + }, + render: () => { + const selection = useMockSelection(['c-oss', 'c-scholarships', 'c-access']); + return undefined} />; + }, +}; + +export const Replayable: Story = { + parameters: { + docs: { + description: { + story: + 'Opened from "How it works" — shows the close button so it can be dismissed.', + }, + }, + }, + render: () => { + const selection = useMockSelection(); + return ( + undefined} + onComplete={() => undefined} + /> + ); + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx b/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx new file mode 100644 index 00000000000..638a041f603 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackHero } from '@dailydotdev/shared/src/features/giveback/components/GivebackHero'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The page cover: brand + "How it works" across the top, the "Ad budgets buy +// clicks. Ours funds real causes." headline with the funding meter on the left, +// and charm on the right. +const meta: Meta = { + title: 'Features/Giveback/Page cover (hero)', + component: GivebackHero, + args: { onHowItWorks: () => undefined }, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Headline + funding meter (with milestone markers) on the left, charm on the right. Shown at a few funding levels.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const PartiallyFunded: Story = { + decorators: [withGiveback({ status: mockStatus() })], +}; + +export const Empty: Story = { + decorators: [ + withGiveback({ status: mockStatus({ currentCyclePoints: 0, contributorsCount: 0 }) }), + ], +}; + +export const NearGoal: Story = { + decorators: [ + withGiveback({ status: mockStatus({ currentCyclePoints: 11600 }) }), + ], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackImpactPanel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackImpactPanel.stories.tsx new file mode 100644 index 00000000000..5aadfc1e062 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackImpactPanel.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackImpactPanel } from '@dailydotdev/shared/src/features/giveback/components/GivebackImpactPanel'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The "Impact" tab: the visitor's journey roadmap (the funding-progress section +// was intentionally removed). Wraps GivebackPersonalRoadmap. +const meta: Meta = { + title: 'Features/Giveback/Impact tab', + component: GivebackImpactPanel, + args: { onTakeAction: () => undefined }, + parameters: { layout: 'padded' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 320 }) })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackPersonalRoadmap.stories.tsx b/packages/storybook/stories/features/giveback/GivebackPersonalRoadmap.stories.tsx new file mode 100644 index 00000000000..103a582fb6d --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackPersonalRoadmap.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackPersonalRoadmap } from '@dailydotdev/shared/src/features/giveback/components/GivebackPersonalRoadmap'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The visitor's reward-ladder "journey": a battle-pass rail of every reward +// tier, with the level you're on highlighted, claimed perks marked, the next +// milestone's progress, and the connector track. Driven by your points +// (status.userPoints) and the reward tiers. +const meta: Meta = { + title: 'Features/Giveback/Journey roadmap', + component: GivebackPersonalRoadmap, + args: { onTakeAction: () => undefined }, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Vary your points to move along the ladder. States: early (nothing unlocked), mid-journey with claimable rewards, some already claimed, near the top, and the empty "journey starts soon" state when no tiers exist. In any state with a claimable tier, click "Claim" to see the reward celebration (the ring burst + sparkles + button pop).', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const ClaimAReward: Story = { + parameters: { + docs: { + description: { + story: + 'One tier just unlocked and unclaimed. Click "Claim" to trigger the celebration animation.', + }, + }, + }, + decorators: [withGiveback({ status: mockStatus({ userPoints: 120 }) })], +}; + +export const MidJourney: Story = { + parameters: { + docs: { + description: { + story: '320 pts: two tiers unlocked and claimable, next at 500.', + }, + }, + }, + decorators: [withGiveback({ status: mockStatus({ userPoints: 320 }) })], +}; + +export const SomeClaimed: Story = { + decorators: [ + withGiveback({ + status: mockStatus({ userPoints: 320 }), + claimedRewardIds: ['t-cores'], + }), + ], +}; + +export const EarlyJourney: Story = { + parameters: { + docs: { description: { story: '0 pts: level 1, working toward the first tier.' } }, + }, + decorators: [withGiveback({ status: mockStatus({ userPoints: 0 }) })], +}; + +export const NearTheTop: Story = { + decorators: [ + withGiveback({ + status: mockStatus({ userPoints: 2100 }), + claimedRewardIds: ['t-cores', 't-plus', 't-call'], + }), + ], +}; + +export const EmptyJourney: Story = { + parameters: { + docs: { description: { story: 'No reward tiers configured yet.' } }, + }, + decorators: [ + withGiveback({ status: mockStatus({ userPoints: 0 }), rewardTiers: [] }), + ], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackScreenshotField.stories.tsx b/packages/storybook/stories/features/giveback/GivebackScreenshotField.stories.tsx new file mode 100644 index 00000000000..bf2509a5f34 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackScreenshotField.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackScreenshotField } from '@dailydotdev/shared/src/features/giveback/components/GivebackScreenshotField'; + +// The screenshot uploader used inside the submission modal. These cover the +// three states you hit when adding proof: empty drop zone, mid-upload, and a +// chosen preview ready to clear. +const meta: Meta = { + title: 'Features/Giveback/Screenshot field', + component: GivebackScreenshotField, + args: { + inputId: 'sb-screenshot', + onSelect: () => undefined, + onClear: () => undefined, + }, + parameters: { layout: 'padded' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { previewSrc: undefined, isUploading: false }, +}; + +export const Uploading: Story = { + args: { previewSrc: undefined, isUploading: true }, +}; + +export const WithPreview: Story = { + args: { + isUploading: false, + previewSrc: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackSponsorTiers.stories.tsx b/packages/storybook/stories/features/giveback/GivebackSponsorTiers.stories.tsx new file mode 100644 index 00000000000..a938372debb --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackSponsorTiers.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackSponsorTiers } from '@dailydotdev/shared/src/features/giveback/components/GivebackSponsorTiers'; +import { mockSponsors, withGiveback } from './giveback.mocks'; +import type { ContributionSponsor } from '@dailydotdev/shared/src/features/giveback/types'; +import { ContributionSponsorTier } from '@dailydotdev/shared/src/features/giveback/types'; + +// The "Sponsored by" wall: brand logos grouped into gold / silver / bronze +// columns split by dividers. Logos are monochrome at rest and light up to full +// colour on hover; logo-less sponsors fall back to their name. +const meta: Meta = { + title: 'Features/Giveback/Sponsor tiers', + component: GivebackSponsorTiers, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Hover a logo to reveal its real colours. Covers the full multi-tier wall, a gold-only wall, the logo-less fallback, and the empty state (renders nothing).', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const AllTiers: Story = { + decorators: [withGiveback({ sponsors: mockSponsors() })], +}; + +export const GoldOnly: Story = { + decorators: [ + withGiveback({ + sponsors: mockSponsors().filter( + (s) => s.tier === ContributionSponsorTier.Gold, + ), + }), + ], +}; + +export const LogoLess: Story = { + parameters: { + docs: { description: { story: 'Sponsors without a logo show their name.' } }, + }, + decorators: [ + withGiveback({ + sponsors: [ + { + id: 's-a', + name: 'Acme Corp', + amountCents: 200000, + url: 'https://acme.test', + logoUrl: null, + tier: ContributionSponsorTier.Gold, + }, + { + id: 's-b', + name: 'Dana K.', + amountCents: 5000, + url: null, + logoUrl: null, + tier: ContributionSponsorTier.Bronze, + }, + ] as ContributionSponsor[], + }), + ], +}; + +export const Empty: Story = { + parameters: { + docs: { description: { story: 'No sponsors yet — the section renders nothing.' } }, + }, + decorators: [withGiveback({ sponsors: [] })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackTabNav.stories.tsx b/packages/storybook/stories/features/giveback/GivebackTabNav.stories.tsx new file mode 100644 index 00000000000..dfe058d3748 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackTabNav.stories.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackTabNav } from '@dailydotdev/shared/src/features/giveback/components/GivebackTabNav'; +import type { GivebackTabId } from '@dailydotdev/shared/src/features/giveback/components/GivebackTabNav'; +import { withGiveback } from './giveback.mocks'; + +// The sticky tab navigation that switches between Take action / Impact / +// Campaign. Prop-driven (activeTab + onSelect); this story keeps the active tab +// in local state so the tabs are clickable. +const meta: Meta = { + title: 'Features/Giveback/Tab nav', + component: GivebackTabNav, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Click between the three campaign tabs to see the active state.', + }, + }, + }, + decorators: [withGiveback()], +}; + +export default meta; + +type Story = StoryObj; + +export const Interactive: Story = { + render: () => { + const [activeTab, setActiveTab] = useState('actions'); + return ; + }, +}; diff --git a/packages/storybook/stories/features/giveback/giveback.mocks.tsx b/packages/storybook/stories/features/giveback/giveback.mocks.tsx new file mode 100644 index 00000000000..42658a4582f --- /dev/null +++ b/packages/storybook/stories/features/giveback/giveback.mocks.tsx @@ -0,0 +1,434 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import type { Decorator } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import { generateQueryKey, RequestKey } from '@dailydotdev/shared/src/lib/query'; +import type { + ContributionAction, + ContributionActionCategory, + ContributionCause, + ContributionRewardTier, + ContributionSponsor, + ContributionStatus, +} from '@dailydotdev/shared/src/features/giveback/types'; +import { + ContributionRewardType, + ContributionSponsorTier, + ContributionSubmissionStatus, +} from '@dailydotdev/shared/src/features/giveback/types'; + +// A single signed-in user so every giveback query key resolves to the same id. +export const MOCK_USER = { + id: 'sb-user', + name: 'Dev Dana', + username: 'devdana', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/devdana', + bio: null, + createdAt: '2021-01-01T00:00:00.000Z', + reputation: 42, + providers: ['github'], +} as const; + +const noop = (): void => undefined; + +// --------------------------------------------------------------------------- +// Mock data builders. Every builder takes overrides so a story can tweak one +// field (e.g. userPoints) without restating the whole object. +// --------------------------------------------------------------------------- + +export const mockStatus = ( + overrides: Partial = {}, +): ContributionStatus => ({ + enabled: true, + eligible: true, + currentCyclePoints: 8200, + currentCycleTargetPoints: 12000, + lifetimePoints: 41000, + lifetimeAmountCents: 4100000, + contributorsCount: 1840, + userPoints: 320, + ...overrides, +}); + +export const mockSponsors = (): ContributionSponsor[] => [ + { + id: 's-vercel', + name: 'Vercel', + amountCents: 500000, + url: 'https://vercel.com', + logoUrl: 'https://svgl.app/library/vercel_wordmark.svg', + tier: ContributionSponsorTier.Gold, + }, + { + id: 's-stripe', + name: 'Stripe', + amountCents: 400000, + url: 'https://stripe.com', + logoUrl: 'https://svgl.app/library/stripe_wordmark.svg', + tier: ContributionSponsorTier.Gold, + }, + { + id: 's-sentry', + name: 'Sentry', + amountCents: 250000, + url: 'https://sentry.io', + logoUrl: 'https://svgl.app/library/sentry.svg', + tier: ContributionSponsorTier.Silver, + }, + { + id: 's-prisma', + name: 'Prisma', + amountCents: 120000, + url: 'https://prisma.io', + logoUrl: 'https://svgl.app/library/prisma.svg', + tier: ContributionSponsorTier.Bronze, + }, + { + // Logo-less sponsor: exercises the name-fallback path. + id: 's-dana', + name: 'Dana K.', + amountCents: 5000, + url: null, + logoUrl: null, + tier: ContributionSponsorTier.Bronze, + }, +]; + +export const mockCauses = (): ContributionCause[] => [ + { + id: 'c-oss', + title: 'Open-source maintainers', + description: + 'Keeps the maintainers behind the libraries you ship every day paid, so the tools you rely on stay alive and secure.', + url: 'https://opencollective.com', + category: 'Open source', + logoUrl: null, + }, + { + id: 'c-scholarships', + title: 'Dev scholarships', + description: + 'Puts students from underrepresented groups through the training that lands them their first job in tech.', + url: 'https://www.codeyourfuture.io', + category: 'Education', + logoUrl: null, + }, + { + id: 'c-access', + title: 'Access to tech', + description: + 'Gets laptops and internet to developers who otherwise could not get online to learn, build, and earn.', + url: 'https://www.codepath.org', + category: 'Accessibility', + logoUrl: null, + }, + { + id: 'c-climate', + title: 'Climate tech', + description: + 'Funds open tools that measure, cut, and fight carbon emissions with transparent, auditable data.', + url: 'https://www.climatebase.org', + category: 'Climate', + logoUrl: null, + }, + { + id: 'c-mentorship', + title: 'Mentorship programs', + description: + 'Pairs early-career devs with experienced mentors who help them grow and break into the industry faster.', + url: 'https://adplist.org', + category: 'Education', + logoUrl: null, + }, + { + id: 'c-docs', + title: 'Better docs', + description: + 'Pays technical writers to turn dense open-source docs into guides people can actually learn from.', + url: 'https://www.writethedocs.org', + category: 'Open source', + logoUrl: null, + }, +]; + +export const mockCategories = (): ContributionActionCategory[] => [ + { id: 'cat-events', title: 'Events' }, + { id: 'cat-content', title: 'Content' }, +]; + +const action = (overrides: Partial): ContributionAction => ({ + id: 'a-default', + categoryId: 'cat-content', + title: 'Action', + description: null, + points: 100, + evidence: { url: { required: true }, screenshot: {}, note: {} }, + metadata: { + platform: null, + instructions: null, + externalUrl: null, + isLoveAction: false, + }, + cooldownSeconds: null, + maxPerUser: null, + userCooldownEndsAt: null, + userCompletions: 0, + latestUserSubmission: null, + ...overrides, +}); + +export const mockActions = (): ContributionAction[] => [ + action({ + id: 'a-meetup', + categoryId: 'cat-events', + title: 'Host a daily.dev meetup', + description: 'Organize a local dev meetup that features daily.dev.', + points: 250, + metadata: { + platform: 'Events', + instructions: 'Share photos and the event link.', + externalUrl: null, + isLoveAction: false, + }, + }), + action({ + id: 'a-speak', + categoryId: 'cat-events', + title: 'Speak about daily.dev at an event', + description: 'Give a talk that features daily.dev in your slides or demo.', + points: 200, + }), + action({ + id: 'a-video', + categoryId: 'cat-content', + title: 'Make a video about daily.dev', + description: 'Create a video or short featuring daily.dev and post it.', + points: 150, + metadata: { + platform: 'YouTube', + instructions: null, + externalUrl: null, + isLoveAction: false, + }, + }), + action({ + id: 'a-write', + categoryId: 'cat-content', + title: 'Write about daily.dev', + description: 'Publish an article or blog post featuring daily.dev.', + points: 120, + metadata: { + platform: 'Blog', + instructions: null, + externalUrl: null, + isLoveAction: false, + }, + // An in-review submission so the catalog shows the "in review" state. + userCompletions: 1, + latestUserSubmission: { + id: 'sub-1', + actionId: 'a-write', + status: ContributionSubmissionStatus.Flagged, + awardedPoints: 0, + createdAt: '2026-06-20T10:00:00.000Z', + reviewedAt: null, + }, + }), + action({ + id: 'a-podcast', + categoryId: 'cat-content', + title: 'Talk about daily.dev on a podcast', + description: 'Host or guest on a podcast and mention daily.dev.', + points: 150, + // Approved: shows the "done" state. + userCompletions: 1, + latestUserSubmission: { + id: 'sub-2', + actionId: 'a-podcast', + status: ContributionSubmissionStatus.Approved, + awardedPoints: 150, + createdAt: '2026-06-18T10:00:00.000Z', + reviewedAt: '2026-06-19T10:00:00.000Z', + }, + }), + action({ + id: 'a-love', + categoryId: null, + title: 'Leave us a kind word', + description: 'A voluntary thank-you. No reward attached.', + points: 0, + evidence: { note: { required: true } }, + metadata: { + platform: null, + instructions: null, + externalUrl: null, + isLoveAction: true, + }, + }), +]; + +export const mockRewardTiers = (): ContributionRewardTier[] => [ + { + id: 't-cores', + title: '500 Cores', + description: 'Spend them on the daily.dev store.', + thresholdPoints: 100, + rewardType: ContributionRewardType.Cores, + }, + { + id: 't-plus', + title: '1 month of Plus', + description: 'Unlock the full daily.dev experience.', + thresholdPoints: 250, + rewardType: ContributionRewardType.PlusDays, + }, + { + id: 't-call', + title: 'A call with the team', + description: 'Shape the product with a 1:1.', + thresholdPoints: 500, + rewardType: ContributionRewardType.Call, + }, + { + id: 't-privilege', + title: 'Founding contributor badge', + description: 'A permanent mark on your profile.', + thresholdPoints: 1000, + rewardType: ContributionRewardType.Privilege, + }, + { + id: 't-custom', + title: 'Limited-edition swag', + description: 'The drop only contributors get.', + thresholdPoints: 2000, + rewardType: ContributionRewardType.Custom, + }, +]; + +// --------------------------------------------------------------------------- +// Providers + query-cache seeding. Pass `null` for a slice to leave it unseeded +// (e.g. status: null + goal 0 drives skeletons). Everything is seeded fresh so +// no real network request is ever made. +// --------------------------------------------------------------------------- + +export interface GivebackMockOptions { + status?: ContributionStatus | null; + sponsors?: ContributionSponsor[]; + causes?: ContributionCause[]; + selectedCauseIds?: string[]; + actions?: ContributionAction[]; + categories?: ContributionActionCategory[]; + rewardTiers?: ContributionRewardTier[]; + claimedRewardIds?: string[]; +} + +const GivebackProviders = ({ + children, + status = mockStatus(), + sponsors = mockSponsors(), + causes = mockCauses(), + selectedCauseIds = [], + actions = mockActions(), + categories = mockCategories(), + rewardTiers = mockRewardTiers(), + claimedRewardIds = [], +}: GivebackMockOptions & { children: ReactNode }): ReactElement => { + const queryClient = useMemo(() => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: Infinity, + gcTime: Infinity, + }, + }, + }); + + client.setQueryData( + generateQueryKey(RequestKey.ContributionOverview, MOCK_USER), + { + contributionStatus: status ?? mockStatus({ currentCycleTargetPoints: 0 }), + contributionSponsors: { + pageInfo: { hasNextPage: false, endCursor: null }, + edges: sponsors.map((node) => ({ node })), + }, + }, + ); + + client.setQueryData( + generateQueryKey(RequestKey.ContributionCausePicker, MOCK_USER), + { causes, selectedCauseIds }, + ); + + client.setQueryData( + generateQueryKey(RequestKey.ContributionActions, MOCK_USER), + { actions, categories, rewardTiers, claimedRewardIds }, + ); + + return client; + // Rebuild only when the story's mock inputs change. + }, [ + status, + sponsors, + causes, + selectedCauseIds, + actions, + categories, + rewardTiers, + claimedRewardIds, + ]); + + const LogContext = getLogContextStatic(); + + return ( + + ''} + updateUser={noop as never} + refetchBoot={noop as never} + visit={{ visitId: 'sb', sessionId: 'sb' } as never} + accessToken={null as never} + squads={[]} + feeds={undefined} + geo={{} as never} + isAndroidApp={false} + > + +
+ {children} +
+
+
+
+ ); +}; + +// A decorator factory: `decorators: [withGiveback({ status: ... })]`. +export const withGiveback = + (options: GivebackMockOptions = {}): Decorator => + (Story) => + ( + + + + );