diff --git a/packages/shared/src/features/giveback/GivebackContext.tsx b/packages/shared/src/features/giveback/GivebackContext.tsx new file mode 100644 index 00000000000..4e1d4d0ae82 --- /dev/null +++ b/packages/shared/src/features/giveback/GivebackContext.tsx @@ -0,0 +1,418 @@ +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, + GivebackSponsor, + GivebackSponsorInput, + GivebackUserAction, + GivebackUserProfile, +} from './types'; +import { + GivebackActionValidationType, + GivebackCauseStatus, + GivebackUserActionStatus, +} from './types'; +import { + createMockCampaign, + createMockUserProfile, + givebackActions, + givebackCauses, + givebackCommunityEvents, + givebackLevels, + givebackSponsors, + 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; + 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; + 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 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 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 top the pot up on top of the baseline raised amount. + const approvedAmount = + baseApprovedAmount + (sponsoredAmount - SEED_SPONSORED_AMOUNT); + 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, + }; + + 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, + sponsorCampaign, + setUserActionStatus, + showCommunityFeed, + setShowCommunityFeed, + geoAvailability, + setGeoAvailability, + celebrationState, + setCelebrationState, + selectedCategory, + setSelectedCategory, + goalPercentage, + setGoalPercentage, + userLevel, + setUserLevel, + }; + }, [ + goalPercentage, + celebrationState, + 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..be9ee8cb2df --- /dev/null +++ b/packages/shared/src/features/giveback/GivebackNavContext.tsx @@ -0,0 +1,51 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { createContext, useContext } from 'react'; + +export type GivebackTabId = + | 'causes' + | 'impact' + | 'why' + | 'sponsors' + | '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 00000000000..c5edc62b887 --- /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 00000000000..664ffa3a1ec --- /dev/null +++ b/packages/shared/src/features/giveback/components/ActionCard.tsx @@ -0,0 +1,162 @@ +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 00000000000..ea172dd3f9c --- /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 00000000000..b5a41773f3e --- /dev/null +++ b/packages/shared/src/features/giveback/components/BudgetRedirectStory.tsx @@ -0,0 +1,32 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +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 ( + + + {formatDonationAmount(campaign.goalAmount, campaign.currency)} going to + the causes you 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 00000000000..8c7679809af --- /dev/null +++ b/packages/shared/src/features/giveback/components/CauseSelection.tsx @@ -0,0 +1,193 @@ +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 { 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 ( + + + Pick as many as you like — daily.dev funds every donation. Change them + anytime. + + + +
+ {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 00000000000..e28934c2b97 --- /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. + + + + +
+ + + +
+ +