From 59b495f03f722ed49e4acb423cdff8a2bbbbe425 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 3 Jun 2026 13:52:05 +0300 Subject: [PATCH 1/4] feat(giveback): baseline /giveback page behind feature flag Phase 1 of the Giveback initiative: a flag-gated, mock-backed baseline of the /giveback page so design can review the core layout. Purely additive - new route plus a new shared features/giveback folder, touching no existing surfaces. - featureGiveback flag (default on in development only) - Typed model with compliance fields + full status enums (mirrors intended API) - Mock campaign/levels/profile + GivebackContext with dev review controls - Baseline UI: hero, community goal progress, personal roadmap/levels with locked secret-reward tiles - Minimal dev review toggle to preview goal % and level states - RTL specs for baseline render + toggle behavior Co-authored-by: Cursor --- .../src/features/giveback/GivebackContext.tsx | 93 ++++++++++++ .../components/CommunityGoalProgress.tsx | 87 +++++++++++ .../giveback/components/GivebackHero.tsx | 78 ++++++++++ .../giveback/components/GivebackPage.spec.tsx | 44 ++++++ .../giveback/components/GivebackPage.tsx | 22 +++ .../components/GivebackReviewToggle.tsx | 109 ++++++++++++++ .../giveback/components/PersonalRoadmap.tsx | 131 ++++++++++++++++ .../giveback/components/SecretRewardTile.tsx | 64 ++++++++ packages/shared/src/features/giveback/mock.ts | 92 ++++++++++++ .../shared/src/features/giveback/types.ts | 140 ++++++++++++++++++ .../shared/src/features/giveback/utils.ts | 20 +++ packages/shared/src/lib/featureManagement.ts | 2 + packages/webapp/pages/giveback/index.tsx | 56 +++++++ 13 files changed, 938 insertions(+) create mode 100644 packages/shared/src/features/giveback/GivebackContext.tsx create mode 100644 packages/shared/src/features/giveback/components/CommunityGoalProgress.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackHero.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackPage.spec.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackPage.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackReviewToggle.tsx create mode 100644 packages/shared/src/features/giveback/components/PersonalRoadmap.tsx create mode 100644 packages/shared/src/features/giveback/components/SecretRewardTile.tsx create mode 100644 packages/shared/src/features/giveback/mock.ts create mode 100644 packages/shared/src/features/giveback/types.ts create mode 100644 packages/shared/src/features/giveback/utils.ts create mode 100644 packages/webapp/pages/giveback/index.tsx diff --git a/packages/shared/src/features/giveback/GivebackContext.tsx b/packages/shared/src/features/giveback/GivebackContext.tsx new file mode 100644 index 0000000000..b20d3dba3b --- /dev/null +++ b/packages/shared/src/features/giveback/GivebackContext.tsx @@ -0,0 +1,93 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; +import type { + GivebackCampaign, + GivebackLevel, + GivebackUserProfile, +} from './types'; +import { + createMockCampaign, + createMockUserProfile, + givebackLevels, +} from './mock'; + +export interface GivebackContextValue { + campaign: GivebackCampaign; + levels: GivebackLevel[]; + userProfile: GivebackUserProfile; + // Dev review controls (Phase 1). The full QA panel lands in a later phase. + goalPercentage: number; + setGoalPercentage: (percentage: number) => void; + userLevel: number; + setUserLevel: (level: number) => void; +} + +const GivebackContext = createContext( + undefined, +); + +const baseCampaign = createMockCampaign(); +const baseProfile = createMockUserProfile(); + +const DEFAULT_GOAL_PERCENTAGE = Math.round( + (baseCampaign.approvedAmount / baseCampaign.goalAmount) * 100, +); + +interface GivebackProviderProps { + children: ReactNode; +} + +export const GivebackProvider = ({ + children, +}: GivebackProviderProps): ReactElement => { + const [goalPercentage, setGoalPercentage] = useState(DEFAULT_GOAL_PERCENTAGE); + const [userLevel, setUserLevel] = useState(baseProfile.currentLevel); + + const value = useMemo(() => { + const approvedAmount = Math.round( + (baseCampaign.goalAmount * goalPercentage) / 100, + ); + const campaign: GivebackCampaign = { + ...baseCampaign, + approvedAmount, + }; + + const activeLevel = + givebackLevels.find((level) => level.levelNumber === userLevel) ?? + givebackLevels[0]; + + const userProfile: GivebackUserProfile = { + ...baseProfile, + currentLevel: activeLevel.levelNumber, + approvedContributionAmount: activeLevel.requiredApprovedAmount, + }; + + return { + campaign, + levels: givebackLevels, + userProfile, + goalPercentage, + setGoalPercentage, + userLevel, + setUserLevel, + }; + }, [goalPercentage, userLevel]); + + return ( + + {children} + + ); +}; + +export const useGivebackContext = (): GivebackContextValue => { + const context = useContext(GivebackContext); + + if (!context) { + throw new Error( + 'useGivebackContext must be used within a GivebackProvider', + ); + } + + return context; +}; diff --git a/packages/shared/src/features/giveback/components/CommunityGoalProgress.tsx b/packages/shared/src/features/giveback/components/CommunityGoalProgress.tsx new file mode 100644 index 0000000000..b4788f3977 --- /dev/null +++ b/packages/shared/src/features/giveback/components/CommunityGoalProgress.tsx @@ -0,0 +1,87 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { ProgressBar } from '../../../components/fields/ProgressBar'; +import { useGivebackContext } from '../GivebackContext'; +import { formatDonationAmount, getGoalProgressPercentage } from '../utils'; + +export const CommunityGoalProgress = (): ReactElement => { + const { campaign } = useGivebackContext(); + const percentage = getGoalProgressPercentage( + campaign.approvedAmount, + campaign.goalAmount, + ); + + return ( + + + + + Community goal + + + {formatDonationAmount(campaign.approvedAmount, campaign.currency)} + + + + of {formatDonationAmount(campaign.goalAmount, campaign.currency)} + + + + + + + + {Math.round(percentage)}% unlocked + + {campaign.pendingAmount > 0 && ( + + +{formatDonationAmount(campaign.pendingAmount, campaign.currency)}{' '} + pending validation + + )} + + + + + daily.dev funds the donation. Approved actions move the community closer + to the goal. + + + ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx new file mode 100644 index 0000000000..29998b7c7d --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -0,0 +1,78 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { DailyIcon, StarIcon } from '../../../components/icons'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { + Button, + ButtonVariant, + ButtonSize, +} from '../../../components/buttons/Button'; +import { useGivebackContext } from '../GivebackContext'; + +export const GivebackHero = (): ReactElement => { + const { campaign } = useGivebackContext(); + + return ( + +
+
+ + + + daily.dev Giveback + + + + Help us grow. We'll fund good causes. + + + {campaign.heroCopy} + + + Complete community actions to help unlock donation value. When the + community hits the goal, daily.dev donates to causes you choose. + + + + ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackPage.spec.tsx b/packages/shared/src/features/giveback/components/GivebackPage.spec.tsx new file mode 100644 index 0000000000..2dd4fedf83 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackPage.spec.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GivebackPage } from './GivebackPage'; +import { GivebackProvider } from '../GivebackContext'; +import { CommunityGoalProgress } from './CommunityGoalProgress'; +import { GivebackReviewToggle } from './GivebackReviewToggle'; + +describe('GivebackPage', () => { + it('renders the baseline sections: hero, community goal, and roadmap', () => { + render(); + + expect( + screen.getByText('Daily Dev funds the donation. You help unlock it.'), + ).toBeInTheDocument(); + expect(screen.getByText('Community goal')).toBeInTheDocument(); + expect(screen.getByText('Your roadmap')).toBeInTheDocument(); + }); + + it('shows the mocked goal progress at 50%', () => { + render(); + + expect(screen.getByText('$5,000')).toBeInTheDocument(); + expect(screen.getByText('of $10,000')).toBeInTheDocument(); + expect(screen.getByText('50% unlocked')).toBeInTheDocument(); + }); +}); + +describe('GivebackReviewToggle', () => { + it('updates the community goal progress when a preset is selected', async () => { + render( + + + + , + ); + + expect(screen.getByText('50% unlocked')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: '100%' })); + + expect(screen.getByText('100% unlocked')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx new file mode 100644 index 0000000000..749347076d --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -0,0 +1,22 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { FlexCol } from '../../../components/utilities'; +import { isDevelopment } from '../../../lib/constants'; +import { GivebackProvider } from '../GivebackContext'; +import { GivebackHero } from './GivebackHero'; +import { CommunityGoalProgress } from './CommunityGoalProgress'; +import { PersonalRoadmap } from './PersonalRoadmap'; +import { GivebackReviewToggle } from './GivebackReviewToggle'; + +export const GivebackPage = (): ReactElement => { + return ( + + + + + + + {isDevelopment && } + + ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackReviewToggle.tsx b/packages/shared/src/features/giveback/components/GivebackReviewToggle.tsx new file mode 100644 index 0000000000..8d8c3d51ef --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackReviewToggle.tsx @@ -0,0 +1,109 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { useGivebackContext } from '../GivebackContext'; + +const GOAL_PRESETS = [0, 25, 50, 99, 100]; +const LEVEL_PRESETS = [1, 2, 3, 4, 5]; + +// Minimal Phase 1 review control so design can preview goal % and level states. +// The full QA/dev testing panel (all action/cause/geo/reward states) lands later. +export const GivebackReviewToggle = (): ReactElement => { + const [isOpen, setIsOpen] = useState(true); + const { goalPercentage, setGoalPercentage, userLevel, setUserLevel } = + useGivebackContext(); + + return ( + + + + Giveback preview + + + + + {isOpen && ( + <> + + + Community goal + + + {GOAL_PRESETS.map((preset) => ( + + ))} + + + + + + Your level + + + {LEVEL_PRESETS.map((preset) => ( + + ))} + + + + )} + + ); +}; diff --git a/packages/shared/src/features/giveback/components/PersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/PersonalRoadmap.tsx new file mode 100644 index 0000000000..6152b96541 --- /dev/null +++ b/packages/shared/src/features/giveback/components/PersonalRoadmap.tsx @@ -0,0 +1,131 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { QuestLevelProgressCircle } from '../../../components/quest/QuestLevelProgressCircle'; +import { ProgressBar } from '../../../components/fields/ProgressBar'; +import { useGivebackContext } from '../GivebackContext'; +import { formatDonationAmount } from '../utils'; +import { SecretRewardTile } from './SecretRewardTile'; + +export const PersonalRoadmap = (): ReactElement => { + const { levels, userProfile, campaign } = useGivebackContext(); + const approved = userProfile.approvedContributionAmount; + + const currentLevel = + levels.find((level) => level.levelNumber === userProfile.currentLevel) ?? + levels[0]; + const nextLevel = levels.find( + (level) => level.requiredApprovedAmount > approved, + ); + + const segmentBase = currentLevel.requiredApprovedAmount; + const segmentTarget = nextLevel?.requiredApprovedAmount ?? segmentBase; + const segmentSpan = segmentTarget - segmentBase; + const segmentProgress = + segmentSpan > 0 + ? Math.max( + 0, + Math.min(100, ((approved - segmentBase) / segmentSpan) * 100), + ) + : 100; + const amountToNext = nextLevel + ? Math.max(0, nextLevel.requiredApprovedAmount - approved) + : 0; + + return ( + + + + Your roadmap + + + {nextLevel + ? `You're ${formatDonationAmount( + amountToNext, + campaign.currency, + )} away from Level ${nextLevel.levelNumber} · ${nextLevel.name}` + : "You've reached the top level. Legend!"} + + + + {nextLevel && ( + + )} + + + {levels.map((level) => { + const isReached = approved >= level.requiredApprovedAmount; + const isCurrent = level.levelNumber === userProfile.currentLevel; + + return ( + + + + {level.name} + + + {level.requiredApprovedAmount === 0 + ? 'Start here' + : formatDonationAmount( + level.requiredApprovedAmount, + campaign.currency, + )} + + {level.reward && ( + + )} + + ); + })} + + + ); +}; diff --git a/packages/shared/src/features/giveback/components/SecretRewardTile.tsx b/packages/shared/src/features/giveback/components/SecretRewardTile.tsx new file mode 100644 index 0000000000..286db63bc5 --- /dev/null +++ b/packages/shared/src/features/giveback/components/SecretRewardTile.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { FlexCol } from '../../../components/utilities'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { LockIcon, GiftIcon } from '../../../components/icons'; +import type { GivebackReward } from '../types'; + +interface SecretRewardTileProps { + reward: GivebackReward; + /** Whether the user has reached the level that holds this reward. */ + isUnlocked: boolean; +} + +export const SecretRewardTile = ({ + reward, + isUnlocked, +}: SecretRewardTileProps): ReactElement => { + const showsMystery = reward.isSecret && !isUnlocked; + + return ( + + + {showsMystery ? : } + + + {showsMystery ? reward.secretTitle : reward.title} + + {!showsMystery && reward.description && ( + + {reward.description} + + )} + + ); +}; diff --git a/packages/shared/src/features/giveback/mock.ts b/packages/shared/src/features/giveback/mock.ts new file mode 100644 index 0000000000..d79f3d43ba --- /dev/null +++ b/packages/shared/src/features/giveback/mock.ts @@ -0,0 +1,92 @@ +import type { + GivebackCampaign, + GivebackLevel, + GivebackUserProfile, +} from './types'; +import { + GivebackCampaignStatus, + GivebackRewardStatus, + GivebackRewardType, +} from './types'; + +// Phase 1 mock data. This is the single source of truth the baseline page renders +// from, and the dev review toggle drives it. Replaced by live queries in a later phase. + +export const GIVEBACK_CURRENCY = 'USD'; + +export const givebackLevels: GivebackLevel[] = [ + { + id: 'level-1', + levelNumber: 1, + name: 'First spark', + requiredApprovedAmount: 0, + }, + { + id: 'level-2', + levelNumber: 2, + name: 'Helping hand', + requiredApprovedAmount: 50, + }, + { + id: 'level-3', + levelNumber: 3, + name: 'Changemaker', + requiredApprovedAmount: 150, + reward: { + id: 'reward-plus', + type: GivebackRewardType.DailyPlus, + title: '1 month of daily.dev Plus', + secretTitle: 'Mystery reward', + description: 'A little thank-you for moving the community forward.', + status: GivebackRewardStatus.Locked, + isSecret: true, + }, + }, + { + id: 'level-4', + levelNumber: 4, + name: 'Community pillar', + requiredApprovedAmount: 300, + }, + { + id: 'level-5', + levelNumber: 5, + name: 'Legend', + requiredApprovedAmount: 500, + reward: { + id: 'reward-legend', + type: GivebackRewardType.SwagCoupon, + title: 'daily.dev swag coupon', + secretTitle: 'Mystery reward', + description: 'For the ones who go all in.', + status: GivebackRewardStatus.Locked, + isSecret: true, + }, + }, +]; + +export const createMockCampaign = ( + overrides: Partial = {}, +): GivebackCampaign => ({ + id: 'campaign-2026', + name: 'Giveback', + slug: 'giveback', + status: GivebackCampaignStatus.Active, + goalAmount: 10000, + currency: GIVEBACK_CURRENCY, + approvedAmount: 5000, + pendingAmount: 450, + heroCopy: 'Daily Dev funds the donation. You help unlock it.', + ...overrides, +}); + +export const createMockUserProfile = ( + overrides: Partial = {}, +): GivebackUserProfile => ({ + currentLevel: 2, + approvedContributionAmount: 85, + pendingContributionAmount: 25, + actionsCompletedCount: 4, + selectedCauseIds: [], + ...overrides, +}); diff --git a/packages/shared/src/features/giveback/types.ts b/packages/shared/src/features/giveback/types.ts new file mode 100644 index 0000000000..00466fd8c2 --- /dev/null +++ b/packages/shared/src/features/giveback/types.ts @@ -0,0 +1,140 @@ +// Type model for the Giveback initiative. Shapes are intentionally aligned with +// the intended backend GraphQL contract so the Phase 1 mock layer can be swapped +// for live queries with minimal churn. See the master plan for details. + +export enum GivebackCampaignStatus { + Draft = 'draft', + Scheduled = 'scheduled', + Active = 'active', + Paused = 'paused', + Ended = 'ended', +} + +export enum GivebackActionValidationType { + Automatic = 'automatic', + Manual = 'manual', + Hybrid = 'hybrid', + None = 'none', +} + +export enum GivebackUserActionStatus { + NotStarted = 'not_started', + Started = 'started', + Submitted = 'submitted', + PendingReview = 'pending_review', + AutoValidating = 'auto_validating', + Approved = 'approved', + Rejected = 'rejected', + Expired = 'expired', + NeedsMoreInfo = 'needs_more_info', + CountedTowardGoal = 'counted_toward_goal', +} + +export enum GivebackRewardStatus { + Locked = 'locked', + Unlocked = 'unlocked', + Revealed = 'revealed', + Claimed = 'claimed', + Expired = 'expired', +} + +export enum GivebackRewardType { + DailyPlus = 'daily_plus', + Cores = 'cores', + SwagCoupon = 'swag_coupon', + Badge = 'badge', + Other = 'other', +} + +export enum GivebackCauseStatus { + Active = 'active', + Inactive = 'inactive', + PendingReview = 'pending_review', + Approved = 'approved', + Rejected = 'rejected', + Archived = 'archived', +} + +export interface GivebackCampaign { + id: string; + name: string; + slug: string; + status: GivebackCampaignStatus; + goalAmount: number; + currency: string; + /** Donation amount that has been validated and is included in donation reporting. */ + approvedAmount: number; + /** Donation amount awaiting validation. Shown honestly as pending, never as counted. */ + pendingAmount: number; + heroCopy?: string; + startAt?: string; + endAt?: string; +} + +export interface GivebackReward { + id: string; + type: GivebackRewardType; + title: string; + /** Teaser shown while the reward is still a mystery. */ + secretTitle: string; + description?: string; + status: GivebackRewardStatus; + isSecret: boolean; + expiresAt?: string; +} + +export interface GivebackLevel { + id: string; + levelNumber: number; + name: string; + /** Approved contribution required to reach this level. */ + requiredApprovedAmount: number; + requiredActionCount?: number; + reward?: GivebackReward; +} + +export interface GivebackUserProfile { + currentLevel: number; + approvedContributionAmount: number; + pendingContributionAmount: number; + actionsCompletedCount: number; + selectedCauseIds: string[]; +} + +// Shells for later phases (catalog, causes). Defined now so the type model and +// compliance guarantees exist from the start, even though the UI ships later. + +export interface GivebackAction { + id: string; + title: string; + description?: string; + category: string; + personaTags: string[]; + donationAmount: number; + currency: string; + validationType: GivebackActionValidationType; + requiresLink: boolean; + requiresImage: boolean; + requiresNote: boolean; + /** Whether this action can grant a reward. Always false for Love actions. */ + isRewarded: boolean; + /** Whether this action can unlock donation value. Always false for Love actions. */ + donationEligible: boolean; + /** Review/rating style actions that a third-party platform forbids incentivizing. */ + isComplianceSensitive: boolean; + /** Voluntary "for love" action with zero donation/reward by design. */ + isLoveAction: boolean; + externalUrl?: string; + instructions?: string; +} + +export interface GivebackCause { + id: string; + name: string; + description: string; + url: string; + logoUrl?: string; + category?: string; + status: GivebackCauseStatus; + sortOrder: number; +} diff --git a/packages/shared/src/features/giveback/utils.ts b/packages/shared/src/features/giveback/utils.ts new file mode 100644 index 0000000000..0f04e3a4bc --- /dev/null +++ b/packages/shared/src/features/giveback/utils.ts @@ -0,0 +1,20 @@ +export const formatDonationAmount = ( + amount: number, + currency = 'USD', +): string => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + maximumFractionDigits: 0, + }).format(amount); + +export const getGoalProgressPercentage = ( + approvedAmount: number, + goalAmount: number, +): number => { + if (!goalAmount) { + return 0; + } + + return Math.max(0, Math.min(100, (approvedAmount / goalAmount) * 100)); +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index e34256aeed..944af66080 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -179,6 +179,8 @@ export const featureReaderModal = new Feature('reader_modal_v2', false); export const featureShortcutsHub = new Feature('shortcuts_hub', false); +export const featureGiveback = new Feature('giveback', isDevelopment); + export const featureNewTabCustomizer = new Feature( 'extension_newtab_customizer', false, diff --git a/packages/webapp/pages/giveback/index.tsx b/packages/webapp/pages/giveback/index.tsx new file mode 100644 index 0000000000..afb2ad3b66 --- /dev/null +++ b/packages/webapp/pages/giveback/index.tsx @@ -0,0 +1,56 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import type { NextSeoProps } from 'next-seo'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks'; +import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { GivebackPage } from '@dailydotdev/shared/src/features/giveback/components/GivebackPage'; +import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../../components/layouts/MainLayout'; +import { defaultOpenGraph, defaultSeo } from '../../next-seo'; +import { getPageSeoTitles } from '../../components/layouts/utils'; + +const seoTitles = getPageSeoTitles('Giveback'); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { + ...defaultOpenGraph, + ...seoTitles.openGraph, + }, + ...defaultSeo, + description: + 'Help daily.dev grow and we will fund good causes. Complete community actions to help unlock donations toward a shared goal.', + nofollow: true, + noindex: true, +}; + +const GivebackRoute = (): ReactElement | null => { + const router = useRouter(); + const { isAuthReady } = useAuthContext(); + const { value: isEnabled, isLoading } = useConditionalFeature({ + feature: featureGiveback, + shouldEvaluate: isAuthReady, + }); + + useEffect(() => { + if (!isLoading && !isEnabled) { + router.replace(webappUrl); + } + }, [isLoading, isEnabled, router]); + + if (isLoading || !isEnabled) { + return null; + } + + return ; +}; + +const getGivebackLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +GivebackRoute.getLayout = getGivebackLayout; +GivebackRoute.layoutProps = { screenCentered: false, seo }; + +export default GivebackRoute; From d0997fb8ee3e4e54f9bfb34eba84f2d1d1dd2111 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 3 Jun 2026 23:28:08 +0300 Subject: [PATCH 2/4] fix: render Giveback page without GrowthBook readiness gate Default the giveback flag to true and render the route based on the flag value instead of blocking on useConditionalFeature loading, so the page is reviewable in preview/staging where encrypted features can't be decrypted locally. Co-authored-by: Cursor --- packages/shared/src/lib/featureManagement.ts | 2 +- packages/webapp/pages/giveback/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 944af66080..f495b3188e 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -179,7 +179,7 @@ export const featureReaderModal = new Feature('reader_modal_v2', false); export const featureShortcutsHub = new Feature('shortcuts_hub', false); -export const featureGiveback = new Feature('giveback', isDevelopment); +export const featureGiveback = new Feature('giveback', true); export const featureNewTabCustomizer = new Feature( 'extension_newtab_customizer', diff --git a/packages/webapp/pages/giveback/index.tsx b/packages/webapp/pages/giveback/index.tsx index afb2ad3b66..0b46171a0a 100644 --- a/packages/webapp/pages/giveback/index.tsx +++ b/packages/webapp/pages/giveback/index.tsx @@ -40,7 +40,7 @@ const GivebackRoute = (): ReactElement | null => { } }, [isLoading, isEnabled, router]); - if (isLoading || !isEnabled) { + if (!isEnabled) { return null; } From 255a6993ff2770a9ee076b86d8d3bc7be10d1c18 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 3 Jun 2026 23:40:09 +0300 Subject: [PATCH 3/4] chore(giveback): add Storybook story for GivebackPage Provides a backend-free way to review the Giveback mock UI and its states via Storybook, independent of app boot/GrowthBook. Co-authored-by: Cursor --- .../features/giveback/GivebackPage.stories.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/storybook/stories/features/giveback/GivebackPage.stories.tsx diff --git a/packages/storybook/stories/features/giveback/GivebackPage.stories.tsx b/packages/storybook/stories/features/giveback/GivebackPage.stories.tsx new file mode 100644 index 0000000000..b10bce4890 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackPage.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackPage } from '@dailydotdev/shared/src/features/giveback/components/GivebackPage'; + +const meta: Meta = { + title: 'Features/Giveback/GivebackPage', + component: GivebackPage, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; From 552d0aed897c587edfdea3900df67b3c06fe93e7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 4 Jun 2026 15:14:18 +0300 Subject: [PATCH 4/4] feat(giveback): polish campaign UX, restructure tabs, personal progress bar - Kickstarter-style hero: funding summary + "Join the campaign" CTA - Restructure tabs: Causes (with why intro), Impact (with rewards roadmap), Why (stretch goals + impact), Take action, Updates, Comments, FAQ - Collapse sections to single titles; homepage-style tab bar (no blur) - Fix brand-color contrast by switching saturated *-subtlest fills to *-flat - Casino polish: meter shine, coin stream, reward pop, live activity dot - Sticky bar now shows level/contribution with an action-focused CTA - Budget-driven campaign: remove deadline/days-to-go Co-authored-by: Cursor --- .../src/features/giveback/GivebackContext.tsx | 278 ++++++++- .../features/giveback/GivebackNavContext.tsx | 50 ++ .../src/features/giveback/actionPlatform.ts | 40 ++ .../giveback/components/ActionCard.tsx | 159 +++++ .../giveback/components/ActionCatalog.tsx | 255 ++++++++ .../components/BudgetRedirectStory.tsx | 48 ++ .../giveback/components/CauseSelection.tsx | 204 ++++++ .../components/CauseSelectionModal.tsx | 171 ++++++ .../components/CommunityGoalProgress.tsx | 153 +++-- .../components/CommunityImpactSection.tsx | 283 +++++++++ .../giveback/components/GeoGateFallback.tsx | 69 +++ .../components/GivebackCampaignVideo.tsx | 58 ++ .../components/GivebackClosingCta.tsx | 82 +++ .../giveback/components/GivebackComments.tsx | 174 ++++++ .../giveback/components/GivebackFaq.tsx | 113 ++++ .../components/GivebackFundingBar.tsx | 111 ++++ .../components/GivebackFundingSummary.tsx | 76 +++ .../giveback/components/GivebackHero.tsx | 113 ++-- .../components/GivebackMeterShine.tsx | 28 + .../giveback/components/GivebackPage.spec.tsx | 289 ++++++++- .../giveback/components/GivebackPage.tsx | 226 ++++++- .../components/GivebackParticipateStrip.tsx | 94 +++ .../giveback/components/GivebackReveal.tsx | 36 ++ .../components/GivebackReviewToggle.tsx | 150 ++++- .../giveback/components/GivebackSection.tsx | 77 +++ .../components/GivebackSocialProof.tsx | 136 ++++ .../components/GivebackStartPanel.tsx | 54 ++ .../components/GivebackStretchGoals.tsx | 161 +++++ .../components/GivebackSubmissionModal.tsx | 258 ++++++++ .../components/GivebackTrustPerks.tsx | 41 ++ .../giveback/components/GivebackUpdates.tsx | 111 ++++ .../giveback/components/PersonalRoadmap.tsx | 503 ++++++++++++--- .../giveback/components/SecretRewardTile.tsx | 64 -- packages/shared/src/features/giveback/mock.ts | 580 +++++++++++++++++- .../src/features/giveback/statusLabels.ts | 86 +++ .../shared/src/features/giveback/types.ts | 108 +++- .../features/giveback/useGivebackMotion.ts | 131 ++++ .../shared/src/features/giveback/utils.ts | 3 + packages/shared/tailwind.config.ts | 23 + .../giveback/GivebackPage.stories.tsx | 11 + 40 files changed, 5329 insertions(+), 278 deletions(-) create mode 100644 packages/shared/src/features/giveback/GivebackNavContext.tsx create mode 100644 packages/shared/src/features/giveback/actionPlatform.ts create mode 100644 packages/shared/src/features/giveback/components/ActionCard.tsx create mode 100644 packages/shared/src/features/giveback/components/ActionCatalog.tsx create mode 100644 packages/shared/src/features/giveback/components/BudgetRedirectStory.tsx create mode 100644 packages/shared/src/features/giveback/components/CauseSelection.tsx create mode 100644 packages/shared/src/features/giveback/components/CauseSelectionModal.tsx create mode 100644 packages/shared/src/features/giveback/components/CommunityImpactSection.tsx create mode 100644 packages/shared/src/features/giveback/components/GeoGateFallback.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackCampaignVideo.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackClosingCta.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackComments.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackFaq.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackFundingBar.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackMeterShine.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackParticipateStrip.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackReveal.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackSection.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackSocialProof.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackStartPanel.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackStretchGoals.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackSubmissionModal.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackTrustPerks.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackUpdates.tsx delete mode 100644 packages/shared/src/features/giveback/components/SecretRewardTile.tsx create mode 100644 packages/shared/src/features/giveback/statusLabels.ts create mode 100644 packages/shared/src/features/giveback/useGivebackMotion.ts diff --git a/packages/shared/src/features/giveback/GivebackContext.tsx b/packages/shared/src/features/giveback/GivebackContext.tsx index b20d3dba3b..0cecc3cf2c 100644 --- a/packages/shared/src/features/giveback/GivebackContext.tsx +++ b/packages/shared/src/features/giveback/GivebackContext.tsx @@ -1,20 +1,60 @@ import type { ReactElement, ReactNode } from 'react'; import React, { createContext, useContext, useMemo, useState } from 'react'; import type { + GivebackAction, + GivebackActionCategoryFilter, + GivebackActionSubmissionInput, GivebackCampaign, + GivebackCause, + GivebackCauseSuggestionInput, + GivebackCommunityEvent, + GivebackDonationAccounting, GivebackLevel, + GivebackUserAction, GivebackUserProfile, } from './types'; +import { + GivebackActionValidationType, + GivebackCauseStatus, + GivebackUserActionStatus, +} from './types'; import { createMockCampaign, createMockUserProfile, + givebackActions, + givebackCauses, + givebackCommunityEvents, givebackLevels, + givebackUserActions, } from './mock'; export interface GivebackContextValue { campaign: GivebackCampaign; levels: GivebackLevel[]; userProfile: GivebackUserProfile; + actions: GivebackAction[]; + filteredActions: GivebackAction[]; + loveActions: GivebackAction[]; + userActions: GivebackUserAction[]; + causes: GivebackCause[]; + suggestedCauses: GivebackCause[]; + communityEvents: GivebackCommunityEvent[]; + donationAccounting: GivebackDonationAccounting; + submitAction: (input: GivebackActionSubmissionInput) => void; + toggleCause: (causeId: string) => void; + suggestCause: (input: GivebackCauseSuggestionInput) => void; + setUserActionStatus: ( + actionId: string, + status: GivebackUserActionStatus, + ) => void; + showCommunityFeed: boolean; + setShowCommunityFeed: (value: boolean) => void; + geoAvailability: 'available' | 'waitlist'; + setGeoAvailability: (value: 'available' | 'waitlist') => void; + celebrationState: 'none' | 'milestone' | 'complete'; + setCelebrationState: (value: 'none' | 'milestone' | 'complete') => void; + selectedCategory: GivebackActionCategoryFilter; + setSelectedCategory: (category: GivebackActionCategoryFilter) => void; // Dev review controls (Phase 1). The full QA panel lands in a later phase. goalPercentage: number; setGoalPercentage: (percentage: number) => void; @@ -28,6 +68,9 @@ const GivebackContext = createContext( const baseCampaign = createMockCampaign(); const baseProfile = createMockUserProfile(); +const ACTIVE_CAUSES = givebackCauses.filter( + ({ status }) => status === GivebackCauseStatus.Active, +); const DEFAULT_GOAL_PERCENTAGE = Math.round( (baseCampaign.approvedAmount / baseCampaign.goalAmount) * 100, @@ -42,8 +85,200 @@ export const GivebackProvider = ({ }: GivebackProviderProps): ReactElement => { const [goalPercentage, setGoalPercentage] = useState(DEFAULT_GOAL_PERCENTAGE); const [userLevel, setUserLevel] = useState(baseProfile.currentLevel); + const [selectedCategory, setSelectedCategory] = + useState('all'); + const [userActions, setUserActions] = + useState(givebackUserActions); + const [selectedCauseIds, setSelectedCauseIds] = useState( + baseProfile.selectedCauseIds, + ); + const [suggestedCauses, setSuggestedCauses] = useState([]); + const [showCommunityFeed, setShowCommunityFeed] = useState(true); + const [geoAvailability, setGeoAvailability] = useState< + 'available' | 'waitlist' + >('available'); + const [celebrationState, setCelebrationState] = useState< + 'none' | 'milestone' | 'complete' + >('none'); + + const submitAction = ({ + actionId, + evidenceLink, + evidenceImage, + note, + }: GivebackActionSubmissionInput): void => { + const action = givebackActions.find(({ id }) => id === actionId); + + if (!action) { + throw new Error(`Giveback action ${actionId} does not exist`); + } + + if (action.isLoveAction || !action.donationEligible) { + throw new Error('Love actions cannot unlock donation value'); + } + + const status = + action.validationType === GivebackActionValidationType.Automatic + ? GivebackUserActionStatus.AutoValidating + : GivebackUserActionStatus.PendingReview; + + setUserActions((currentActions) => { + const nextAction: GivebackUserAction = { + actionId, + status, + unlockedDonationAmount: action.donationAmount, + pendingDonationAmount: action.donationAmount, + approvedDonationAmount: 0, + rejectedDonationAmount: 0, + evidenceLink, + evidenceImage, + note, + submittedAt: new Date().toISOString(), + }; + const existingIndex = currentActions.findIndex( + (userAction) => userAction.actionId === actionId, + ); + + if (existingIndex === -1) { + return [...currentActions, nextAction]; + } + + return currentActions.map((userAction, index) => + index === existingIndex ? nextAction : userAction, + ); + }); + }; + + const toggleCause = (causeId: string): void => { + setSelectedCauseIds((current) => { + if (current.includes(causeId)) { + return current.filter((id) => id !== causeId); + } + + return [...current, causeId]; + }); + }; + + const suggestCause = ({ + name, + url, + note, + category, + }: GivebackCauseSuggestionInput): void => { + const trimmedName = name.trim(); + const trimmedUrl = url.trim(); + + if (!trimmedName || !trimmedUrl) { + return; + } + + setSuggestedCauses((current) => [ + { + id: `suggested-${Date.now().toString()}`, + name: trimmedName, + description: + note?.trim() || 'Suggested by the community for future review.', + url: trimmedUrl, + category: category?.trim() || 'Community suggestion', + status: GivebackCauseStatus.PendingReview, + sortOrder: ACTIVE_CAUSES.length + current.length + 1, + }, + ...current, + ]); + }; + + const setUserActionStatus = ( + actionId: string, + status: GivebackUserActionStatus, + ): void => { + const action = givebackActions.find(({ id }) => id === actionId); + + if (!action) { + throw new Error(`Giveback action ${actionId} does not exist`); + } + + setUserActions((currentActions) => { + const existingAction = currentActions.find( + (userAction) => userAction.actionId === actionId, + ); + const nextAction: GivebackUserAction = { + actionId, + status, + unlockedDonationAmount: + status === GivebackUserActionStatus.NotStarted + ? 0 + : action.donationAmount, + pendingDonationAmount: [ + GivebackUserActionStatus.Submitted, + GivebackUserActionStatus.PendingReview, + GivebackUserActionStatus.AutoValidating, + ].includes(status) + ? action.donationAmount + : 0, + approvedDonationAmount: [ + GivebackUserActionStatus.Approved, + GivebackUserActionStatus.CountedTowardGoal, + ].includes(status) + ? action.donationAmount + : 0, + rejectedDonationAmount: + status === GivebackUserActionStatus.Rejected + ? action.donationAmount + : 0, + submittedAt: + existingAction?.submittedAt ?? + (status === GivebackUserActionStatus.NotStarted + ? undefined + : new Date().toISOString()), + reviewedAt: [ + GivebackUserActionStatus.Approved, + GivebackUserActionStatus.CountedTowardGoal, + GivebackUserActionStatus.Rejected, + ].includes(status) + ? new Date().toISOString() + : undefined, + rejectionReason: + status === GivebackUserActionStatus.Rejected + ? 'Simulated rejection from the QA panel.' + : undefined, + needsMoreInfoReason: + status === GivebackUserActionStatus.NeedsMoreInfo + ? 'Simulated request for more proof from the QA panel.' + : undefined, + }; + const existingIndex = currentActions.findIndex( + (userAction) => userAction.actionId === actionId, + ); + + if (existingIndex === -1) { + return [...currentActions, nextAction]; + } + + return currentActions.map((userAction, index) => + index === existingIndex ? nextAction : userAction, + ); + }); + }; const value = useMemo(() => { + const donationAccounting = userActions.reduce( + (sum, userAction) => ({ + unlockedDonationAmount: + sum.unlockedDonationAmount + userAction.unlockedDonationAmount, + pendingDonationAmount: + sum.pendingDonationAmount + userAction.pendingDonationAmount, + approvedDonationAmount: + sum.approvedDonationAmount + userAction.approvedDonationAmount, + rejectedDonationAmount: + sum.rejectedDonationAmount + userAction.rejectedDonationAmount, + }), + { + unlockedDonationAmount: 0, + pendingDonationAmount: 0, + approvedDonationAmount: 0, + rejectedDonationAmount: 0, + }, + ); const approvedAmount = Math.round( (baseCampaign.goalAmount * goalPercentage) / 100, ); @@ -60,18 +295,59 @@ export const GivebackProvider = ({ ...baseProfile, currentLevel: activeLevel.levelNumber, approvedContributionAmount: activeLevel.requiredApprovedAmount, + selectedCauseIds, }; + const donationActions = givebackActions.filter( + (action) => !action.isLoveAction, + ); + const loveActions = givebackActions.filter((action) => action.isLoveAction); + + const filteredActions = donationActions.filter( + (action) => + selectedCategory === 'all' || action.category === selectedCategory, + ); + return { campaign, levels: givebackLevels, userProfile, + actions: donationActions, + filteredActions, + loveActions, + userActions, + causes: ACTIVE_CAUSES, + suggestedCauses, + communityEvents: showCommunityFeed ? givebackCommunityEvents : [], + donationAccounting, + submitAction, + toggleCause, + suggestCause, + setUserActionStatus, + showCommunityFeed, + setShowCommunityFeed, + geoAvailability, + setGeoAvailability, + celebrationState, + setCelebrationState, + selectedCategory, + setSelectedCategory, goalPercentage, setGoalPercentage, userLevel, setUserLevel, }; - }, [goalPercentage, userLevel]); + }, [ + goalPercentage, + celebrationState, + geoAvailability, + selectedCategory, + selectedCauseIds, + showCommunityFeed, + suggestedCauses, + userActions, + userLevel, + ]); return ( diff --git a/packages/shared/src/features/giveback/GivebackNavContext.tsx b/packages/shared/src/features/giveback/GivebackNavContext.tsx new file mode 100644 index 0000000000..96611fcea7 --- /dev/null +++ b/packages/shared/src/features/giveback/GivebackNavContext.tsx @@ -0,0 +1,50 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { createContext, useContext } from 'react'; + +export type GivebackTabId = + | 'causes' + | 'impact' + | 'why' + | 'actions' + | 'updates' + | 'comments' + | 'faq'; + +interface GivebackNavContextValue { + // Whether the visitor has opted in from the hero gateway. Until then the + // tabs and the rest of the experience stay hidden. + hasStarted: boolean; + start: () => void; + activeTab: GivebackTabId; + setActiveTab: (tab: GivebackTabId) => void; +} + +const GivebackNavContext = createContext( + undefined, +); + +interface GivebackNavProviderProps extends GivebackNavContextValue { + children: ReactNode; +} + +export const GivebackNavProvider = ({ + hasStarted, + start, + activeTab, + setActiveTab, + children, +}: GivebackNavProviderProps): ReactElement => ( + + {children} + +); + +export const useGivebackNav = (): GivebackNavContextValue => { + const context = useContext(GivebackNavContext); + if (!context) { + throw new Error('useGivebackNav must be used within a GivebackNavProvider'); + } + return context; +}; diff --git a/packages/shared/src/features/giveback/actionPlatform.ts b/packages/shared/src/features/giveback/actionPlatform.ts new file mode 100644 index 0000000000..c5edc62b88 --- /dev/null +++ b/packages/shared/src/features/giveback/actionPlatform.ts @@ -0,0 +1,40 @@ +import type { ComponentType } from 'react'; +import { + AppleIcon, + ChromeIcon, + DailyIcon, + GitHubIcon, + HashnodeIcon, + LinkedInIcon, + RedditIcon, + TwitterIcon, + YoutubeIcon, +} from '../../components/icons'; +import type { IconProps } from '../../components/Icon'; +import { GivebackActionPlatform } from './types'; + +interface ActionPlatformVisual { + Icon: ComponentType; + name: string; +} + +// Real platform logos so each action reads as a growth move on a known surface +// (post on X, video on YouTube, ship on GitHub...). Rendered with the colored +// `secondary` variant on a light tile, app-store style. +export const actionPlatformVisual: Record< + GivebackActionPlatform, + ActionPlatformVisual +> = { + [GivebackActionPlatform.X]: { Icon: TwitterIcon, name: 'X' }, + [GivebackActionPlatform.YouTube]: { Icon: YoutubeIcon, name: 'YouTube' }, + [GivebackActionPlatform.Hashnode]: { Icon: HashnodeIcon, name: 'Hashnode' }, + [GivebackActionPlatform.GitHub]: { Icon: GitHubIcon, name: 'GitHub' }, + [GivebackActionPlatform.Reddit]: { Icon: RedditIcon, name: 'Reddit' }, + [GivebackActionPlatform.LinkedIn]: { Icon: LinkedInIcon, name: 'LinkedIn' }, + [GivebackActionPlatform.AppStore]: { Icon: AppleIcon, name: 'App Store' }, + [GivebackActionPlatform.ChromeWebStore]: { + Icon: ChromeIcon, + name: 'Chrome Web Store', + }, + [GivebackActionPlatform.DailyDev]: { Icon: DailyIcon, name: 'daily.dev' }, +}; diff --git a/packages/shared/src/features/giveback/components/ActionCard.tsx b/packages/shared/src/features/giveback/components/ActionCard.tsx new file mode 100644 index 0000000000..7a2fcb932b --- /dev/null +++ b/packages/shared/src/features/giveback/components/ActionCard.tsx @@ -0,0 +1,159 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { IconSize } from '../../../components/Icon'; +import type { GivebackAction, GivebackUserAction } from '../types'; +import { GivebackUserActionStatus } from '../types'; +import { formatDonationAmount } from '../utils'; +import { + getUserActionStatusClassName, + getUserActionStatusLabel, +} from '../statusLabels'; +import { actionPlatformVisual } from '../actionPlatform'; + +interface ActionCardProps { + action: GivebackAction; + userAction?: GivebackUserAction; + onSubmit?: (action: GivebackAction) => void; +} + +const getStatus = (userAction?: GivebackUserAction): GivebackUserActionStatus => + userAction?.status ?? GivebackUserActionStatus.NotStarted; + +export const ActionCard = ({ + action, + userAction, + onSubmit, +}: ActionCardProps): ReactElement => { + const status = getStatus(userAction); + const isUnavailable = + status === GivebackUserActionStatus.Expired || + status === GivebackUserActionStatus.Rejected; + const canSubmit = + !action.isLoveAction && + action.donationEligible && + [ + GivebackUserActionStatus.NotStarted, + GivebackUserActionStatus.Started, + GivebackUserActionStatus.NeedsMoreInfo, + ].includes(status); + const isInteractive = canSubmit && !!onSubmit; + const showStatus = status !== GivebackUserActionStatus.NotStarted; + const { Icon, name: platformName } = actionPlatformVisual[action.platform]; + + const content: ReactNode = ( + <> + + + + + + + {platformName} + + + {action.isLoveAction ? ( + + Just for love + + ) : ( + + +{formatDonationAmount(action.donationAmount, action.currency)} + + )} + + + + + {action.title} + + {action.description && ( + + {action.description} + + )} + + + + {action.isLoveAction ? ( + + We can't pay for this — but we'd genuinely appreciate it. + + ) : ( + showStatus && ( + + {getUserActionStatusLabel(status)} + + ) + )} + {isInteractive && ( + + Submit proof › + + )} + + + ); + + if (isInteractive) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); +}; diff --git a/packages/shared/src/features/giveback/components/ActionCatalog.tsx b/packages/shared/src/features/giveback/components/ActionCatalog.tsx new file mode 100644 index 0000000000..e0389e2e41 --- /dev/null +++ b/packages/shared/src/features/giveback/components/ActionCatalog.tsx @@ -0,0 +1,255 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { useGivebackContext } from '../GivebackContext'; +import type { GivebackAction, GivebackActionCategoryFilter } from '../types'; +import { GivebackActionCategory } from '../types'; +import { actionCategoryLabels } from '../statusLabels'; +import { ActionCard } from './ActionCard'; +import { GivebackSubmissionModal } from './GivebackSubmissionModal'; +import { GivebackSection } from './GivebackSection'; +import { formatDonationAmount } from '../utils'; + +interface FilterChipProps { + isSelected: boolean; + label: string; + onClick: () => void; +} + +const FilterChip = ({ + isSelected, + label, + onClick, +}: FilterChipProps): ReactElement => ( + +); + +type SortKey = 'recommended' | 'value-desc' | 'value-asc' | 'newest'; + +const sortOptions: [SortKey, string][] = [ + ['recommended', 'Recommended'], + ['value-desc', 'Highest value'], + ['value-asc', 'Lowest value'], + ['newest', 'Newest'], +]; + +const sortActions = ( + actions: GivebackAction[], + sort: SortKey, +): GivebackAction[] => { + if (sort === 'value-desc') { + return [...actions].sort((a, b) => b.donationAmount - a.donationAmount); + } + if (sort === 'value-asc') { + return [...actions].sort((a, b) => a.donationAmount - b.donationAmount); + } + if (sort === 'newest') { + // No timestamps in the mock layer; treat later catalog entries as newer. + return [...actions].reverse(); + } + return actions; +}; + +export const ActionCatalog = (): ReactElement => { + const { + actions, + donationAccounting, + filteredActions, + loveActions, + userActions, + selectedCategory, + setSelectedCategory, + } = useGivebackContext(); + const [submissionAction, setSubmissionAction] = + useState(null); + const [sort, setSort] = useState('recommended'); + + const categories = useMemo( + () => + Array.from( + new Set([...actions, ...loveActions].map((action) => action.category)), + ), + [actions, loveActions], + ); + const userActionById = useMemo( + () => + new Map( + userActions.map((userAction) => [userAction.actionId, userAction]), + ), + [userActions], + ); + const sortedActions = useMemo( + () => sortActions(filteredActions, sort), + [filteredActions, sort], + ); + + // Love actions live in the same catalog but are non-paid, so they render as a + // highlighted "just for love" group rather than alongside the paid missions. + const isLoveCategory = + selectedCategory === GivebackActionCategory.CommunityLove; + const donationToRender = isLoveCategory ? [] : sortedActions; + const loveToRender = + selectedCategory === 'all' || isLoveCategory ? loveActions : []; + const hasResults = donationToRender.length > 0 || loveToRender.length > 0; + + // One simple, trust-by-default number: everything you've earned counts the + // moment you act. Rejected submissions are the only thing we subtract. + const earnedContribution = + donationAccounting.unlockedDonationAmount - + donationAccounting.rejectedDonationAmount; + + return ( + + + + Your contribution + + + {formatDonationAmount(earnedContribution, 'USD')} + + + Counts the moment you act — we trust you. If a submission is rejected, + we'll subtract it. + + + + + + setSelectedCategory('all')} + /> + {categories.map((category) => ( + + setSelectedCategory(category as GivebackActionCategoryFilter) + } + /> + ))} + + + + + Sort + + + + + + {hasResults ? ( + + {donationToRender.length > 0 && ( +
+ {donationToRender.map((action) => ( + + ))} +
+ )} + + {loveToRender.length > 0 && ( + + + + Just for love + + + We can't pay for these — but we'd genuinely + appreciate them. + + +
+ {loveToRender.map((action) => ( + + ))} +
+
+ )} +
+ ) : ( + + + No actions match this filter + + + Try another filter. + + + )} + + {submissionAction && ( + setSubmissionAction(null)} + /> + )} +
+ ); +}; diff --git a/packages/shared/src/features/giveback/components/BudgetRedirectStory.tsx b/packages/shared/src/features/giveback/components/BudgetRedirectStory.tsx new file mode 100644 index 0000000000..32c0666063 --- /dev/null +++ b/packages/shared/src/features/giveback/components/BudgetRedirectStory.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol } from '../../../components/utilities'; +import { useGivebackContext } from '../GivebackContext'; +import { formatDonationAmount } from '../utils'; +import { GivebackSection } from './GivebackSection'; + +// "Why we do it" — kept short and emotional. The community contribution speaks +// for itself; this is just the one-line reason behind it. +export const BudgetRedirectStory = (): ReactElement => { + const { campaign } = useGivebackContext(); + + return ( + + + + Big tech burns billions just to get noticed. We're taking part of + that budget and putting it where it{' '} + + actually changes lives + + . + + + {formatDonationAmount(campaign.goalAmount, campaign.currency)} for the + causes developers pick — scholarships, open source, and access to + tech. Not a marketing campaign. A community deciding what its work is + worth. + + + + ); +}; diff --git a/packages/shared/src/features/giveback/components/CauseSelection.tsx b/packages/shared/src/features/giveback/components/CauseSelection.tsx new file mode 100644 index 0000000000..9dd28fec1f --- /dev/null +++ b/packages/shared/src/features/giveback/components/CauseSelection.tsx @@ -0,0 +1,204 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { + ArrowIcon, + PlusIcon, + SparkleIcon, + VIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { useGivebackContext } from '../GivebackContext'; +import { useGivebackNav } from '../GivebackNavContext'; +import { GivebackSection } from './GivebackSection'; +import { CauseSelectionModal } from './CauseSelectionModal'; + +// Brand-tinted emblems so each cause card reads as its own tile, Lemonade-style. +const emblemAccents = [ + 'bg-accent-cabbage-flat text-accent-cabbage-default', + 'bg-accent-avocado-flat text-accent-avocado-default', + 'bg-accent-onion-flat text-accent-onion-default', + 'bg-accent-bacon-flat text-accent-bacon-default', +]; + +export const CauseSelection = (): ReactElement => { + const { causes, suggestedCauses, toggleCause, userProfile } = + useGivebackContext(); + const { setActiveTab } = useGivebackNav(); + const [isSuggestOpen, setIsSuggestOpen] = useState(false); + + const selectedCount = causes.filter((cause) => + userProfile.selectedCauseIds.includes(cause.id), + ).length; + + return ( + + + + Why we're doing this + + + We fund developers, not ads.{' '} + + You pick where it goes. + + + + + +
+ {causes.map((cause, index) => { + const isSelected = userProfile.selectedCauseIds.includes(cause.id); + + return ( + + ); + })} +
+ + {suggestedCauses.length > 0 && ( + + + Your suggestions: + + {suggestedCauses.map((cause) => ( + + + {cause.name} + + + pending review + + + ))} + + )} + + + + {selectedCount} selected + + + + + + + {isSuggestOpen && ( + setIsSuggestOpen(false)} /> + )} +
+
+ ); +}; diff --git a/packages/shared/src/features/giveback/components/CauseSelectionModal.tsx b/packages/shared/src/features/giveback/components/CauseSelectionModal.tsx new file mode 100644 index 0000000000..e28934c2b9 --- /dev/null +++ b/packages/shared/src/features/giveback/components/CauseSelectionModal.tsx @@ -0,0 +1,171 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { useGivebackContext } from '../GivebackContext'; + +interface CauseSelectionModalProps { + onClose: () => void; +} + +// Focused "suggest a cause" modal. The full cause catalog is now the card grid +// on the Causes tab, so this is only the lightweight suggestion form. +export const CauseSelectionModal = ({ + onClose, +}: CauseSelectionModalProps): ReactElement => { + const { suggestedCauses, suggestCause } = useGivebackContext(); + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [note, setNote] = useState(''); + + const onSuggest = () => { + suggestCause({ name, url, note }); + setName(''); + setUrl(''); + setNote(''); + onClose(); + }; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + className="fixed inset-0 z-modal flex items-center justify-center bg-overlay-primary-pepper px-4 backdrop-blur-sm" + > +
+
+ + + + Suggest a cause + + + Missing a cause you care about? Tell us — we review every suggestion + before it goes live. + + + + +
+ + + +
+ +