diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 40b0bad2a55..088c8ab9c61 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -321,7 +321,11 @@ function MainLayoutComponent({
{ + const hit = GOAL_MILESTONES.filter((m) => before < m && after >= m); + return hit.length ? hit[hit.length - 1] : null; +}; + +export interface GivebackContextValue { + campaign: GivebackCampaign; + levels: GivebackLevel[]; + userProfile: GivebackUserProfile; + actions: GivebackAction[]; + filteredActions: GivebackAction[]; + loveActions: GivebackAction[]; + userActions: GivebackUserAction[]; + causes: GivebackCause[]; + suggestedCauses: GivebackCause[]; + communityEvents: GivebackCommunityEvent[]; + topContributors: GivebackTopContributor[]; + leaderboard: GivebackLeaderboardEntry[]; + communityRally: GivebackCommunityRally; + donationAccounting: GivebackDonationAccounting; + submitAction: (input: GivebackActionSubmissionInput) => void; + toggleCause: (causeId: string) => void; + suggestCause: (input: GivebackCauseSuggestionInput) => void; + sponsorCampaign: (input: GivebackSponsorInput) => 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; + // Session contribution unlocked by actions taken during this visit, used to + // tick the community pot up live as the user takes action. + sessionContribution: number; + celebration: GivebackCelebration | null; + celebrate: (input: GivebackCelebrationInput) => void; + dismissCelebration: () => 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; + userLevel: number; + setUserLevel: (level: number) => void; +} + +const GivebackContext = createContext( + undefined, +); + +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, +); + +// Sponsorships seeded into the mock are already reflected in the baseline raised +// amount, so only sponsorships added during the session top the pot up further. +const SEED_SPONSORED_AMOUNT = givebackSponsors.reduce( + (sum, sponsor) => sum + sponsor.amount, + 0, +); + +interface GivebackProviderProps { + children: ReactNode; +} + +export const GivebackProvider = ({ + children, +}: 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 [sponsors, setSponsors] = useState(givebackSponsors); + const [showCommunityFeed, setShowCommunityFeed] = useState(true); + const [geoAvailability, setGeoAvailability] = useState< + 'available' | 'waitlist' + >('available'); + const [celebrationState, setCelebrationState] = useState< + 'none' | 'milestone' | 'complete' + >('none'); + const [sessionContribution, setSessionContribution] = useState(0); + const [celebration, setCelebration] = useState( + null, + ); + const celebrationIdRef = useRef(0); + + const celebrate = ({ + amount, + currency = baseCampaign.currency, + milestone = null, + complete = false, + }: GivebackCelebrationInput): void => { + celebrationIdRef.current += 1; + setCelebration({ + id: celebrationIdRef.current, + amount, + currency, + milestone, + complete, + }); + }; + + const dismissCelebration = (): void => setCelebration(null); + + 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; + + // Tick the community pot up by the unlocked amount and fire the win moment, + // flagging any goal milestone the contribution pushes the pot across. + const amount = action.donationAmount; + const sponsoredTotal = sponsors.reduce((sum, s) => sum + s.amount, 0); + const baseApproved = Math.round( + (baseCampaign.goalAmount * goalPercentage) / 100, + ); + const approvedBefore = + baseApproved + + (sponsoredTotal - SEED_SPONSORED_AMOUNT) + + sessionContribution; + const beforePct = (approvedBefore / baseCampaign.goalAmount) * 100; + const afterPct = + ((approvedBefore + amount) / baseCampaign.goalAmount) * 100; + const milestone = crossedMilestone(beforePct, afterPct); + + setSessionContribution((current) => current + amount); + celebrate({ + amount, + currency: action.currency, + milestone, + complete: afterPct >= 100 && beforePct < 100, + }); + + 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 sponsorCampaign = ({ + name, + type, + amount, + message, + }: GivebackSponsorInput): void => { + const trimmedName = name.trim(); + + if (!trimmedName || amount <= 0) { + return; + } + + setSponsors((current) => [ + { + id: `sponsor-${Date.now().toString()}`, + name: trimmedName, + type, + amount, + currency: baseCampaign.currency, + message: message?.trim() || undefined, + createdAt: new Date().toISOString(), + }, + ...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 sponsoredAmount = sponsors.reduce( + (sum, sponsor) => sum + sponsor.amount, + 0, + ); + const baseApprovedAmount = Math.round( + (baseCampaign.goalAmount * goalPercentage) / 100, + ); + // New sponsorships and live session actions top the pot up on top of the + // baseline raised amount. + const approvedAmount = + baseApprovedAmount + + (sponsoredAmount - SEED_SPONSORED_AMOUNT) + + sessionContribution; + const campaign: GivebackCampaign = { + ...baseCampaign, + approvedAmount, + sponsoredAmount, + sponsors, + }; + + const activeLevel = + givebackLevels.find((level) => level.levelNumber === userLevel) ?? + givebackLevels[0]; + + const userProfile: GivebackUserProfile = { + ...baseProfile, + currentLevel: activeLevel.levelNumber, + approvedContributionAmount: activeLevel.requiredApprovedAmount, + selectedCauseIds, + }; + + // The board's "You" row mirrors the same earned total the "Your + // contribution" card shows (unlocked minus anything rejected), so the two + // surfaces can never drift apart. + const earnedContribution = + donationAccounting.unlockedDonationAmount - + donationAccounting.rejectedDonationAmount; + const leaderboard = givebackLeaderboard.map((entry) => + entry.isCurrentUser + ? { ...entry, contributionAmount: earnedContribution } + : entry, + ); + + 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 : [], + topContributors: givebackTopContributors, + leaderboard, + communityRally: givebackCommunityRally, + donationAccounting, + submitAction, + toggleCause, + suggestCause, + sponsorCampaign, + setUserActionStatus, + showCommunityFeed, + setShowCommunityFeed, + geoAvailability, + setGeoAvailability, + celebrationState, + setCelebrationState, + sessionContribution, + celebration, + celebrate, + dismissCelebration, + selectedCategory, + setSelectedCategory, + goalPercentage, + setGoalPercentage, + userLevel, + setUserLevel, + }; + }, [ + goalPercentage, + celebrationState, + sessionContribution, + celebration, + geoAvailability, + selectedCategory, + selectedCauseIds, + showCommunityFeed, + sponsors, + suggestedCauses, + userActions, + 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/GivebackNavContext.tsx b/packages/shared/src/features/giveback/GivebackNavContext.tsx new file mode 100644 index 00000000000..f782691e8e2 --- /dev/null +++ b/packages/shared/src/features/giveback/GivebackNavContext.tsx @@ -0,0 +1,43 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { createContext, useContext } from 'react'; + +export type GivebackTabId = 'impact' | 'why' | 'actions'; + +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 00000000000..6553cef1e83 --- /dev/null +++ b/packages/shared/src/features/giveback/actionPlatform.ts @@ -0,0 +1,220 @@ +import type { ComponentType } from 'react'; +// AndroidIcon is not re-exported by the icons barrel, so import it directly. +import { AndroidIcon } from '../../components/icons/Android'; +import { + AppleIcon, + BrowserGroupIcon, + CalendarIcon, + ChromeIcon, + DailyIcon, + DiscordIcon, + DiscussIcon, + DocsIcon, + EarthIcon, + EdgeIcon, + FeatherIcon, + GitHubIcon, + HashnodeIcon, + HotIcon, + LinkedInIcon, + MailIcon, + MegaphoneIcon, + MicrophoneIcon, + PlayIcon, + RedditIcon, + SitesIcon, + SlackIcon, + StackOverflowIcon, + StarIcon, + TelegramIcon, + TerminalIcon, + TrendingIcon, + TwitterIcon, + YoutubeIcon, +} from '../../components/icons'; +import type { IconProps } from '../../components/Icon'; +import { GivebackActionPlatform } from './types'; + +interface ActionPlatformVisual { + name: string; + /** + * Real brand logo rendered as an `` on the card (preferred for branded + * surfaces). When absent, the card renders the internal `Icon` instead. If + * the remote logo ever fails to load, the card also falls back to `Icon`, so + * a tile can never render broken or blank. + */ + logoUrl?: string; + /** + * Internal glyph used either as the primary visual for generic surfaces + * (blogs, newsletters, forums, events...) that have no single brand, or as + * the offline fallback for branded surfaces. + */ + Icon: ComponentType; + /** + * Some internal brand glyphs ship only a hardcoded-white SVG (no color or + * `currentColor` variant), invisible on the light tile. Flag those so the + * card can force the fallback glyph to a dark silhouette. + */ + forceDark?: boolean; +} + +// Most brand logos come from Simple Icons' on-demand CDN (single, predictable +// slug -> official brand-colored glyph). A few brands Simple Icons drops for +// trademark reasons (LinkedIn, Slack, Edge) come from SVGL, the same open logo +// library the sponsor wall uses. +const simpleIcon = (slug: string): string => + `https://cdn.simpleicons.org/${slug}`; +const svglIcon = (slug: string): string => + `https://svgl.app/library/${slug}.svg`; + +// Real platform logos so each action reads as a growth move on a known surface +// (post on X, video on YouTube, ship on GitHub...). Branded surfaces get their +// actual logo; surfaces without a dedicated brand reuse the closest semantic +// glyph (reviews -> star, blogs -> globe, events -> calendar...). +export const actionPlatformVisual: Record< + GivebackActionPlatform, + ActionPlatformVisual +> = { + [GivebackActionPlatform.X]: { + name: 'X', + logoUrl: simpleIcon('x'), + Icon: TwitterIcon, + }, + [GivebackActionPlatform.YouTube]: { + name: 'YouTube', + logoUrl: simpleIcon('youtube'), + Icon: YoutubeIcon, + }, + [GivebackActionPlatform.Hashnode]: { + name: 'Hashnode', + logoUrl: simpleIcon('hashnode'), + Icon: HashnodeIcon, + forceDark: true, + }, + [GivebackActionPlatform.GitHub]: { + name: 'GitHub', + logoUrl: simpleIcon('github'), + Icon: GitHubIcon, + }, + [GivebackActionPlatform.Reddit]: { + name: 'Reddit', + logoUrl: simpleIcon('reddit'), + Icon: RedditIcon, + }, + [GivebackActionPlatform.LinkedIn]: { + name: 'LinkedIn', + logoUrl: svglIcon('linkedin'), + Icon: LinkedInIcon, + }, + [GivebackActionPlatform.AppStore]: { + name: 'App Store', + logoUrl: simpleIcon('appstore'), + Icon: AppleIcon, + forceDark: true, + }, + [GivebackActionPlatform.ChromeWebStore]: { + name: 'Chrome Web Store', + logoUrl: simpleIcon('googlechrome'), + Icon: ChromeIcon, + }, + [GivebackActionPlatform.DailyDev]: { + name: 'daily.dev', + logoUrl: simpleIcon('dailydotdev'), + Icon: DailyIcon, + forceDark: true, + }, + [GivebackActionPlatform.EdgeAddons]: { + name: 'Edge Add-ons', + logoUrl: svglIcon('edge'), + Icon: EdgeIcon, + }, + [GivebackActionPlatform.FirefoxAddons]: { + name: 'Firefox Add-ons', + logoUrl: simpleIcon('firefoxbrowser'), + Icon: BrowserGroupIcon, + }, + [GivebackActionPlatform.GooglePlay]: { + name: 'Google Play', + logoUrl: simpleIcon('googleplay'), + Icon: AndroidIcon, + }, + [GivebackActionPlatform.Trustpilot]: { + name: 'Trustpilot', + logoUrl: simpleIcon('trustpilot'), + Icon: StarIcon, + }, + [GivebackActionPlatform.G2]: { + name: 'G2', + logoUrl: simpleIcon('g2'), + Icon: StarIcon, + }, + // Capterra has no logo on either CDN, so it keeps the semantic review glyph. + [GivebackActionPlatform.Capterra]: { name: 'Capterra', Icon: StarIcon }, + [GivebackActionPlatform.ProductHunt]: { + name: 'Product Hunt', + logoUrl: simpleIcon('producthunt'), + Icon: TrendingIcon, + }, + [GivebackActionPlatform.Directory]: { name: 'Directories', Icon: SitesIcon }, + [GivebackActionPlatform.Medium]: { + name: 'Medium', + logoUrl: simpleIcon('medium'), + Icon: FeatherIcon, + }, + [GivebackActionPlatform.Dev]: { + name: 'DEV', + logoUrl: simpleIcon('devdotto'), + Icon: TerminalIcon, + }, + [GivebackActionPlatform.Blog]: { name: 'Blog', Icon: EarthIcon }, + [GivebackActionPlatform.Newsletter]: { name: 'Newsletter', Icon: MailIcon }, + [GivebackActionPlatform.Notion]: { + name: 'Notion', + logoUrl: simpleIcon('notion'), + Icon: DocsIcon, + }, + [GivebackActionPlatform.Website]: { name: 'Website', Icon: EarthIcon }, + [GivebackActionPlatform.HackerNews]: { + name: 'Hacker News', + logoUrl: simpleIcon('ycombinator'), + Icon: HotIcon, + }, + [GivebackActionPlatform.StackOverflow]: { + name: 'Stack Overflow', + logoUrl: simpleIcon('stackoverflow'), + Icon: StackOverflowIcon, + }, + [GivebackActionPlatform.Discord]: { + name: 'Discord', + logoUrl: simpleIcon('discord'), + Icon: DiscordIcon, + }, + [GivebackActionPlatform.Slack]: { + name: 'Slack', + logoUrl: svglIcon('slack'), + Icon: SlackIcon, + }, + [GivebackActionPlatform.Telegram]: { + name: 'Telegram', + logoUrl: simpleIcon('telegram'), + Icon: TelegramIcon, + }, + [GivebackActionPlatform.IndieHackers]: { + name: 'Indie Hackers', + logoUrl: simpleIcon('indiehackers'), + Icon: MegaphoneIcon, + }, + [GivebackActionPlatform.Forum]: { name: 'Forums', Icon: DiscussIcon }, + [GivebackActionPlatform.Twitch]: { + name: 'Twitch', + logoUrl: simpleIcon('twitch'), + Icon: PlayIcon, + }, + [GivebackActionPlatform.Podcast]: { name: 'Podcast', Icon: MicrophoneIcon }, + [GivebackActionPlatform.Event]: { name: 'Events', Icon: CalendarIcon }, + [GivebackActionPlatform.Wiki]: { + name: 'Wikipedia', + logoUrl: simpleIcon('wikipedia'), + Icon: EarthIcon, + }, +}; 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 00000000000..46a899ad589 --- /dev/null +++ b/packages/shared/src/features/giveback/components/ActionCard.tsx @@ -0,0 +1,292 @@ +import type { ComponentType, ReactElement, ReactNode } from 'react'; +import React, { useState } 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 { IconProps } from '../../../components/Icon'; +import type { GivebackAction, GivebackUserAction } from '../types'; +import { GivebackUserActionStatus } from '../types'; +import { formatCompactNumber, formatDonationAmount } from '../utils'; +import { actionPlatformVisual } from '../actionPlatform'; +import { + LinkIcon, + StarIcon, + TimerIcon, + VIcon, +} from '../../../components/icons'; +import { GivebackContributorFaces } from './GivebackContributorFaces'; + +interface ActionCardProps { + action: GivebackAction; + userAction?: GivebackUserAction; + onSubmit?: (action: GivebackAction) => void; +} + +const getStatus = (userAction?: GivebackUserAction): GivebackUserActionStatus => + userAction?.status ?? GivebackUserActionStatus.NotStarted; + +interface PlatformLogoProps { + logoUrl?: string; + Icon: ComponentType; + forceDark?: boolean; + isDone: boolean; +} + +// Prefers the real brand logo (an SVG from the logo CDN) and falls back to the +// internal glyph if there is no logo for the surface or the remote one fails to +// load — so a tile is never broken or blank. The parent tile already pins the +// background and applies the dimmed/grayscale "done" treatment. +const PlatformLogo = ({ + logoUrl, + Icon, + forceDark, + isDone, +}: PlatformLogoProps): ReactElement => { + const [failed, setFailed] = useState(false); + + if (logoUrl && !failed) { + return ( + setFailed(true)} + className="size-6 object-contain" + /> + ); + } + + return ( + + ); +}; + +// One sharp, explicit title carries the ask — no competing subtitle. The +// supporting details (payout, social proof, "Popular") sit in a calm top/bottom +// frame around it so the card stays easy to scan at a glance. +export const ActionCard = ({ + action, + userAction, + onSubmit, +}: ActionCardProps): ReactElement => { + const status = getStatus(userAction); + const isCompleted = + status === GivebackUserActionStatus.Approved || + status === GivebackUserActionStatus.CountedTowardGoal; + const isInReview = + status === GivebackUserActionStatus.Submitted || + status === GivebackUserActionStatus.PendingReview || + status === GivebackUserActionStatus.AutoValidating; + // "Done" = you've already acted on it, so it locks into a flat, dimmed claimed + // state. Everything else (including expired/rejected) stays clickable to retry. + const isDone = isCompleted || isInReview; + // Every actionable (not-yet-done) card is clickable — including "just for + // love" ones, which open a compliant appreciation view instead of the proof + // flow (they carry no reward/donation). + const isInteractive = !isDone && !!onSubmit; + // Every platform is mapped, but fall back to a neutral link glyph so a card + // can never render a blank tile if a new platform slips through unmapped. + const { + Icon, + name: platformName, + forceDark, + logoUrl, + } = actionPlatformVisual[action.platform] ?? { + Icon: LinkIcon, + name: 'Link', + }; + + const doneMeta = isCompleted + ? { label: 'Done', Icon: VIcon } + : { label: 'In review', Icon: TimerIcon }; + + const contributorsCount = action.contributorsCount ?? 0; + const contributorsLast24h = action.contributorsLast24h ?? 0; + + // The top-right slot shows one of three mutually exclusive states: a status + // pill once acted on, a soft "love" tag for no-reward actions, or the payout. + const renderTopRightMeta = (): ReactNode => { + if (isDone) { + return ( + + + + {doneMeta.label} + + + ); + } + + if (action.isLoveAction) { + return ( + + Just for love + + ); + } + + return ( + + +{formatDonationAmount(action.donationAmount, action.currency)} + + ); + }; + + const content: ReactNode = ( + <> + + + + + + + + {platformName} + + {action.isTrending && ( + + + + Popular + + + )} + + + {renderTopRightMeta()} + + + + {action.title} + + + {contributorsCount > 0 && ( + + + + {formatCompactNumber(contributorsCount)} contributed + {contributorsLast24h > 0 && + ` · ${formatCompactNumber(contributorsLast24h)} today`} + + + )} + + ); + + if (isInteractive) { + return ( + + ); + } + + // Already acted on: a flat "claimed" state. No solid fill — just a dashed + // outline, a grayscale icon, a struck-through title and a vivid status pill — + // so it's unmistakably distinct from the actionable, tappable cards. + if (isDone) { + return ( +
+ {content} +
+ ); + } + + 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 00000000000..0ad6da01d56 --- /dev/null +++ b/packages/shared/src/features/giveback/components/ActionCatalog.tsx @@ -0,0 +1,493 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { useGivebackContext } from '../GivebackContext'; +import { useGivebackNav } from '../GivebackNavContext'; +import type { GivebackAction, GivebackActionCategoryFilter } from '../types'; +import { GivebackActionCategory } from '../types'; +import { actionCategoryLabels } from '../statusLabels'; +import { + ArrowIcon, + GiftIcon, + InfoIcon, + MedalBadgeIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { ButtonIconPosition } from '../../../components/buttons/common'; +import { ActionCard } from './ActionCard'; +import { GivebackFilterChip } from './GivebackFilterChip'; +import { + GivebackAvatar, + GivebackContributorFaces, +} from './GivebackContributorFaces'; +import { GivebackSubmissionModal } from './GivebackSubmissionModal'; +import { GivebackSection } from './GivebackSection'; +import { GivebackLiveTicker } from './GivebackLiveTicker'; +import { formatDonationAmount } from '../utils'; +import { + useCountUp, + useInView, + usePrefersReducedMotion, +} from '../useGivebackMotion'; + +type SortKey = 'recommended' | 'value-desc' | 'value-asc' | 'newest'; + +const sortOptions: [SortKey, string][] = [ + ['recommended', 'Recommended'], + ['value-desc', 'Highest value'], + ['value-asc', 'Lowest value'], + ['newest', 'Newest'], +]; + +// Keep the initial grid short so the tab opens scannable; the rest expand on +// demand. 12 fills four clean rows on the 3-column breakpoint (six on the +// 2-column one). +const INITIAL_VISIBLE_ACTIONS = 12; + +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(); + } + // "Recommended" leads with popular actions, then by real-time momentum so the + // catalog surfaces what the community is doing right now. + return [...actions].sort((a, b) => { + if (!!a.isTrending !== !!b.isTrending) { + return a.isTrending ? -1 : 1; + } + return (b.contributorsLast24h ?? 0) - (a.contributorsLast24h ?? 0); + }); +}; + +export const ActionCatalog = (): ReactElement => { + const { + actions, + campaign, + donationAccounting, + filteredActions, + leaderboard, + levels, + loveActions, + topContributors, + userActions, + userProfile, + selectedCategory, + setSelectedCategory, + } = useGivebackContext(); + const { setActiveTab } = useGivebackNav(); + const [submissionAction, setSubmissionAction] = + useState(null); + const [sort, setSort] = useState('recommended'); + const [showAllActions, setShowAllActions] = useState(false); + const filtersRef = useRef(null); + const didMountRef = useRef(false); + + // Re-collapse the grid whenever the filter or sort changes so a new view + // always opens to the short, scannable list. Also re-anchor the viewport to + // the filter row: when a filter shrinks the list the page height collapses, + // and without this the browser strands the scroll position in empty space + // below the results (the "jump to the bottom" layout shift). + useEffect(() => { + setShowAllActions(false); + if (!didMountRef.current) { + didMountRef.current = true; + return; + } + const node = filtersRef.current; + if (node && typeof node.scrollIntoView === 'function') { + node.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [selectedCategory, sort]); + + 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. This group sits outside the filters and + // is always visible below the paid actions, whatever category is selected. + const isLoveCategory = + selectedCategory === GivebackActionCategory.CommunityLove; + const donationToRender = isLoveCategory ? [] : sortedActions; + const visibleDonationActions = showAllActions + ? donationToRender + : donationToRender.slice(0, INITIAL_VISIBLE_ACTIONS); + const hiddenActionsCount = + donationToRender.length - visibleDonationActions.length; + const loveToRender = loveActions; + + // 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; + + const leaderAvatars = topContributors.map((person) => person.avatar); + + // Personal standing for the "Your contribution" card: pull the viewer's row + // from the same board shown on Impact, plus the next reward they're working + // toward, so the card answers "where am I, what have I earned, what's next". + const currentUser = leaderboard.find((entry) => entry.isCurrentUser); + const nextLevel = levels.find( + (level) => + level.requiredApprovedAmount > userProfile.approvedContributionAmount, + ); + const amountToNextReward = nextLevel + ? Math.max( + 0, + nextLevel.requiredApprovedAmount - + userProfile.approvedContributionAmount, + ) + : 0; + + // Live "developers contributed this week" counter. It starts at the real + // total and trickles upward on a steady cadence so the hub feels like it's + // moving in real time — each tick stands in for a fresh community action. The + // number rolls up with a count-up animation; reduced-motion users just see the + // static full total. + const reducedMotion = usePrefersReducedMotion(); + const { ref: backersRef, inView: backersInView } = + useInView(); + const [liveBackers, setLiveBackers] = useState(campaign.backersCount); + + useEffect(() => { + setLiveBackers(campaign.backersCount); + }, [campaign.backersCount]); + + useEffect(() => { + if (reducedMotion || typeof window === 'undefined') { + return undefined; + } + const timer = window.setInterval( + () => setLiveBackers((current) => current + 1), + 3600, + ); + return () => window.clearInterval(timer); + }, [reducedMotion]); + + const animatedBackers = useCountUp(liveBackers, backersInView); + + return ( + + + + + + + + + {currentUser && ( +
+ + + Lvl {userProfile.currentLevel} + +
+ )} + + + + + Your contribution + + + + + + Counts the moment you act, because we trust you. If a + submission is rejected, we'll subtract it. + + + + + + + {formatDonationAmount(earnedContribution, 'USD')} + + + unlocked for your causes + + + {currentUser && ( + + + + Rank #{currentUser.rank} + + + · {userProfile.actionsCompletedCount} actions taken + + + )} + + + {nextLevel?.reward && ( + + + Next reward + + + + + {nextLevel.reward.title} + + + + {formatDonationAmount(amountToNextReward, 'USD')} to go + + + )} +
+ + + + setSelectedCategory('all')} + /> + {categories.map((category) => ( + + setSelectedCategory(category as GivebackActionCategoryFilter) + } + /> + ))} + + +
+ + +
+
+ + + {donationToRender.length > 0 && ( + +
+ {visibleDonationActions.map((action) => ( + + ))} +
+ {donationToRender.length > INITIAL_VISIBLE_ACTIONS && ( + + + + )} +
+ )} + + {donationToRender.length === 0 && !isLoveCategory && ( + + + No actions match this filter + + + Try another filter. + + + )} + + {loveToRender.length > 0 && ( + + + + Just for love + + + We can't pay for these, but we'd genuinely appreciate + them. + + +
+ {loveToRender.map((action) => ( + + ))} +
+
+ )} +
+ + {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 00000000000..e98d5c5595e --- /dev/null +++ b/packages/shared/src/features/giveback/components/BudgetRedirectStory.tsx @@ -0,0 +1,52 @@ +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 { useGivebackContext } from '../GivebackContext'; +import { formatDonationAmount } from '../utils'; +import { GivebackSection } from './GivebackSection'; +import { GivebackHeadline } from './GivebackHeadline'; +import { GivebackMascot, GivebackMascotMood } from './GivebackMascot'; + +interface BudgetRedirectStoryProps { + headline: { title: string; highlight: string }; +} + +// "Why we do it" — kept short and emotional. The headline + reason stack in a +// left column with the charm beside both as the "genie" who grants the +// community's wishes, so the row stays tight with no empty space top/bottom. +export const BudgetRedirectStory = ({ + headline, +}: BudgetRedirectStoryProps): ReactElement => { + const { campaign } = useGivebackContext(); + + return ( + + + + + + {formatDonationAmount(campaign.goalAmount, campaign.currency)} goes + straight to the causes you pick: scholarships, open source, and + access to tech. We could have spent it on ads. We would rather let + the community decide what its work is worth. + + + + + + ); +}; diff --git a/packages/shared/src/features/giveback/components/CauseEmblem.tsx b/packages/shared/src/features/giveback/components/CauseEmblem.tsx new file mode 100644 index 00000000000..2fe53741370 --- /dev/null +++ b/packages/shared/src/features/giveback/components/CauseEmblem.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { SparkleIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import type { GivebackCause } from '../types'; + +// Brand-tinted emblems so each cause 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', +]; + +interface CauseEmblemProps { + cause: GivebackCause; + /** Position in the list, used to pick a stable brand tint for the fallback. */ + index: number; + className?: string; +} + +// Shows the cause's real logo on a light tile so each card reads as the actual +// nonprofit. Falls back to a brand-tinted sparkle emblem if the logo is missing +// or fails to load (kept in state so we don't mutate the DOM node directly). +export const CauseEmblem = ({ + cause, + index, + className, +}: CauseEmblemProps): ReactElement => { + const [failed, setFailed] = useState(false); + + if (cause.logoUrl && !failed) { + return ( + + setFailed(true)} + className="size-7 object-contain" + /> + + ); + } + + return ( + + + + ); +}; 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 00000000000..055696c2a77 --- /dev/null +++ b/packages/shared/src/features/giveback/components/CauseSelection.tsx @@ -0,0 +1,283 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, 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, + OpenLinkIcon, + PlusIcon, + VIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { useGivebackContext } from '../GivebackContext'; +import type { GivebackCause } from '../types'; +import { CauseSelectionModal } from './CauseSelectionModal'; +import { CauseEmblem } from './CauseEmblem'; +import { GivebackFilterChip } from './GivebackFilterChip'; + +// Synthetic first filter that shows our hand-picked developer causes. +const RECOMMENDED_FILTER = 'recommended'; + +interface CauseSelectionProps { + /** Called when the visitor confirms their causes (onboarding or settings). */ + onContinue: () => void; + /** Label for the confirm button. Defaults to "Continue" for onboarding. */ + continueLabel?: string; + /** + * Pin the confirm CTA to a sticky bottom bar (like the level bar) so it stays + * one tap away while scrolling the cause list. Used for the onboarding step. + */ + stickyContinue?: boolean; +} + +export const CauseSelection = ({ + onContinue, + continueLabel = 'Continue', + stickyContinue = false, +}: CauseSelectionProps): ReactElement => { + const { causes, suggestedCauses, toggleCause, userProfile } = + useGivebackContext(); + const [isSuggestOpen, setIsSuggestOpen] = useState(false); + + const isSelected = (cause: GivebackCause): boolean => + userProfile.selectedCauseIds.includes(cause.id); + + const selectedCount = causes.filter(isSelected).length; + + // Lead with a "Recommended" filter (our hand-picked developer causes), then + // one chip per cause category so people can browse by what they care about. + const categoryOptions = Array.from( + new Set( + causes + .map((cause) => cause.category) + .filter((category): category is string => Boolean(category)), + ), + ); + const [activeFilter, setActiveFilter] = useState(RECOMMENDED_FILTER); + const filtersRef = useRef(null); + const didMountRef = useRef(false); + + // Re-anchor the viewport to the filter row when the category changes: a + // filter that shrinks the grid collapses the page height, and without this + // the browser strands the scroll position in empty space below the results + // (the "jump to the bottom" layout shift). + useEffect(() => { + if (!didMountRef.current) { + didMountRef.current = true; + return; + } + const node = filtersRef.current; + if (node && typeof node.scrollIntoView === 'function') { + node.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [activeFilter]); + + const indexedCauses = causes.map((cause, index) => ({ cause, index })); + const visibleCauses = indexedCauses.filter(({ cause }) => + activeFilter === RECOMMENDED_FILTER + ? cause.recommended + : cause.category === activeFilter, + ); + + return ( + + + Pick as many as you like. daily.dev funds every donation, and you can + change them anytime. + + + + + + setActiveFilter(RECOMMENDED_FILTER)} + /> + {categoryOptions.map((category) => ( + setActiveFilter(category)} + /> + ))} + + +
+ {visibleCauses.map(({ cause, index }) => { + const selected = isSelected(cause); + + return ( +
+ {/* Full-card overlay handles the toggle so the "Learn more" + link can live inside the box without nesting interactives. */} +
+ ); + })} +
+
+ + {suggestedCauses.length > 0 && ( + + + Your suggestions: + + {suggestedCauses.map((cause) => ( + + + {cause.name} + + + pending review + + + ))} + + )} + + + {!stickyContinue && ( + + {selectedCount} selected + + )} + + + + {!stickyContinue && ( + + )} + + {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 00000000000..2c2780a049b --- /dev/null +++ b/packages/shared/src/features/giveback/components/CauseSelectionModal.tsx @@ -0,0 +1,180 @@ +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 { RootPortal } from '../../../components/tooltips/Portal'; +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. + + + + +
+ + + +
+ +