Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ Prefix the change with one of these keywords:

## [Unreleased]

### Changed

- New campaign banner
- Updated footer links of FF

## [0.29.4]

### Added
Expand Down
44 changes: 31 additions & 13 deletions lib/components/CampaignBanner/CampaignBanner.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { CampaignBanner } from './CampaignBanner'

Expand All @@ -17,18 +16,37 @@ export default meta
type Story = StoryObj<typeof CampaignBanner>

export const Primary: Story = {
args: {
title: 'Fund the Future of Clean Energy Research',
raised: 42500,
goal: 100000,
endDate: '2026-08-27',
categories: [
{ name: 'Environment', link: 'https://example.com/environment' },
{ name: 'Science & Innovation', link: 'https://example.com/science' },
{ name: 'Student Experience' },
],
},
args: {},
render: (args) => {
return <CampaignBanner {...args} />
return (
<>
<CampaignBanner {...args}>
<CampaignBanner.Content
title="The Music Award for Indigenous, Black and Racialized Students"
tags={[
{ name: 'Music', link: 'https://example.com/music' },
{ name: 'Arts', link: 'https://example.com/arts' },
]}
categories={[
{ name: 'Arts', link: 'https://example.com/arts' },
{ name: 'Arts & Social Sciences', link: 'https://example.com/arts-social-sciences' },
{ name: 'Student Experience', link: 'https://example.com/student-experience' },
{ name: 'Scholarships', link: 'https://example.com/scholarships' },
{ name: 'Giving Day', link: 'https://example.com/giving-day' },
{ name: 'Healthcare', link: 'https://example.com/healthcare' },
]}
>
<p>
The Music Award for Indigenous, Black and Racialized Students is awarded annually to a student who is
entering or continuing in the undergraduate Music program.
</p>
</CampaignBanner.Content>
<CampaignBanner.Image>
<img src="https://picsum.photos/id/15/1600/900" alt="placeholder" />
</CampaignBanner.Image>
<CampaignBanner.Stats raised={1349} goal={5000} endDate="2026-10-27" />
</CampaignBanner>
</>
)
},
}
90 changes: 15 additions & 75 deletions lib/components/CampaignBanner/CampaignBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,20 @@
import { Badge } from '../Badge/Badge'
import { BadgeGroup } from '../BadgeGroup/BadgeGroup'
import { ButtonGroup } from '../ButtonGroup/ButtonGroup'
import { PageHeader } from '../PageHeader/PageHeader'
import { ProgressBar } from '../ProgressBar/ProgressBar'
import { formatCurrency } from '../../helpers/formatCurrency'

export interface CampaignCategory {
name: string
link?: string
}

interface CampaignBannerProps {
title: string
raised: number
goal: number
endDate: string
categories?: CampaignCategory[]
}

const parseDateLocal = (dateStr: string): Date => {
const [year, month, day] = dateStr.split('-').map(Number)
return new Date(year, month - 1, day)
}

