feat: project details modal

This commit is contained in:
MTG2000
2022-10-06 13:03:13 +03:00
parent 9c388ecdb4
commit 034b3317aa
37 changed files with 1674 additions and 41 deletions

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import Categories from './Categories';
export default {
title: 'Projects/Explore Page/Categories',
component: Categories,
} as ComponentMeta<typeof Categories>;
const Template: ComponentStory<typeof Categories> = (args) => <Categories />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,68 @@
import { useNavigate } from 'react-router-dom';
import { useAllCategoriesQuery } from 'src/graphql';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { useCarousel } from 'src/utils/hooks';
const colors = [
'#FDF2F8',
'#F5F3FF',
'#FEFCE8',
'#F0FDF4',
'#EFF6FF',
'#FFFBEB',
'#FEF2F2',
'#FDF2F8',
'#FFF7ED',
'#F1F5F9'
]
export default function Categories() {
const { viewportRef, scrollSlides, canScrollNext, canScrollPrev, isClickAllowed } = useCarousel({
align: 'start', slidesToScroll: 2,
containScroll: "trimSnaps",
})
const { data, loading } = useAllCategoriesQuery();
const navigate = useNavigate();
if (loading || !data)
return <div className="flex gap-12">
{Array(5).fill(0).map((_, idx) =>
<div
key={idx}
className=' block p-16 rounded-16 bg-gray-100 active:scale-90 transition-transform'
>
<span className="opacity-0">category</span>
</div>
)}
</div>
return (
<div className="relative group">
<div className="overflow-hidden" ref={viewportRef}>
<div className="select-none w-full flex gap-16">
{data?.categoryList?.filter(c => c !== null).map((category, idx) =>
<button
key={category!.id}
onClick={() => { }}
className='min-w-max block p-16 rounded-16 hover:bg-gray-100 active:bg-gray-200 active:scale-90 transition-transform'
style={{ backgroundColor: colors[idx % colors.length] }}
>{category!.icon} {category!.name}</button>
)}
</div>
</div>
<button className={`absolute text-body6 w-[28px] aspect-square flex justify-center items-center left-0 -translate-x-1/2 top-1/2 -translate-y-1/2 rounded-full bg-white text-gray-400 opacity-0 ${canScrollPrev && 'group-hover:opacity-100'} active:scale-90 transition-opacity border border-gray-200 shadow-md`} onClick={() => scrollSlides(-1)}>
<FaChevronLeft />
</button>
<button className={`absolute text-body6 w-[28px] aspect-square flex justify-center items-center right-0 translate-x-1/2 top-1/2 -translate-y-1/2 rounded-full bg-white text-gray-400 opacity-0 ${canScrollNext && 'group-hover:opacity-100'} active:scale-90 transition-opacity border border-gray-200 shadow-md`} onClick={() => scrollSlides(1)}>
<FaChevronRight />
</button>
</div>
)
}

View File

@@ -0,0 +1,7 @@
query AllCategories {
categoryList {
id
name
icon
}
}

View File