const getTimeRemainingLabel = (endDate: string): string => {
const daysLeft = Math.ceil((parseDateLocal(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
if (daysLeft > 365) return 'more than a year to go'
if (daysLeft > 30) return `${Math.round(daysLeft / 30)} months to go`
return `${Math.max(daysLeft, 0)} days to go`
}

export const CampaignBanner = ({ title, raised, goal, endDate, categories }: CampaignBannerProps) => {
const percent = goal > 0 ? Math.min(Math.round((raised / goal) * 100), 100) : 0
const timeLabel = getTimeRemainingLabel(endDate)
const isActive = parseDateLocal(endDate).getTime() >= Date.now()
const statusText = isActive ? 'Active Campaign' : 'Campaign Completed'
const statusColor = isActive ? 'green' : 'red'
import type { ReactNode } from 'react'
import { CampaignBannerContent } from './CampaignBannerContent'
import { CampaignBannerImage } from './CampaignBannerImage'
import { CampaignBannerStats } from './CampaignBannerStats'

export const CampaignBannerWrapper = ({ children }: { children: ReactNode }) => {
return (
<div className="cu-campaign-banner max-w-5xl mx-auto flex flex-col lg:flex-row items-stretch gap-8 lg:gap-16 lg:rounded-sm mb-8 lg:mb-12">
<div className="lg:py-4 lg:w-3/5">
<BadgeGroup bottom={0} gap="2" left={0} right={0} top={0}>
<Badge color={statusColor} text={statusText} rounded="base" />
</BadgeGroup>

<PageHeader as="h1" header={title} size="lg" noUnderline />

{categories && categories.length > 0 && (
<BadgeGroup bottom={0} gap="2" left={0} right={0} top={0}>
{categories.map((cat) => (
<Badge key={cat.name} color="grey" text={cat.name} link={cat.link} rounded="base" />
))}
</BadgeGroup>
)}
</div>
<div className="lg:w-2/5 bg-cu-black-50 rounded-lg p-6 lg:px-10 lg:py-10 flex flex-col justify-center">
<div>
<div className="mb-2">
<span className="text-4xl font-bold">{formatCurrency(raised)}</span>
<span className="text-base text-cu-black-400 ml-2">of {formatCurrency(goal)}</span>
</div>
<div className="mb-1.5">
<ProgressBar value={raised} max={goal} />
</div>
<p className="text-sm text-cu-black-600 italic">
{percent}% funded with {timeLabel}
</p>
<ButtonGroup align="start" gap="5">
<a href="#fund-this-campaign" className="cu-button cu-button--full not-prose cu-button--red">
Fund this Project
</a>
</ButtonGroup>
</div>
</div>
</div>
<section className="cu-campaign-banner relative w-screen ml-offset-center bg-cu-black-25 grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-16 px-4 md:px-6 lg:px-0">
{children}
</section>
)
}

CampaignBanner.displayName = 'CampaignBanner'
export const CampaignBanner = Object.assign(CampaignBannerWrapper, {
Content: CampaignBannerContent,
Image: CampaignBannerImage,
Stats: CampaignBannerStats,
})

CampaignBannerWrapper.displayName = 'CampaignBanner'
72 changes: 72 additions & 0 deletions lib/components/CampaignBanner/CampaignBannerContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Badge } from '../Badge/Badge'
import { ButtonGroup } from '../ButtonGroup/ButtonGroup'
import { PageHeader } from '../PageHeader/PageHeader'
import { Button } from '../Button/Button'
import React from 'react'
import { useLinkContext } from '../LinkProvider/useLinkContext'

export interface CampaignCategory {
name: string
link: string
}

export interface CampaignTag {
name: string
link: string
}

export interface CampaignBannerContentProps {
children?: React.ReactNode
title: string
tags?: CampaignTag[]
categories?: CampaignCategory[]
}

export const CampaignBannerContent = ({ title, tags, categories, children }: CampaignBannerContentProps) => {
const LinkComponent = useLinkContext()

const tagNames =
tags && tags.length > 0
? tags.map((tag, index) => (
<>
<LinkComponent key={tag.name} href={tag.link}>
{tag.name}
</LinkComponent>
{index < tags.length - 1 ? ', ' : null}
</>
))
: ''

return (
<div className="lg:max-w-[calc(theme(maxWidth.5xl)/2)] lg:ml-auto lg:-mr-8 my-6 lg:my-14">
{/* Page Header */}
<PageHeader as="h1" header={title} size="lg" preHeader={tagNames}>
{children}
</PageHeader>

{/* Categories */}
{categories && categories.length > 0 && (
<div className="flex flex-wrap gap-3">
{categories.map((cat) => (
<React.Fragment key={cat.name}>
<Badge color="grey" text={cat.name} link={cat.link} rounded="base" />
</React.Fragment>
))}
</div>
)}

<ButtonGroup align="start" gap="5">
<Button
color="red"
type="button"
title="Fund this Project"
onClick={() => {
window.location.hash = 'fund-this-campaign'
}}
/>
</ButtonGroup>
</div>
)
}

CampaignBannerContent.displayName = 'CampaignBanner.Content'
9 changes: 9 additions & 0 deletions lib/components/CampaignBanner/CampaignBannerImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface CampaignBannerImageProps {
children: React.ReactNode
}

export const CampaignBannerImage = ({ children }: CampaignBannerImageProps) => {
return <div className="cu-campaign-banner__image hidden lg:block">{children}</div>
}

CampaignBannerImage.displayName = 'CampaignBanner.Image'
65 changes: 65 additions & 0 deletions lib/components/CampaignBanner/CampaignBannerStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { formatCurrency } from '../../helpers/formatCurrency'
import { Column } from '../../layouts/Column/Column'
import { Card } from '../Card/Card'

export interface CampaignBannerStatsProps {
raised: number
goal: number
endDate: string
}

const parseDateLocal = (dateStr: string): Date => {
const [year, month, day] = dateStr.split('-').map(Number)
return new Date(year, month - 1, day)
}

const getTimeRemainingLabel = (endDate: string): string => {
const daysLeft = Math.ceil((parseDateLocal(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
if (daysLeft > 365) return 'more than a year to go'
if (daysLeft > 30) return `${Math.round(daysLeft / 30)} months to go`
return `${Math.max(daysLeft, 0)} days left`
}

export const CampaignBannerStats = ({ raised, goal, endDate }: CampaignBannerStatsProps) => {
const percent = goal > 0 ? Math.min(Math.round((raised / goal) * 100), 100) : 0

const StatData = [
{
id: 'database-regular',
stat: formatCurrency(raised),
desc: 'Amount Raised',
},
{
id: 'bullseye-arrow',
stat: formatCurrency(goal),
desc: 'Goal',
},
{
id: 'file-chart-pie',
stat: `${percent}%`,
desc: 'of Goal Reached',
reverse: true,
},
{
id: 'calendar-days',
stat: `${getTimeRemainingLabel(endDate)}`,
desc: '',
},
]

return (
<div className="col-span-full w-full max-w-5xl mx-auto mb-4 lg:mb-8">
<Column cols="4">
{StatData.slice(0, 4).map(({ id, stat, desc, reverse }) => (
<>
<Card key={id} leftBorder noHover>
<Card.Stats stat={stat} desc={desc} reverse={reverse} />
</Card>
</>
))}
</Column>
</div>
)
}

CampaignBannerStats.displayName = 'CampaignBanner.Stats'
29 changes: 23 additions & 6 deletions lib/components/CampaignBanner/styles.css
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
.cu-campaign-banner p + .cu-badgegroup {
@apply pt-5;
.cu-section > .cu-campaign-banner {
margin-top: -40px;
@apply -mt-6 lg:-mt-10;
}

.cu-campaign-banner .cu-pageheader {
@apply mt-6 lg:mt-8;
.cu-campaign-banner__image {
position: relative;
background-color: #191919;
}

.cu-campaign-banner .cu-badgegroup:not(:first-child) {
@apply mt-4 lg:mt-6;
.cu-campaign-banner__image:after {
content: '';
display: block;
position: absolute;
top: 0;
right: 0;
width: calc(100% - 1024px / 2);
height: 100%;
background-image: linear-gradient(270deg, #191919, transparent);
}

.cu-campaign-banner__image img {
object-fit: cover;
width: 100%;
height: 100%;
margin: 0;
border-radius: 0;
}
2 changes: 1 addition & 1 deletion lib/components/PageHeader/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const SIZE_CONFIG = {
export interface PageHeaderProps {
children?: React.ReactNode
as?: 'h1' | 'h2' | 'h3'
preHeader?: string
preHeader?: string | React.ReactNode
header: string
content?: string
metaData?: React.ReactNode
Expand Down
Loading
Loading