@@ -20,10 +20,10 @@ export default function ProjectCardMini({ project, onClick }: Props) {
tabIndex={0}
role='button'
>
<img src={project?.logo?.[0]['thumbnails']['large'].url} alt={project?.project ?? ''} draggable="false" className="flex-shrink-0 w-64 h-64 bg-gray-200 border-0 rounded-full hover:cursor-pointer"></img>
<img src={project?.logo?.[0]['thumbnails']['large'].url} alt={project?.title ?? ''} draggable="false" className="flex-shrink-0 w-64 h-64 bg-gray-200 border-0 rounded-full hover:cursor-pointer"></img>
<div className="justify-around items-start min-w-0 flex-1 hover:cursor-pointer"
>
<p className="text-body4 w-full font-bold overflow-ellipsis overflow-hidden whitespace-nowrap">{project?.category}</p>
<p className="text-body4 w-full font-bold overflow-ellipsis overflow-hidden whitespace-nowrap">{project?.title}</p>
{/* <p className="text-body5 text-gray-600 font-light my-[5px]">{project?.category.title}</p> */}
{/* <span className="chip-small bg-warning-50 text-yellow-700 font-light text-body5 py-[3px] px-10"> <MdLocalFireDepartment className='inline-block text-fire transform text-body4 align-middle' /> {numberFormatter(project?.votes_count)} </span> */}
</div>

View File

@@ -4,6 +4,7 @@ import { useExplorePageQuery } from 'src/graphql';
import HeaderImage from './HeaderImage/HeaderImage';
import ProjectsGrid from './ProjectsGrid/ProjectsGrid';
import { Helmet } from "react-helmet";
import Categories from '../../Components/Categories/Categories';
export default function ExplorePage() {
@@ -38,6 +39,7 @@ export default function ExplorePage() {
img={data?.getCategory.cover_image!}
apps_count={data?.getCategory.apps_count!}
/> */}
<Categories />
<div className="mt-40">
<ProjectsGrid
isLoading={loading}

View File

@@ -1,7 +1,7 @@
query ExplorePage($page: JSON, $pageSize: JSON) {
projects(_page: $page, _page_size: $pageSize) {
id
project
title
category
logo
yearFounded

View File

@@ -0,0 +1,17 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import Claim_CopySignatureCard from './Claim_CopySignatureCard';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Projects/Claim/Copy Signature Card',
component: Claim_CopySignatureCard,
decorators: [ModalsDecorator]
} as ComponentMeta<typeof Claim_CopySignatureCard>;
const Template: ComponentStory<typeof Claim_CopySignatureCard> = (args) => <Claim_CopySignatureCard {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,61 @@
import { motion } from 'framer-motion'
import { Direction, replaceModal } from 'src/redux/features/modals.slice';
import { useAppDispatch, useAppSelector } from 'src/utils/hooks';
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
import CopyToClipboard from 'src/Components/CopyToClipboard/CopyToClipboard'
import { useCallback } from 'react';
import { IoClose } from 'react-icons/io5';
export default function Claim_CopySignatureCard({ onClose, direction, ...props }: ModalCard) {
const dispatch = useAppDispatch();
const { projectName, image } = useAppSelector(state => ({ projectName: state.project.project?.title, image: state.project.project?.thumbnail_image, }))
const generatedHash = '0x000330RaaSt302440zxc327jjiaswf19987183345pRReuBNksbaaueee'
const handleNext = useCallback(() => {
dispatch(replaceModal({
Modal: 'Claim_SubmittedCard',
direction: Direction.NEXT
}))
}, [dispatch])
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[343px] p-24 rounded-xl relative"
>
<IoClose className='absolute text-body2 top-24 right-24 hover:cursor-pointer' onClick={onClose} />
<h2 className='text-h5 font-bold'>Claim this project</h2>
<div className="flex justify-center my-32">
<img
src={image}
className='w-80 h-80 object-cover rounded-2xl'
alt="" />
</div>
<p className="text-body4 text-center px-16">
Good job! Now paste this on the webpage
<a className="font-bold" href="www.projectname.com/"
target='_blank' rel='noreferrer'
> www.projectname.com/</a>
</p>
<div className="input-wrapper mt-32">
<input
className="input-text overflow-ellipsis"
type='text'
value={generatedHash}
/>
<CopyToClipboard text={generatedHash} />
</div>
<div className="mt-32">
<button className='btn btn-primary w-full' onClick={handleNext}>Submit for review</button>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,17 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import Claim_FundWithdrawCard from './Claim_FundWithdrawCard';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Projects/Claim/Fund Withdraw Card',
component: Claim_FundWithdrawCard,
decorators: [ModalsDecorator]
} as ComponentMeta<typeof Claim_FundWithdrawCard>;
const Template: ComponentStory<typeof Claim_FundWithdrawCard> = (args) => <Claim_FundWithdrawCard {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,38 @@
import { motion } from 'framer-motion'
// import { useAppDispatch } from '../../utils/hooks';
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
export default function Claim_FundWithdrawCard({ onClose, direction, ...props }: ModalCard) {
//const dispatch = useAppDispatch();
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[260px] py-16 px-24 rounded-xl relative"
>
<div className="flex justify-center my-16">
<img
src={'assets/icons/lightning-small.svg'}
className='w-48 h-48 object-cover rounded-full'
alt="" />
</div>
<p className="text-h4 text-center font-bold">
2,220 sats
</p>
<p className="text-body4 text-center text-gray-400">
2.78$
</p>
<div className="mt-16 flex flex-col gap-8">
<button className='btn btn-primary w-full shadow-xs' >Fund</button>
<button className='btn border w-full shadow-xs' >Withdraw</button>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,16 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import Claim_GenerateSignatureCard from './Claim_GenerateSignatureCard';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Projects/Claim/Generate Signature Card',
component: Claim_GenerateSignatureCard,
decorators: [ModalsDecorator]
} as ComponentMeta<typeof Claim_GenerateSignatureCard>;
const Template: ComponentStory<typeof Claim_GenerateSignatureCard> = (args) => <Claim_GenerateSignatureCard {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,59 @@
import { motion } from 'framer-motion'
import { Direction, replaceModal } from 'src/redux/features/modals.slice';
import { useAppDispatch, useAppSelector } from 'src/utils/hooks';
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
import { useCallback, useEffect } from 'react';
import { IoClose } from 'react-icons/io5';
export default function Claim_GenerateSignatureCard({ onClose, direction, ...props }: ModalCard) {
const dispatch = useAppDispatch();
const { projectName, image } = useAppSelector(state => ({ projectName: state.project.project?.title, image: state.project.project?.thumbnail_image, }))
const handleNext = useCallback(() => {
dispatch(replaceModal({
Modal: 'Claim_CopySignatureCard',
direction: Direction.NEXT
}))
}, [dispatch])
useEffect(() => {
// const timeout = setTimeout(handleNext, 3000)
// return () => clearTimeout(timeout)
}, [handleNext])
//const onCopy = () => {
// // Copy to Clipboard
// setTimeout(handleNext, 2000)
//}
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[343px] p-24 rounded-xl relative"
>
<IoClose className='absolute text-body2 top-24 right-24 hover:cursor-pointer' onClick={onClose} />
<h2 className='text-h5 font-bold'>Claim this project</h2>
<div className="flex justify-center my-32">
<img
src={image}
className='w-80 h-80 object-cover rounded-2xl'
alt="" />
</div>
<p className="text-body4 text-center px-16">
To claim ownership of <span className="font-bold">{projectName}</span> and its tips, you need to sign a message and paste this on the project website so we can verify you are the real ownership
</p>
<div className="mt-32">
<button className='btn btn-primary w-full' onClick={handleNext}>Generate Signature</button>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,17 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import Claim_SubmittedCard from './Claim_SubmittedCard';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Projects/Claim/Submitted Card',
component: Claim_SubmittedCard,
decorators: [ModalsDecorator]
} as ComponentMeta<typeof Claim_SubmittedCard>;
const Template: ComponentStory<typeof Claim_SubmittedCard> = (args) => <Claim_SubmittedCard {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,39 @@
import { motion } from 'framer-motion'
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
import { IoClose } from 'react-icons/io5';
export default function Claim_SubmittedCard({ onClose, direction, ...props }: ModalCard) {
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[343px] p-24 rounded-xl relative"
>
<IoClose
className='absolute text-body2 top-24 right-24 hover:cursor-pointer'
onClick={onClose} />
<h2 className='text-h5 font-bold'>Submitted For Review</h2>
<div className="flex justify-center my-32">
<img
src="assets/icons/flag-icon.svg"
className='w-80 h-80'
alt="success" />
</div>
<p className="text-body4 text-center">
Great work! your claim to <span className="font-bold">Application Name</span> has been submitted for review.
<br />
Check back soon to see if it was successful.
</p>
</motion.div>
)
}

View File

@@ -0,0 +1,7 @@
import { lazyModal } from "src/utils/helperFunctions";
export const { LazyComponent: Claim_CopySignatureCard } = lazyModal(() => import('./Claim_CopySignatureCard'))
export const { LazyComponent: Claim_FundWithdrawCard } = lazyModal(() => import('./Claim_FundWithdrawCard'))
export const { LazyComponent: Claim_GenerateSignatureCard } = lazyModal(() => import('./Claim_GenerateSignatureCard'))
export const { LazyComponent: Claim_SubmittedCard } = lazyModal(() => import('./Claim_SubmittedCard'))

View File

@@ -0,0 +1,130 @@
import linkifyHtml from 'linkifyjs/lib/linkify-html'
import { useState } from 'react'
import { MdLocalFireDepartment } from 'react-icons/md'
import Button from 'src/Components/Button/Button'
import Card from 'src/Components/Card/Card'
import Lightbox from 'src/Components/Lightbox/Lightbox'
import { ProjectDetailsQuery, ProjectLaunchStatusEnum, ProjectPermissionEnum, } from 'src/graphql'
import { openModal } from 'src/redux/features/modals.slice'
import { setVoteAmount } from 'src/redux/features/vote.slice'
import { numberFormatter } from 'src/utils/helperFunctions'
import { useAppDispatch } from 'src/utils/hooks'
import { createRoute } from 'src/utils/routing'
import LinksCard from '../LinksCard/LinksCard'
interface Props {
project: Pick<ProjectDetailsQuery['getProject'],
| "id"
| "cover_image"
| "thumbnail_image"
| "title"
| "category"
| "permissions"
| "launch_status"
| "description"
| "screenshots"
| "tagline"
| "website"
| "votes_count"
| 'discord'
| 'website'
| 'github'
| 'twitter'
| 'slack'
| 'telegram'
>
}
export default function AboutCard({ project }: Props) {
const dispatch = useAppDispatch();
const [screenshotsOpen, setScreenshotsOpen] = useState(-1);
const onVote = (votes?: number) => {
dispatch(setVoteAmount(votes ?? 10));
dispatch(openModal({
Modal: 'VoteCard', props: {
projectId: project.id,
title: project.title,
initVotes: votes
}
}))
}
const canEdit = project.permissions.includes(ProjectPermissionEnum.UpdateInfo);
return (
<Card defaultPadding={false} onlyMd>
{/* Cover Image */}
<div className="hidden md:block relative rounded-t-12 md:rounded-t-16 h-[120px] lg:h-[160px]">
<img className="w-full h-full object-cover rounded-12 md:rounded-0 md:rounded-t-16" src={project.cover_image} alt="" />
<div className="absolute top-16 md:top-24 left-24 flex gap-8 bg-gray-800 bg-opacity-60 text-white rounded-48 py-4 px-12 text-body6 font-medium">
{project.launch_status === ProjectLaunchStatusEnum.Launched && `🚀 Launched`}
{project.launch_status === ProjectLaunchStatusEnum.Wip && `🔧 WIP`}
</div>
<div className="absolute left-24 bottom-0 translate-y-1/2 w-[108px] aspect-square">
<img className="w-full h-full border-2 border-white rounded-24" src={project.thumbnail_image} alt="" />
</div>
</div>
<div className="md:p-24 md:pt-0 flex flex-col gap-24">
{/* Title & Basic Info */}
<div className="flex flex-col gap-24 relative">
<div className="flex flex-wrap justify-end items-center gap-16 min-h-[40px] mt-12">
{canEdit && <Button size="sm" color="gray" href={createRoute({ type: "edit-project", id: project.id })}>Edit Project</Button>}
<Button size="sm" variant='outline' color='gray' className='hidden md:block hover:!text-red-500 hover:!border-red-200 hover:!bg-red-50' onClick={() => onVote()}>
<MdLocalFireDepartment />{<span className="align-middle w-[4ch]"> {numberFormatter(project.votes_count)}</span>}
</Button>
</div>
<div className='flex flex-col gap-8 items-start justify-between -mt-12'>
<a href={project.website} target='_blank' rel="noreferrer"><h3 className="text-body1 font-bold">{project.title}</h3></a>
<p className="text-body4 text-gray-600">{project.tagline}</p>
<div>
<span className="font-medium text-body5 text-gray-900">{project.category.icon} {project.category.title}</span>
</div>
</div>
<Button size="sm" fullWidth variant='outline' color='gray' className='md:hidden hover:!text-red-500 hover:!border-red-200 hover:!bg-red-50' onClick={() => onVote()}>
<MdLocalFireDepartment />{<span className="align-middle w-[4ch]"> {numberFormatter(project.votes_count)}</span>}
</Button>
</div>
<div className="md:hidden">
<LinksCard links={project} />
</div>
{/* About */}
<div>
<div className="text-body4 text-gray-600 leading-normal whitespace-pre-line" dangerouslySetInnerHTML={{
__html: linkifyHtml(project.description, {
className: ' text-blue-500 underline',
defaultProtocol: 'https',
target: "_blank",
rel: 'noreferrer'
})
}}></div>
</div>
{project.screenshots.length > 0 && <>
<div className="">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 justify-items-center">
{project.screenshots.slice(0, 4).map((screenshot, idx) => <div
key={idx}
className="w-full relative pt-[56%] cursor-pointer bg-gray-100 border rounded-10 overflow-hidden"
onClick={() => setScreenshotsOpen(idx)}
>
<img src={screenshot} className="absolute top-0 left-0 w-full h-full object-cover" alt='' />
</div>)}
</div>
</div>
<Lightbox
images={project.screenshots}
isOpen={screenshotsOpen !== -1}
initOpenIndex={screenshotsOpen}
onClose={() => setScreenshotsOpen(-1)}
/>
</>}
</div>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import React from 'react'
import Badge from 'src/Components/Badge/Badge'
import Card from 'src/Components/Card/Card'
import { ProjectDetailsQuery } from 'src/graphql'
interface Props {
capabilities: ProjectDetailsQuery['getProject']['capabilities']
}
export default function CapabilitiesCard({ capabilities }: Props) {
return (
<Card onlyMd>
<p className="text-body6 max-md:uppercase max-md:text-gray-400 md:text-body2 font-bold">🦾 Capabilities</p>
<div className="mt-16">
{capabilities.length === 0 && <>
<p className="text-gray-700 text-body4">No capabilities added</p>
</>}
<div className="flex flex-wrap gap-8">
{capabilities.map(cap => <Badge key={cap.id} size='sm'>{cap.icon} {cap.title}</Badge>)}
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,78 @@
import Card from 'src/Components/Card/Card'
import { Project } from 'src/graphql'
import { FaDiscord, } from 'react-icons/fa';
import { FiGithub, FiGlobe, FiTwitter } from 'react-icons/fi';
import CopyToClipboard from 'react-copy-to-clipboard';
import { NotificationsService, } from 'src/services'
interface Props {
links: Pick<Project, 'discord' | 'website' | 'github' | 'twitter' | 'slack' | 'telegram'>
}
export default function LinksCard({ links }: Props) {
const linksList = [
{
value: links.discord,
text: links.discord,
icon: FaDiscord,
colors: "bg-violet-100 text-violet-900",
},
{
value: links.website,
text: links.website?.replace(/(^\w+:|^)\/\//, '').replace(/\/$/, ""),
icon: FiGlobe,
colors: "bg-gray-100 text-gray-900",
url: links.website
},
{
value: links.twitter,
text: links.twitter,
icon: FiTwitter,
colors: "bg-blue-100 text-blue-500",
url: links.twitter
},
{
value: links.github,
text: links.github,
icon: FiGithub,
colors: "bg-pink-100 text-pink-600",
url: links.github
},
];
return (
<Card onlyMd>
<p className="text-body2 font-bold mb-16 hidden md:block">🔗 Links</p>
<div className="">
{linksList.length === 0 && <>
<p className="text-gray-700 text-body4">No links added</p>
</>}
<div className="flex flex-wrap gap-16">
{linksList.filter(link => !!link.value).map((link, idx) =>
(link.url ? <a
key={idx}
href={link.url!}
className={`w-40 aspect-square rounded-full flex justify-center items-center ${link.colors}`}
target='_blank'
rel="noreferrer">
<link.icon className="scale-125" />
</a>
:
<CopyToClipboard
text={link.value!}
onCopy={() => NotificationsService.info(" Copied to clipboard", { icon: "📋" })}
>
<button
key={idx}
onClick={() => { }}
className={`w-40 aspect-square rounded-full flex justify-center items-center ${link.colors}`}
>
<link.icon className="scale-125" />
</button>
</CopyToClipboard>
))}
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,50 @@
import { Link } from 'react-router-dom'
import Badge from 'src/Components/Badge/Badge'
import Card from 'src/Components/Card/Card'
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar'
import { sortMembersByRole } from 'src/features/Projects/utils/helperFunctions'
import { ProjectDetailsQuery } from 'src/graphql'
import { createRoute } from 'src/utils/routing'
interface Props {
members: ProjectDetailsQuery['getProject']['members']
recruit_roles: ProjectDetailsQuery['getProject']['recruit_roles']
}
export default function MakersCard({ members, recruit_roles }: Props) {
return (
<Card onlyMd>
<p className="text-body6 max-md:uppercase max-md:text-gray-400 md:text-body2 font-bold">👾 Makers</p>
<div className="mt-16">
<div className="flex flex-wrap gap-8">
{members.length === 0 && <p className="text-body4 text-gray-500">Not listed</p>}
{sortMembersByRole(members).map(m => <Link key={m.user.id} to={createRoute({ type: "profile", id: m.user.id, username: m.user.name })}>
<Avatar
width={40}
src={m.user.avatar}
renderTooltip={() => <div className='bg-white px-12 py-8 border border-gray-200 rounded-12 flex flex-wrap gap-12 shadow-lg'>
<Avatar width={48} src={m.user.avatar} />
<div className='overflow-hidden'>
<p className={`text-black font-medium overflow-hidden text-ellipsis`}>{m.user.name}</p>
<p className={`text-body6 text-gray-600`}>{m.user.jobTitle}</p>
</div>
</div>}
/>
</Link>)}
</div>
</div>
<p className="text-body6 uppercase font-medium text-gray-400 mt-24">Open roles</p>
<div className="mt-8">
{recruit_roles.length === 0 && <>
<p className="text-gray-700 text-body4">No open roles for now</p>
</>}
<div className="flex flex-wrap gap-8">
{recruit_roles.map(role => <Badge key={role.id} size='sm'>{role.icon} {role.title}</Badge>)}
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,25 @@
import Badge from 'src/Components/Badge/Badge'
import Card from 'src/Components/Card/Card'
import { ProjectDetailsQuery } from 'src/graphql'
interface Props {
recruit_roles: ProjectDetailsQuery['getProject']['recruit_roles']
}
export default function OpenRolesCard({ recruit_roles }: Props) {
return (
<Card onlyMd>
<p className="text-body2 font-bold">👀 Open roles</p>
<div className="mt-16">
{recruit_roles.length === 0 && <>
<p className="text-gray-700 text-body4">No open roles for now</p>
</>}
<div className="flex flex-wrap gap-16">
{recruit_roles.map(role => <Badge key={role.id} size='sm'>{role.icon} {role.title}</Badge>)}
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,20 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import SimilarProjectsCard from './SimilarProjectsCard';
export default {
title: 'Projects/Project Page/Similar Projects Card',
component: SimilarProjectsCard,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof SimilarProjectsCard>;
const Template: ComponentStory<typeof SimilarProjectsCard> = (args) => <div className="max-w-[326px]"><SimilarProjectsCard {...args as any} ></SimilarProjectsCard></div>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,38 @@
import { Link } from 'react-router-dom'
import Card from 'src/Components/Card/Card'
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar'
import { User, useSimilarProjectsQuery } from 'src/graphql'
import { createRoute } from 'src/utils/routing'
interface Props {
id: number
}
export default function SimilarProjectsCard({ id }: Props) {
const query = useSimilarProjectsQuery({ variables: { projectId: id } })
if (query.loading) return null;
if (query.data?.similarProjects.length === 0) return null;
return (
<Card onlyMd>
<h3 className="text-body2 font-bolder">🚀 Similar projects</h3>
<ul className='flex flex-col'>
{query.data?.similarProjects.map(project => {
return <Link key={project.id} to={createRoute({ type: "project", tag: project.hashtag })} className="md:border-b py-16 last-of-type:border-b-0 last-of-type:pb-0">
<li className="flex items-center gap-12">
<img className='w-48 aspect-square rounded-12 border border-gray-100' alt='' src={project.thumbnail_image} />
<div className='overflow-hidden'>
<p className="text-body4 text-gray-800 font-medium whitespace-nowrap overflow-hidden text-ellipsis">{project.title}</p>
<p className="text-body5 text-gray-500">{project.category.icon} {project.category.title}</p>
</div>
</li>
</Link>
})}
</ul>
</Card>
)
}

View File

@@ -0,0 +1,31 @@
query ProjectDetails($projectsId: String) {
projects(id: $projectsId) {
id
title
dead
createdAt
companyName
category
categoryList {
name
}
description
discord
endDate
twitter
updatedAt
watchers
website
yearFounded
telegram
subcategory
stars
repository
openSource
logo
linkedIn
license
language
forks
}
}

View File

@@ -0,0 +1,79 @@
import { motion } from 'framer-motion'
import { MdClose, } from 'react-icons/md';
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer';
import Skeleton from 'react-loading-skeleton';
import Badge from 'src/Components/Badge/Badge';
import { useMediaQuery } from 'src/utils/hooks';
import { MEDIA_QUERIES } from 'src/utils/theme';
import Button from 'src/Components/Button/Button';
interface Props extends ModalCard {
}
export default function ProjectDetailsCardSkeleton({ onClose, direction, ...props }: Props) {
const isMdScreen = useMediaQuery(MEDIA_QUERIES.isMedium)
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className={`modal-card max-w-[676px] ${props.isPageModal && !isMdScreen && 'rounded-0 w-full min-h-screen'}`}
>
<div className="relative h-[100px] lg:h-[80px]">
<Skeleton height='100%' className='!leading-inherit' />
<button className="w-32 h-32 bg-gray-600 bg-opacity-80 text-white absolute top-24 right-24 rounded-full hover:bg-gray-800 text-center" onClick={onClose}><MdClose className=' inline-block' /></button>
</div>
<div className="p-24 flex flex-col gap-24">
<div className="flex flex-col mt-[-80px] md:flex-row md:mt-0 gap-24 items-start relative">
<div className="flex-shrink-0 w-[108px] h-[108px] ">
<Skeleton height='100%' className='rounded-24 border-2 border-white' />
</div>
<div className='flex flex-col gap-8 items-start justify-between'>
<h3 className="text-body1 font-bold"><Skeleton width='13ch' /></h3>
<p className="text-body4 text-gray-600"><Skeleton width='30ch' /></p>
<div>
<span className="font-medium text-body4 text-gray-600"><Skeleton width='10ch' /></span>
</div>
</div>
<div className="flex-shrink-0 w-full md:w-auto md:flex ml-auto gap-16 self-stretch">
<Button fullWidth variant='outline' color='gray' className='!px-8'>
<p className='opacity-0'>votes</p>
</Button>
</div>
</div>
<p className="text-body4 leading-normal">
<Skeleton width='98%' />
<Skeleton width='90%' />
<Skeleton width='70%' />
<Skeleton width='40%' />
</p>
<div className="flex flex-wrap gap-16">
<Skeleton width='40px' height='40px' className='rounded-full' />
<Skeleton width='40px' height='40px' className='rounded-full' />
</div>
<div >
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 justify-items-center">
{
Array(4).fill(0).map((_, idx) => <div key={idx} className="w-full relative pt-[56%] cursor-pointer bg-gray-200 shadow-sm rounded-10 overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full object-cover"></div>
</div>)
}
</div>
</div>
<div className="text-center h-[46px]"></div>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,25 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import ProjectDetailsCard from './ProjectDetailsCard';
import ProjectDetailsCardSkeleton from './ProjectDetailsCard.Skeleton';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Projects/Project Page/Project Details Modal',
component: ProjectDetailsCard,
decorators: [ModalsDecorator],
} as ComponentMeta<typeof ProjectDetailsCard>;
const Template: ComponentStory<typeof ProjectDetailsCard> = (args) => <ProjectDetailsCard {...args} />;
export const Default = Template.bind({});
Default.args = {
projectId: 1,
isPageModal: true
}
const LoadingTemplate: ComponentStory<typeof ProjectDetailsCardSkeleton> = (args) => <ProjectDetailsCardSkeleton {...args} />;
export const LoadingState = LoadingTemplate.bind({
isPageModal: true
})

View File

@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react'
import { MdLocalFireDepartment } from 'react-icons/md';
import { ModalCard } from 'src/Components/Modals/ModalsContainer/ModalsContainer';
import { useAppDispatch, useAppSelector, useMediaQuery } from 'src/utils/hooks';
import { openModal, scheduleModal } from 'src/redux/features/modals.slice';
import { setProject } from 'src/redux/features/project.slice';
import Button from 'src/Components/Button/Button';
import ProjectCardSkeleton from './ProjectDetailsCard.Skeleton'
// import VoteButton from 'src/features/Projects/pages/ProjectPage/VoteButton/VoteButton';
import { NotificationsService, Wallet_Service } from 'src/services'
import { ProjectLaunchStatusEnum, ProjectPermissionEnum, useProjectDetailsQuery } from 'src/graphql';
import Lightbox from 'src/Components/Lightbox/Lightbox'
import linkifyHtml from 'linkify-html';
import ErrorMessage from 'src/Components/Errors/ErrorMessage/ErrorMessage';
import { setVoteAmount } from 'src/redux/features/vote.slice';
import { numberFormatter } from 'src/utils/helperFunctions';
import { MEDIA_QUERIES } from 'src/utils/theme';
import { FaDiscord, } from 'react-icons/fa';
import { FiEdit2, FiGithub, FiGlobe, FiTwitter } from 'react-icons/fi';
import CopyToClipboard from 'react-copy-to-clipboard';
import Badge from 'src/Components/Badge/Badge';
import { Link } from 'react-router-dom';
import { createRoute } from 'src/utils/routing';
import { IoMdClose } from 'react-icons/io';
interface Props extends ModalCard {
projectId: string;
}
export default function ProjectDetailsCard({ direction, projectId, ...props }: Props) {
const dispatch = useAppDispatch();
const [screenshotsOpen, setScreenshotsOpen] = useState(-1);
const { isWalletConnected } = useAppSelector(state => ({
isWalletConnected: state.wallet.isConnected,
}));
const isMdScreen = useMediaQuery(MEDIA_QUERIES.isMedium)
const { data, loading, error } = useProjectDetailsQuery({
variables: {
projectsId: projectId!,
},
onCompleted: data => {
dispatch(setProject((data.projects?.[0] as any) ?? null))
},
onError: () => {
dispatch(setProject(null));
},
skip: !Boolean(projectId)
});
const closeModal = () => {
props.onClose?.();
}
if (error)
return <div
className={`modal-card max-w-[768px] ${props.isPageModal && !isMdScreen && 'rounded-0 w-full min-h-screen'}`}
>
<div className="p-64">
<ErrorMessage type='fetching' message='Something Wrong happened while fetching project details, please try refreshing the page' />
</div>
</div>
if (loading)
return <ProjectCardSkeleton onClose={closeModal} direction={direction} isPageModal={props.isPageModal} />;
const project = data?.projects?.[0];
if (!project) return <p>404</p>
const links = [
{
value: project.discord,
text: project.discord,
icon: FaDiscord,
colors: "bg-violet-100 text-violet-900",
},
{
value: project.website,
text: project.website?.replace(/(^\w+:|^)\/\//, '').replace(/\/$/, ""),
icon: FiGlobe,
colors: "bg-gray-100 text-gray-900",
url: project.website
},
{
value: project.twitter,
text: project.twitter,
icon: FiTwitter,
colors: "bg-blue-100 text-blue-500",
url: project.twitter
},
{
value: project.github,
text: project.github,
icon: FiGithub,
colors: "bg-pink-100 text-pink-600",
url: project.github
},
];
const onVote = (votes?: number) => {
dispatch(setVoteAmount(votes ?? 10));
// dispatch(openModal({
// Modal: 'VoteCard', props: {
// projectId: project.id,
// title: project.title,
// initVotes: votes
// }
// }))
}
return (
<div
className={`modal-card max-w-[676px] ${(props.isPageModal && !isMdScreen) && '!rounded-0 w-full min-h-screen'}`}
>
{/* Cover Image */}
<div className="relative h-[120px] lg:h-[80px]">
<img className="w-full h-full object-cover" src={project.cover_image} alt="" />
<div className="absolute w-full px-16 md:px-24 top-16 md:top-24 flex justify-between items-center">
<div className="flex gap-8 bg-gray-800 bg-opacity-60 text-white rounded-48 py-4 px-12 text-body6 font-medium">
{project.launch_status === ProjectLaunchStatusEnum.Launched && `🚀 Launched`}
{project.launch_status === ProjectLaunchStatusEnum.Wip && `🔧 WIP`}
</div>
<div className="flex gap-8">
{project.permissions.includes(ProjectPermissionEnum.UpdateInfo) &&
<Link className="w-32 h-32 bg-gray-800 bg-opacity-60 text-white rounded-full hover:bg-opacity-40 text-center flex flex-col justify-center items-center" onClick={() => props.onClose?.()} to={createRoute({ type: "edit-project", id: project.id })}><FiEdit2 /></Link>}
<button className="w-32 h-32 bg-gray-800 bg-opacity-60 text-white rounded-full hover:bg-opacity-40 text-center flex flex-col justify-center items-center" onClick={closeModal}><IoMdClose className=' inline-block' /></button>
</div>
</div>
</div>
<div className="p-24 flex flex-col gap-24">
{/* Title & Basic Info */}
<div className="flex flex-col mt-[-80px] md:flex-row md:mt-0 gap-24 md:items-center relative">
<div className="flex-shrink-0 w-[108px] h-[108px]">
<img className="w-full h-full border-2 border-white rounded-24" src={project.thumbnail_image} alt="" />
</div>
<div className='flex flex-col gap-8 items-start justify-between'>
<a href={project.website} target='_blank' rel="noreferrer"><h3 className="text-body1 font-bold">{project.title}</h3></a>
<p className="text-body4 text-gray-600">{project.tagline}</p>
<div>
<span className="font-medium text-body4 text-gray-600">{project.category.icon} {project.category.title}</span>
</div>
</div>
<div className="flex-shrink-0 w-full md:w-auto md:flex ml-auto gap-16 self-stretch">
{/* <Button color='primary' size='md' className=" my-16" href={project.website} newTab >Visit <BsJoystick /></Button> */}
{/* <VoteButton onVote={onVote} /> */}
{/* <VoteButton fullWidth votes={project.votes_count} direction='vertical' onVote={onVote} /> */}
{/* {isWalletConnected ?
:
<Button onClick={onConnectWallet} size='md' className="border border-gray-200 bg-gray-100 hover:bg-gray-50 active:bg-gray-100 my-16"><AiFillThunderbolt className='inline-block text-thunder transform scale-125' /> Connect Wallet to Vote</Button>
} */}
<Button fullWidth variant='outline' color='gray' className='!px-8' onClick={() => onVote()}>
<div className="flex justify-center items-center gap-8 md:flex-col ">
<MdLocalFireDepartment />{<span className="align-middle w-[4ch]"> {numberFormatter(project.votes_count)}</span>}
</div>
</Button>
</div>
</div>
{/* About */}
<div>
<p className="text-body6 uppercase font-medium text-gray-400 mb-8">About</p>
<div className=" text-body4 text-gray-600 leading-normal whitespace-pre-line" dangerouslySetInnerHTML={{
__html: linkifyHtml(project.description, {
className: ' text-blue-500 underline',
defaultProtocol: 'https',
target: "_blank",
rel: 'noreferrer'
})
}}></div>
{/* Links */}
<div className="mt-16 flex flex-wrap gap-16">
{links.filter(link => !!link.value).map((link, idx) =>
(link.url ? <a
key={idx}
href={link.url!}
className={`w-40 aspect-square rounded-full flex justify-center items-center ${link.colors}`}
target='_blank'
rel="noreferrer">
<link.icon className="scale-125" />
</a>
:
<CopyToClipboard
text={link.value!}
onCopy={() => NotificationsService.info(" Copied to clipboard", { icon: "📋" })}
>
<button
key={idx}
onClick={() => { }}
className={`w-40 aspect-square rounded-full flex justify-center items-center ${link.colors}`}
>
<link.icon className="scale-125" />
</button>
</CopyToClipboard>
))}
</div>
</div>
{project.screenshots.length > 0 && <>
<div className="">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 justify-items-center">
{project.screenshots.slice(0, 4).map((screenshot, idx) => <div
key={idx}
className="w-full relative pt-[56%] cursor-pointer bg-gray-100 border rounded-10 overflow-hidden"
onClick={() => setScreenshotsOpen(idx)}
>
<img src={screenshot} className="absolute top-0 left-0 w-full h-full object-cover" alt='' />
</div>)}
</div>
</div>
<Lightbox
images={project.screenshots}
isOpen={screenshotsOpen !== -1}
initOpenIndex={screenshotsOpen}
onClose={() => setScreenshotsOpen(-1)}
/>
</>}
{project.capabilities.length > 0 &&
<div>
<p className="text-body6 uppercase font-medium text-gray-400 mb-8">CAPABILITIES</p>
<div className="flex flex-wrap gap-8">
{project.capabilities.map(cap => <Badge key={cap.id} size='sm'>{cap.icon} {cap.title}</Badge>)}
</div>
</div>}
{project.members.length > 0 &&
<div className='relative'>
<p className="text-body6 uppercase font-medium text-gray-400 mb-8">MAKERS</p>
<div className="flex flex-wrap gap-8">
{sortMembersByRole(project.members).map(m => <Link key={m.user.id} to={createRoute({ type: "profile", id: m.user.id, username: m.user.name })}>
<Avatar
width={40}
src={m.user.avatar}
renderTooltip={() => <div className='bg-white px-12 py-8 border border-gray-200 rounded-12 flex flex-wrap gap-12 shadow-lg relative z-10'>
<Avatar width={48} src={m.user.avatar} />
<div className='overflow-hidden'>
<p className={`text-black font-medium overflow-hidden text-ellipsis`}>{m.user.name}</p>
<p className={`text-body6 text-gray-600`}>{m.user.jobTitle}</p>
</div>
</div>}
/>
</Link>)}
</div>
</div>}
<Button color='white' fullWidth href={createRoute({ type: "project", tag: project.hashtag })} onClick={props.onClose}>View project details</Button>
{/* <div className="text-center">
<h3 className="text-body4 font-regular">Are you the creator of this project</h3>
<Button
color='gray'
size='md'
className="my-16"
href={`https://airtable.com/shr67F20KG9Gdok6d?prefill_app_name=${project.title}&prefill_app_link=${project.website}`}
newTab
// onClick={onClaim}
>Claim 🖐</Button>
</div> */}
</div>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import ProjectDetailsCardSkeleton from './ProjectDetailsCard.Skeleton'
import { lazyModal } from 'src/utils/helperFunctions';
export const { LazyComponent: ProjectDetailsCard, preload: projectDetailsCardPreload } = lazyModal(() => import('./ProjectDetailsCard'), ProjectDetailsCardSkeleton)

View File

@@ -0,0 +1,17 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import VoteButton from './VoteButton';
import { centerDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Projects/Project Page/Vote Button',
component: VoteButton,
decorators: [
centerDecorator
]
} as ComponentMeta<typeof VoteButton>;
const Template: ComponentStory<typeof VoteButton> = (args) => <VoteButton onVote={() => { }} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,157 @@
import { MdLocalFireDepartment } from 'react-icons/md'
import Button from 'src/Components/Button/Button'
import { useAppSelector, usePressHolder } from 'src/utils/hooks'
import _throttle from 'lodash.throttle'
import { ComponentProps, useRef, useState } from 'react'
import styles from './vote-button-style.module.css'
import { random, randomItem } from 'src/utils/helperFunctions'
interface Particle {
id: string,
offsetX: number,
color: '#ff6a00' | '#ff7717' | '#ff6217' | '#ff8217' | '#ff5717'
animation: 'fly-spark-1' | 'fly-spark-2',
animationSpeed: number,
scale: number
}
type Props = {
onVote: (Vote: number) => void
} & Omit<ComponentProps<typeof Button>, 'children'>
export default function VoteButton({ onVote = () => { }, ...props }: Props) {
const [voteCnt, setVoteCnt] = useState(0)
const voteCntRef = useRef(0);
const [sparks, setSparks] = useState<Particle[]>([]);
const [wasActive, setWasActive] = useState(false);
const isMobileScreen = useAppSelector(s => s.ui.isMobileScreen)
const { onPressDown, onPressUp } = usePressHolder(_throttle(() => {
const _incStep = (Math.ceil((voteCnt + 1) / 10) + 1) ** 2 * 10;
setVoteCnt(s => {
const newValue = s + _incStep;
voteCntRef.current = newValue;
return newValue;
})
const newSpark = {
id: Math.random().toString(),
offsetX: random(1, 99),
animation: randomItem(styles.fly_spark_1, styles.fly_spark_1) as any,
animationSpeed: randomItem(1, 1.5, 2),
color: randomItem('#ff6a00', '#ff7717', '#ff6217', '#ff8217', '#ff5717'),
scale: random(1.2, 2.2)
} as const;
// if on mobile screen, reduce number of sparks particles to 60%
if (!isMobileScreen || Math.random() > .4) {
setSparks(oldSparks => [...oldSparks, newSpark])
setTimeout(() => {
setSparks(s => {
return s.filter(spark => spark.id !== newSpark.id)
})
}, newSpark.animationSpeed * 1000)
}
}, 100), 100);
const handlePressDown = () => {
setWasActive(true);
onPressDown();
}
const handlePressUp = (event?: any) => {
if (!wasActive) return;
setWasActive(false);
if (event?.preventDefault) event.preventDefault();
onPressUp();
if (voteCnt === 0)
onVote(10);
else
setTimeout(() => {
setSparks([]);
onVote(voteCntRef.current);
setVoteCnt(0);
voteCntRef.current = 0;
}, 500)
}
return (
<Button
onMouseDown={handlePressDown}
onMouseUp={handlePressUp}
onMouseLeave={handlePressUp}
onTouchStart={handlePressDown}
onTouchEnd={handlePressUp}
size='md'
color='none'
className={`${styles.vote_button} border relative 100 my-16 noselect`}
style={{
"--scale": voteCnt,
} as any}
{...props}
>
Hold To Vote !!! <MdLocalFireDepartment className='text-fire' />
<span
className={styles.vote_counter}
>{voteCnt}</span>
<div
className={styles.spark}
style={{
"--offsetX": 23,
"--animationSpeed": 3,
"--scale": 1,
"animationIterationCount": 'infinite',
"animationName": styles.fly_spark_1,
"animationDelay": '1.1s',
color: '#ff6a00'
} as any}
><MdLocalFireDepartment className='' /></div>
<div
className={styles.spark}
style={{
"--offsetX": 50,
"--animationSpeed": 2.2,
"--scale": 1,
"animationIterationCount": 'infinite',
"animationName": styles.fly_spark_2,
"animationDelay": '0.4s',
color: '#ff6a00'
} as any}
><MdLocalFireDepartment className='' /></div>
<div
className={styles.spark}
style={{
"--offsetX": 70,
"--animationSpeed": 2.5,
"--scale": 1,
"animationIterationCount": 'infinite',
"animationName": styles.fly_spark_1,
color: '#ff6a00'
} as any}
><MdLocalFireDepartment className='' /></div>
{sparks.map(spark =>
<div
key={spark.id}
className={styles.spark}
style={{
"--offsetX": spark.offsetX,
"--animationSpeed": spark.animationSpeed,
"--scale": spark.scale,
"animationName": spark.animation,
"color": spark.color
} as any}
><MdLocalFireDepartment className='' /></div>)
}
</Button>
)
}

View File

@@ -0,0 +1,77 @@
.vote_button {
--scale: 0;
transition: background-color 1s;
background-color: hsl(25, 100%, max(calc((95 - var(--scale) / 4) * 1%), 63%));
}
.vote_counter {
position: absolute;
left: 50%;
bottom: 110%;
color: hsl(25, 100%, 50%);
font-weight: bold;
font-size: 21px;
will-change: transform;
opacity: min(calc(var(--scale) * 1), 1);
transform: translate(-50%, max(calc(-1px * var(--scale) / 10), -30px))
scale(calc(1 + min(var(--scale) / 150, 2)));
text-shadow: 0 0 4px hsl(25, 100%, 50%);
}
.spark {
position: absolute;
bottom: 46%;
left: calc(var(--offsetX) * 1%);
transform: scale(var(--scale));
opacity: 0;
will-change: transform;
animation-name: fly-spark-1;
animation-duration: calc(var(--animationSpeed) * 1s);
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
filter: drop-shadow(0 0 4px);
}
@keyframes fly_spark_1 {
0% {
transform: translate(0, 0) scale(var(--scale));
opacity: 1;
}
33% {
transform: translate(12px, -70px) scale(var(--scale));
}
66% {
transform: translate(0, -140px) scale(var(--scale));
opacity: 0.6;
}
100% {
transform: translate(6px, -200px) scale(var(--scale));
opacity: 0;
}
}
@keyframes fly_spark_2 {
0% {
transform: translate(0, 0) scale(var(--scale));
opacity: 1;
}
50% {
transform: translate(-10px, -80px) scale(var(--scale));
}
80% {
transform: translate(-4px, -140px) scale(var(--scale));
opacity: 0.6;
}
100% {
transform: translate(-6px, -160px) scale(var(--scale));
opacity: 0;
}
}

View File

@@ -0,0 +1,17 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import VoteCard from './VoteCard';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Projects/Project Page/Vote Card',
component: VoteCard,
decorators: [ModalsDecorator]
} as ComponentMeta<typeof VoteCard>;
const Template: ComponentStory<typeof VoteCard> = (args) => <VoteCard {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,126 @@
import { motion } from 'framer-motion'
import React, { FormEvent, useState } from 'react';
import { AiFillThunderbolt } from 'react-icons/ai'
import { IoClose } from 'react-icons/io5'
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer';
import { PaymentStatus, useVote } from 'src/utils/hooks';
import Confetti from "react-confetti";
import { useWindowSize } from '@react-hookz/web';
import { Vote_Item_Type } from 'src/graphql';
import IconButton from 'src/Components/IconButton/IconButton';
const defaultOptions = [
{ text: '100 sat', value: 100 },
{ text: '1k sat', value: 1000 },
{ text: '10k sats', value: 10000 },
]
interface Props extends ModalCard {
projectId: number;
title?: string;
initVotes?: number;
}
export default function VoteCard({ onClose, direction, projectId, initVotes, ...props }: Props) {
const { width, height } = useWindowSize()
const [selectedOption, setSelectedOption] = useState(10);
const [voteAmount, setVoteAmount] = useState<number>(initVotes ?? 10);
const { vote, paymentStatus } = useVote({
itemId: projectId,
itemType: Vote_Item_Type.Project
})
const onChangeInput = (event: React.ChangeEvent<HTMLInputElement>) => {
setSelectedOption(-1);
setVoteAmount(Number(event.target.value));
};
const onSelectOption = (idx: number) => {
setSelectedOption(idx);
setVoteAmount(defaultOptions[idx].value);
}
const requestPayment = (e: FormEvent) => {
e.preventDefault();
vote(voteAmount, {
onSuccess: () => {
setTimeout(() => {
onClose?.();
}, 4000);
},
onError: () => {
setTimeout(() => {
onClose?.();
}, 4000);
}
});
}
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[343px] p-24 rounded-xl relative"
>
<div className="flex items-start gap-12">
<h2 className='text-h5 font-bold'>Vote for {props.title ?? "project"}</h2>
<IconButton onClick={onClose} >
<IoClose className='text-body2' />
</IconButton>
</div>
<form onSubmit={requestPayment} className="mt-32 ">
<label className="block text-gray-700 text-body4 mb-2 ">
Enter Amount
</label>
<div className="input-wrapper">
<input
className={` input-text input-removed-arrows`}
value={voteAmount} onChange={onChangeInput}
type="number"
placeholder="e.g 5 sats"
autoFocus
/>
<p className='px-16 shrink-0 self-center text-primary-400'>
Sats
</p>
</div>
<div className="flex mt-16 justify-between">
{defaultOptions.map((option, idx) =>
<button
type='button'
key={idx}
className={`btn border px-12 rounded-md py-8 text-body ${idx === selectedOption && "border-primary-500 bg-primary-100 hover:bg-primary-100 text-primary-600"}`}
onClick={() => onSelectOption(idx)}
>
{option.text}<AiFillThunderbolt className='inline-block text-thunder' />
</button>
)}
</div>
<p className="text-body6 mt-12 text-gray-500">1 sat = 1 vote</p>
<p className="text-body6 mt-12 text-gray-500"><strong>Where do these sats go?</strong> <br /> Claimed project votes go directly towards the maker's. Unclaimed project votes go towards BOLT.FUN's community pool.</p>
{paymentStatus === PaymentStatus.FETCHING_PAYMENT_DETAILS && <p className="text-body6 mt-12 text-yellow-500">Please wait while we the fetch payment details.</p>}
{paymentStatus === PaymentStatus.NOT_PAID && <p className="text-body6 mt-12 text-red-500">You did not confirm the payment. Please try again.</p>}
{paymentStatus === PaymentStatus.PAID && <p className="text-body6 mt-12 text-green-500">The invoice was paid! Please wait while we confirm it.</p>}
{paymentStatus === PaymentStatus.AWAITING_PAYMENT && <p className="text-body6 mt-12 text-yellow-500">Waiting for your payment...</p>}
{paymentStatus === PaymentStatus.PAYMENT_CONFIRMED && <p className="text-body6 mt-12 text-green-500">Thanks for your vote</p>}
<button
type='submit'
className="btn btn-primary w-full mt-32"
disabled={paymentStatus !== PaymentStatus.DEFAULT && paymentStatus !== PaymentStatus.NOT_PAID}
>
{paymentStatus === PaymentStatus.DEFAULT || paymentStatus === PaymentStatus.NOT_PAID ? "Vote" : "Voting..."}
</button>
</form>
{paymentStatus === PaymentStatus.PAYMENT_CONFIRMED && <Confetti width={width} height={height} />}
</motion.div>
)
}

View File

@@ -0,0 +1,8 @@
import { lazyModal } from 'src/utils/helperFunctions';
export const { LazyComponent: VoteCard } = lazyModal(() => import('./VoteCard'))

View File

@@ -0,0 +1,30 @@
.grid {
display: grid;
grid-template-columns: 100%;
gap: 24px;
grid-template-areas: "main";
> aside:first-of-type {
display: flex;
flex-direction: column;
gap: 24px;
grid-area: aside1;
}
> main {
display: flex;
flex-direction: column;
gap: 24px;
grid-area: main;
}
> aside:last-of-type {
display: flex;
flex-direction: column;
gap: 24px;
grid-area: aside2;
}
@media screen and (min-width: 768px) {
grid-template-columns: 1fr 2fr 1fr;
grid-template-areas: "aside1 main aside2";
}
}

View File

@@ -25,5 +25,5 @@ export interface ProjectCategory {
}
export type ProjectCard = Pick<Projects, 'id' | 'project' | 'logo' | 'category'>
export type ProjectCard = Pick<Projects, 'id' | 'title' | 'logo' | 'category'>

View File

@@ -158,12 +158,12 @@ export type QueryProjectsArgs = {
linkedIn: InputMaybe<Scalars['String']>;
logo: InputMaybe<Array<InputMaybe<Scalars['JSON']>>>;
openSource: InputMaybe<Scalars['String']>;
project: InputMaybe<Scalars['String']>;
repository: InputMaybe<Scalars['String']>;
stars: InputMaybe<Scalars['Float']>;
status: InputMaybe<Scalars['String']>;
subcategory: InputMaybe<Array<InputMaybe<Scalars['String']>>>;
telegram: InputMaybe<Scalars['String']>;
title: InputMaybe<Scalars['String']>;
twitter: InputMaybe<Scalars['String']>;
updatedAt: InputMaybe<Scalars['String']>;
watchers: InputMaybe<Scalars['Float']>;
@@ -300,12 +300,12 @@ export type Projects = {
linkedIn: Maybe<Scalars['String']>;
logo: Maybe<Array<Maybe<Scalars['JSON']>>>;
openSource: Maybe<Scalars['String']>;
project: Maybe<Scalars['String']>;
repository: Maybe<Scalars['String']>;
stars: Maybe<Scalars['Float']>;
status: Maybe<Scalars['String']>;
subcategory: Maybe<Array<Maybe<Scalars['String']>>>;
telegram: Maybe<Scalars['String']>;
title: Maybe<Scalars['String']>;
twitter: Maybe<Scalars['String']>;
updatedAt: Maybe<Scalars['String']>;
watchers: Maybe<Scalars['Float']>;
@@ -327,14 +327,10 @@ export type Tags = {
projectProductFromFeatured: Maybe<Array<Maybe<Scalars['String']>>>;
};
export type SearchProjectsQueryVariables = Exact<{
category: InputMaybe<Scalars['String']>;
pageSize: InputMaybe<Scalars['JSON']>;
page: InputMaybe<Scalars['JSON']>;
}>;
export type AllCategoriesQueryVariables = Exact<{ [key: string]: never; }>;
export type SearchProjectsQuery = { __typename?: 'Query', projects: Array<{ __typename?: 'projects', id: string | null, project: string | null, category: string | null, subcategory: Array<string | null> | null, logo: Array<any | null> | null, yearFounded: number | null } | null> | null };
export type AllCategoriesQuery = { __typename?: 'Query', categoryList: Array<{ __typename?: 'categoryList', id: string | null, name: string | null, icon: string | null } | null> | null };
export type ExplorePageQueryVariables = Exact<{
page: InputMaybe<Scalars['JSON']>;
@@ -342,56 +338,57 @@ export type ExplorePageQueryVariables = Exact<{
}>;
export type ExplorePageQuery = { __typename?: 'Query', projects: Array<{ __typename?: 'projects', id: string | null, project: string | null, category: string | null, logo: Array<any | null> | null, yearFounded: number | null, websiteFunctionalLightningRelated: string | null, companyName: string | null, website: string | null, description: string | null, repository: string | null, status: string | null, dead: boolean | null, twitter: string | null, linkedIn: string | null, telegram: string | null, language: string | null, updatedAt: string | null, createdAt: string | null, discord: string | null, stars: number | null } | null> | null };
export type ExplorePageQuery = { __typename?: 'Query', projects: Array<{ __typename?: 'projects', id: string | null, title: string | null, category: string | null, logo: Array<any | null> | null, yearFounded: number | null, websiteFunctionalLightningRelated: string | null, companyName: string | null, website: string | null, description: string | null, repository: string | null, status: string | null, dead: boolean | null, twitter: string | null, linkedIn: string | null, telegram: string | null, language: string | null, updatedAt: string | null, createdAt: string | null, discord: string | null, stars: number | null } | null> | null };
export type ProjectDetailsQueryVariables = Exact<{
projectsId: InputMaybe<Scalars['String']>;
}>;
export const SearchProjectsDocument = gql`
query SearchProjects($category: String, $pageSize: JSON, $page: JSON) {
projects(category: $category, _page_size: $pageSize, _page: $page) {
export type ProjectDetailsQuery = { __typename?: 'Query', projects: Array<{ __typename?: 'projects', id: string | null, title: string | null, dead: boolean | null, createdAt: string | null, companyName: string | null, category: string | null, description: string | null, discord: string | null, endDate: string | null, twitter: string | null, updatedAt: string | null, watchers: number | null, website: string | null, yearFounded: number | null, telegram: string | null, subcategory: Array<string | null> | null, stars: number | null, repository: string | null, openSource: string | null, logo: Array<any | null> | null, linkedIn: string | null, license: string | null, language: string | null, forks: number | null, categoryList: Array<{ __typename?: 'categoryList', name: string | null } | null> | null } | null> | null };
export const AllCategoriesDocument = gql`
query AllCategories {
categoryList {
id
project
category
subcategory
logo
yearFounded
name
icon
}
}
`;
/**
* __useSearchProjectsQuery__
* __useAllCategoriesQuery__
*
* To run a query within a React component, call `useSearchProjectsQuery` and pass it any options that fit your needs.
* When your component renders, `useSearchProjectsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* To run a query within a React component, call `useAllCategoriesQuery` and pass it any options that fit your needs.
* When your component renders, `useAllCategoriesQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useSearchProjectsQuery({
* const { data, loading, error } = useAllCategoriesQuery({
* variables: {
* category: // value for 'category'
* pageSize: // value for 'pageSize'
* page: // value for 'page'
* },
* });
*/
export function useSearchProjectsQuery(baseOptions?: Apollo.QueryHookOptions<SearchProjectsQuery, SearchProjectsQueryVariables>) {
export function useAllCategoriesQuery(baseOptions?: Apollo.QueryHookOptions<AllCategoriesQuery, AllCategoriesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<SearchProjectsQuery, SearchProjectsQueryVariables>(SearchProjectsDocument, options);
return Apollo.useQuery<AllCategoriesQuery, AllCategoriesQueryVariables>(AllCategoriesDocument, options);
}
export function useSearchProjectsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SearchProjectsQuery, SearchProjectsQueryVariables>) {
export function useAllCategoriesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<AllCategoriesQuery, AllCategoriesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<SearchProjectsQuery, SearchProjectsQueryVariables>(SearchProjectsDocument, options);
return Apollo.useLazyQuery<AllCategoriesQuery, AllCategoriesQueryVariables>(AllCategoriesDocument, options);
}
export type SearchProjectsQueryHookResult = ReturnType<typeof useSearchProjectsQuery>;
export type SearchProjectsLazyQueryHookResult = ReturnType<typeof useSearchProjectsLazyQuery>;
export type SearchProjectsQueryResult = Apollo.QueryResult<SearchProjectsQuery, SearchProjectsQueryVariables>;
export type AllCategoriesQueryHookResult = ReturnType<typeof useAllCategoriesQuery>;
export type AllCategoriesLazyQueryHookResult = ReturnType<typeof useAllCategoriesLazyQuery>;
export type AllCategoriesQueryResult = Apollo.QueryResult<AllCategoriesQuery, AllCategoriesQueryVariables>;
export const ExplorePageDocument = gql`
query ExplorePage($page: JSON, $pageSize: JSON) {
projects(_page: $page, _page_size: $pageSize) {
id
project
title
category
logo
yearFounded
@@ -441,4 +438,65 @@ export function useExplorePageLazyQuery(baseOptions?: Apollo.LazyQueryHookOption
}
export type ExplorePageQueryHookResult = ReturnType<typeof useExplorePageQuery>;
export type ExplorePageLazyQueryHookResult = ReturnType<typeof useExplorePageLazyQuery>;
export type ExplorePageQueryResult = Apollo.QueryResult<ExplorePageQuery, ExplorePageQueryVariables>;
export type ExplorePageQueryResult = Apollo.QueryResult<ExplorePageQuery, ExplorePageQueryVariables>;
export const ProjectDetailsDocument = gql`
query ProjectDetails($projectsId: String) {
projects(id: $projectsId) {
id
title
dead
createdAt
companyName
category
categoryList {
name
}
description
discord
endDate
twitter
updatedAt
watchers
website
yearFounded
telegram
subcategory
stars
repository
openSource
logo
linkedIn
license
language
forks
}
}
`;
/**
* __useProjectDetailsQuery__
*
* To run a query within a React component, call `useProjectDetailsQuery` and pass it any options that fit your needs.
* When your component renders, `useProjectDetailsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useProjectDetailsQuery({
* variables: {
* projectsId: // value for 'projectsId'
* },
* });
*/
export function useProjectDetailsQuery(baseOptions?: Apollo.QueryHookOptions<ProjectDetailsQuery, ProjectDetailsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ProjectDetailsQuery, ProjectDetailsQueryVariables>(ProjectDetailsDocument, options);
}
export function useProjectDetailsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ProjectDetailsQuery, ProjectDetailsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ProjectDetailsQuery, ProjectDetailsQueryVariables>(ProjectDetailsDocument, options);
}
export type ProjectDetailsQueryHookResult = ReturnType<typeof useProjectDetailsQuery>;
export type ProjectDetailsLazyQueryHookResult = ReturnType<typeof useProjectDetailsLazyQuery>;
export type ProjectDetailsQueryResult = Apollo.QueryResult<ProjectDetailsQuery, ProjectDetailsQueryVariables>;

View File

@@ -8,10 +8,6 @@ let apiClientUri = "https://api.baseql.com/airtable/graphql/app7wOLbDNm617R18";
const httpLink = new HttpLink({
uri: apiClientUri,
credentials: "include",
headers: {
'Authorization': 'Bearer NWU1YTNhNGItZWQ1ZC00NWQyLTk4M2ItNWRhZGViMGYxMjQ4'
},
});
const errorLink = onError(({ graphQLErrors, networkError, response }) => {