diff --git a/api/functions/graphql/nexus-typegen.ts b/api/functions/graphql/nexus-typegen.ts index e1a56dca..cc164d41 100644 --- a/api/functions/graphql/nexus-typegen.ts +++ b/api/functions/graphql/nexus-typegen.ts @@ -147,9 +147,19 @@ export interface NexusGenInputs { key: string; // String! name: string; // String! } + acceptOrRejectClubInvitationInput: { // input type + code: string; // String! + email?: string | null; // String + isAccepted: boolean; // Boolean! + } + applyToFoundersClubInput: { // input type + project_id: number; // Int! + reason: string; // String! + } } export interface NexusGenEnums { + CLUB_INVITATION_STATUS: "ACCEPTED" | "DECLINED" | "INVALID" | "UNUSED" POST_TYPE: "Bounty" | "Question" | "Story" ProjectLaunchStatusEnum: "Launched" | "WIP" ProjectPermissionEnum: "DeleteProject" | "UpdateAdmins" | "UpdateInfo" | "UpdateMembers" @@ -553,7 +563,9 @@ export interface NexusGenFieldTypes { title: string; // String! } Mutation: { // field return type + acceptOrRejectClubInvitation: NexusGenRootTypes['User']; // User! addProjectToTournament: NexusGenRootTypes['ParticipationInfo'] | null; // ParticipationInfo + applyToFoundersClub: string; // String! confirmDonation: NexusGenRootTypes['Donation']; // Donation! confirmVote: NexusGenRootTypes['Vote']; // Vote! createProject: NexusGenRootTypes['CreateProjectResponse'] | null; // CreateProjectResponse @@ -658,6 +670,7 @@ export interface NexusGenFieldTypes { getTournamentToRegister: NexusGenRootTypes['Tournament'][]; // [Tournament!]! getTrendingPosts: NexusGenRootTypes['Post'][]; // [Post!]! hottestProjects: NexusGenRootTypes['Project'][]; // [Project!]! + isClubInvitationValid: NexusGenEnums['CLUB_INVITATION_STATUS'] | null; // CLUB_INVITATION_STATUS me: NexusGenRootTypes['User'] | null; // User newProjects: NexusGenRootTypes['Project'][]; // [Project!]! officialTags: NexusGenRootTypes['Tag'][]; // [Tag!]! @@ -794,6 +807,7 @@ export interface NexusGenFieldTypes { github: string | null; // String id: number; // Int! in_tournament: boolean; // Boolean! + is_in_founders_club: boolean; // Boolean! jobTitle: string | null; // String join_date: NexusGenScalars['Date']; // Date! lightning_address: string | null; // String @@ -841,6 +855,7 @@ export interface NexusGenFieldTypes { github: string | null; // String id: number; // Int! in_tournament: boolean; // Boolean! + is_in_founders_club: boolean; // Boolean! jobTitle: string | null; // String join_date: NexusGenScalars['Date']; // Date! lightning_address: string | null; // String @@ -974,7 +989,9 @@ export interface NexusGenFieldTypeNames { title: 'String' } Mutation: { // field return type name + acceptOrRejectClubInvitation: 'User' addProjectToTournament: 'ParticipationInfo' + applyToFoundersClub: 'String' confirmDonation: 'Donation' confirmVote: 'Vote' createProject: 'CreateProjectResponse' @@ -1079,6 +1096,7 @@ export interface NexusGenFieldTypeNames { getTournamentToRegister: 'Tournament' getTrendingPosts: 'Post' hottestProjects: 'Project' + isClubInvitationValid: 'CLUB_INVITATION_STATUS' me: 'User' newProjects: 'Project' officialTags: 'Tag' @@ -1215,6 +1233,7 @@ export interface NexusGenFieldTypeNames { github: 'String' id: 'Int' in_tournament: 'Boolean' + is_in_founders_club: 'Boolean' jobTitle: 'String' join_date: 'Date' lightning_address: 'String' @@ -1262,6 +1281,7 @@ export interface NexusGenFieldTypeNames { github: 'String' id: 'Int' in_tournament: 'Boolean' + is_in_founders_club: 'Boolean' jobTitle: 'String' join_date: 'Date' lightning_address: 'String' @@ -1293,9 +1313,15 @@ export interface NexusGenFieldTypeNames { export interface NexusGenArgTypes { Mutation: { + acceptOrRejectClubInvitation: { // args + data?: NexusGenInputs['acceptOrRejectClubInvitationInput'] | null; // acceptOrRejectClubInvitationInput + } addProjectToTournament: { // args input?: NexusGenInputs['AddProjectToTournamentInput'] | null; // AddProjectToTournamentInput } + applyToFoundersClub: { // args + data?: NexusGenInputs['applyToFoundersClubInput'] | null; // applyToFoundersClubInput + } confirmDonation: { // args payment_request: string; // String! preimage: string; // String! @@ -1427,6 +1453,9 @@ export interface NexusGenArgTypes { skip?: number | null; // Int take: number | null; // Int } + isClubInvitationValid: { // args + invitationCode?: string | null; // String + } newProjects: { // args skip?: number | null; // Int take: number | null; // Int diff --git a/api/functions/graphql/schema.graphql b/api/functions/graphql/schema.graphql index 2bc65d90..a1ebca36 100644 --- a/api/functions/graphql/schema.graphql +++ b/api/functions/graphql/schema.graphql @@ -31,6 +31,7 @@ interface BaseUser { github: String id: Int! in_tournament(id: Int!): Boolean! + is_in_founders_club: Boolean! jobTitle: String join_date: Date! lightning_address: String @@ -75,6 +76,13 @@ type BountyApplication { workplan: String! } +enum CLUB_INVITATION_STATUS { + ACCEPTED + DECLINED + INVALID + UNUSED +} + type Capability { icon: String! id: Int! @@ -194,7 +202,9 @@ input MakerSkillInput { } type Mutation { + acceptOrRejectClubInvitation(data: acceptOrRejectClubInvitationInput): User! addProjectToTournament(input: AddProjectToTournamentInput): ParticipationInfo + applyToFoundersClub(data: applyToFoundersClubInput): String! confirmDonation(payment_request: String!, preimage: String!): Donation! confirmVote(payment_request: String!, preimage: String!): Vote! createProject(input: CreateProjectInput): CreateProjectResponse @@ -368,6 +378,7 @@ type Query { getTournamentToRegister: [Tournament!]! getTrendingPosts: [Post!]! hottestProjects(skip: Int = 0, take: Int = 50): [Project!]! + isClubInvitationValid(invitationCode: String): CLUB_INVITATION_STATUS me: User newProjects(skip: Int = 0, take: Int = 20): [Project!]! officialTags: [Tag!]! @@ -595,6 +606,7 @@ type User implements BaseUser { github: String id: Int! in_tournament(id: Int!): Boolean! + is_in_founders_club: Boolean! jobTitle: String join_date: Date! lightning_address: String @@ -651,4 +663,15 @@ type WalletKey { is_current: Boolean! key: String! name: String! +} + +input acceptOrRejectClubInvitationInput { + code: String! + email: String + isAccepted: Boolean! +} + +input applyToFoundersClubInput { + project_id: Int! + reason: String! } \ No newline at end of file diff --git a/api/functions/graphql/types/users.js b/api/functions/graphql/types/users.js index 86b7e7c1..10b4eadb 100644 --- a/api/functions/graphql/types/users.js +++ b/api/functions/graphql/types/users.js @@ -23,6 +23,7 @@ const { validateEvent, } = require("../../../utils/nostr-tools"); const { queueService } = require("../../../services/queue.service"); +const { ValidationError, AuthenticationError } = require("apollo-server"); const BaseUser = interfaceType({ name: "BaseUser", @@ -76,6 +77,15 @@ const BaseUser = interfaceType({ return prisma.user.findUnique({ where: { id: parent.id } }).skills(); }, }); + + t.nonNull.boolean("is_in_founders_club", { + resolve: (parent) => { + return prisma.user + .findUnique({ where: { id: parent.id } }) + .founders_club_membership() + .then((res) => !!res); + }, + }); t.nonNull.list.nonNull.field("tournaments", { type: Tournament, resolve: async (parent) => { @@ -765,6 +775,168 @@ const updateProfileRoles = extendType({ }, }); +const ClubInvitationStatus = enumType({ + name: "CLUB_INVITATION_STATUS", + members: { + INVALID: "INVALID", + UNUSED: "UNUSED", + ACCEPTED: "ACCEPTED", + DECLINED: "DECLINED", + }, +}); + +const isClubInvitationValid = extendType({ + type: "Query", + definition(t) { + t.field("isClubInvitationValid", { + type: ClubInvitationStatus, + args: { + invitationCode: stringArg(), + }, + async resolve(parent, { invitationCode }, context, info) { + if (!invitationCode) return null; + return prisma.foundersClubInvitation + .findUnique({ + where: { + code: invitationCode, + }, + }) + .then((res) => + res ? res.status : ClubInvitationStatus.value.members["INVALID"] + ); + }, + }); + }, +}); + +const acceptOrRejectClubInvitationInput = inputObjectType({ + name: "acceptOrRejectClubInvitationInput", + definition(t) { + t.nonNull.string("code"); + t.nonNull.boolean("isAccepted"); + t.string("email"); + }, +}); + +const acceptOrRejectClubInvitation = extendType({ + type: "Mutation", + definition(t) { + t.nonNull.field("acceptOrRejectClubInvitation", { + type: "User", + args: { data: acceptOrRejectClubInvitationInput }, + async resolve(_root, { data: { code, isAccepted, email } }, ctx, info) { + const user = await getUserById(ctx.user?.id); + + if (!user?.id) throw new AuthenticationError("You have to login"); + + const invitation = await prisma.foundersClubInvitation.findUnique({ + where: { + code, + }, + select: { + status: true, + }, + }); + + if (!invitation) throw new ValidationError("Invalid invitation code"); + if (invitation.status !== "UNUSED") + throw new ValidationError("Invitation has already been used"); + + const isAlreadyMember = await prisma.foundersClubMember.findUnique({ + where: { + user_id: user.id, + }, + }); + + if (isAlreadyMember) + throw new ValidationError( + "You are already a member of the Founders Club!" + ); + + await prisma.$transaction([ + prisma.foundersClubInvitation.update({ + where: { + code, + }, + data: { + status: isAccepted ? "ACCEPTED" : "DECLINED", + }, + }), + prisma.foundersClubMember.create({ + data: { + user_id: user.id, + email, + }, + }), + ]); + + const select = new PrismaSelect(info, { + defaultFields: defaultPrismaSelectFields, + }).valueWithFilter("User"); + + return prisma.user.findUnique({ + where: { + id: user.id, + }, + ...select, + }); + }, + }); + }, +}); + +const applyToFoundersClubInput = inputObjectType({ + name: "applyToFoundersClubInput", + definition(t) { + t.nonNull.int("project_id"); + t.nonNull.string("reason"); + }, +}); + +const applyToFoundersClub = extendType({ + type: "Mutation", + definition(t) { + t.nonNull.field("applyToFoundersClub", { + type: "String", + args: { data: applyToFoundersClubInput }, + async resolve(_root, { data: { project_id, reason } }, ctx) { + const user = await getUserById(ctx.user?.id); + + // Do some validation + if (!user?.id) throw new AuthenticationError("You have to login"); + const project = await prisma.project.findUnique({ + where: { + id: project_id, + }, + select: { + id: true, + title: true, + hashtag: true, + members: { + select: { + user: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + if (!project) throw new ValidationError("Invalid project"); + + if (!project.members.find((m) => m.user.id === user.id)) + throw new ValidationError("You are not a member of this project"); + + // sendInvitationEmail(user.email, project.title, project.hashtag, reason); + + return "Success"; + }, + }); + }, +}); + module.exports = { // Types @@ -781,10 +953,13 @@ module.exports = { getAllMakersRoles, getAllMakersSkills, usersByNostrKeys, + isClubInvitationValid, // Mutations updateProfileDetails, updateUserPreferences, updateProfileRoles, linkNostrKey, unlinkNostrKey, + acceptOrRejectClubInvitation, + applyToFoundersClub, }; diff --git a/api/functions/is-logged-in/is-logged-in.js b/api/functions/is-logged-in/is-logged-in.js index 8623bbc5..458fce8c 100644 --- a/api/functions/is-logged-in/is-logged-in.js +++ b/api/functions/is-logged-in/is-logged-in.js @@ -74,3 +74,25 @@ const handler = serverless(app); exports.handler = async (event, context) => { return await handler(event, context); }; + +function getCookieConfig() { + if ( + env.NODE_ENV === "development" || + !env.FUNCTIONS_URL.startsWith("https://master--makers-bolt-fun.netlify.app") + ) + return { + maxAge: 3600000 * 24 * 30, + secure: true, + httpOnly: true, + sameSite: "none", + }; + + return { + maxAge: 3600000 * 24 * 30, + secure: true, + httpOnly: true, + domain: `.bolt.fun`, + }; +} + +exports.getCookieConfig = getCookieConfig; diff --git a/api/functions/logout/logout.js b/api/functions/logout/logout.js index cf54078d..01429e69 100644 --- a/api/functions/logout/logout.js +++ b/api/functions/logout/logout.js @@ -1,21 +1,12 @@ const serverless = require("serverless-http"); const { createExpressApp } = require("../../modules"); const express = require("express"); +const { getCookieConfig } = require("../is-logged-in/is-logged-in"); const logoutHandler = (req, res, next) => { - res - .clearCookie("Authorization", { - secure: true, - httpOnly: true, - sameSite: "none", - }) - .clearCookie("Authorization", { - secure: true, - httpOnly: true, - domain: `.bolt.fun`, - }) - .redirect("/") - .end(); + const cookieConfig = getCookieConfig(); + delete cookieConfig.maxAge; + res.clearCookie("Authorization", cookieConfig).sendStatus(200).end(); }; let app; diff --git a/src/Components/Button/Button.tsx b/src/Components/Button/Button.tsx index 426e746d..a1ddd927 100644 --- a/src/Components/Button/Button.tsx +++ b/src/Components/Button/Button.tsx @@ -87,15 +87,15 @@ const Button = React.forwardRef( }, ref ) => { - let classes = ` - inline-block font-sans rounded-lg font-regular hover:cursor-pointer text-center relative - ${baseBtnStyles[variant]} - ${btnPadding[size]} - ${variant === "fill" ? btnStylesFill[color] : btnStylesOutline[color]} - ${isLoading && disableOnLoading && "bg-opacity-70 pointer-events-none"} - ${fullWidth && "w-full"} - ${disabled && "opacity-80 pointer-events-none"} - `; + let classes = createButtonStyleClasses({ + color, + variant, + size, + fullWidth, + disabled, + isLoading, + disableOnLoading, + }); const onClick = props.onClick; @@ -150,4 +150,35 @@ const Button = React.forwardRef( } ); +export function createButtonStyleClasses({ + color = "white", + variant = "fill", + size = "md", + disabled, + fullWidth, + isLoading, + disableOnLoading, +}: Pick< + Props, + | "variant" + | "size" + | "color" + | "fullWidth" + | "isLoading" + | "disabled" + | "disableOnLoading" +>) { + let classes = ` + inline-block font-sans rounded-lg font-regular hover:cursor-pointer text-center relative + ${baseBtnStyles[variant]} + ${btnPadding[size]} + ${variant === "fill" ? btnStylesFill[color] : btnStylesOutline[color]} + ${isLoading && disableOnLoading && "bg-opacity-70 pointer-events-none"} + ${fullWidth && "w-full"} + ${disabled && "opacity-80 pointer-events-none"} + `; + + return classes; +} + export default Button; diff --git a/src/Components/Inputs/Selects/BasicSelectInput/BasicSelectInput.tsx b/src/Components/Inputs/Selects/BasicSelectInput/BasicSelectInput.tsx index 6687651e..821ab8f9 100644 --- a/src/Components/Inputs/Selects/BasicSelectInput/BasicSelectInput.tsx +++ b/src/Components/Inputs/Selects/BasicSelectInput/BasicSelectInput.tsx @@ -1,157 +1,169 @@ - import React from "react"; -import Select, { StylesConfig, components, OptionProps, ValueContainerProps, GroupBase } from "react-select"; +import Select, { + StylesConfig, + components, + OptionProps, + ValueContainerProps, + GroupBase, +} from "react-select"; import { ControlledStateHandler } from "src/utils/interfaces"; - - type Props, IsMulti extends boolean = boolean> = { - options: T[]; - labelField: keyof T - valueField: keyof T - placeholder?: string - disabled?: boolean - isLoading?: boolean; - isClearable?: boolean; - isSearchable?: boolean; - control?: any, - name?: string, - menuPosition?: 'fixed' | 'absolute' - className?: string, - renderOption?: (option: OptionProps) => JSX.Element - ValueContainer?: React.ComponentType>> | undefined - formatOption?: (data: T) => React.ReactNode -} & ControlledStateHandler - - - - - - -export const selectCustomStyle: StylesConfig = ({ - control: (styles, state) => ({ - ...styles, - padding: '5px 12px', - borderRadius: 12, - borderColor: '#D0D5DD', - // border: 'none', - // boxShadow: 'none', - - ":hover": { - cursor: "pointer", - borderColor: '#98A2B3', - }, - ":focus-within": { - '--tw-border-opacity': '1', - borderColor: 'rgb(179 160 255 / var(--tw-border-opacity))', - outlineColor: '#9E88FF', - '--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)', - '--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)', - boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)', - '--tw-ring-color': 'rgb(179 160 255 / var(--tw-ring-opacity))', - '--tw-ring-opacity': '0.5' - } - }), - indicatorSeparator: (styles, state) => ({ - ...styles, - display: "none" - }), - input: (styles, state) => ({ - ...styles, - " input": { - boxShadow: 'none !important' - }, - }), - menu: (styles, state) => ({ - ...styles, - padding: 8, - borderRadius: "16px !important" - }), -}) - - -export default function BasicSelectInput, IsMulti extends boolean>({ - options, - labelField, - valueField, - placeholder = "Select Option...", - isMulti, - menuPosition, - isClearable, - isSearchable, - disabled, - className, - value, - onChange, - onBlur, - formatOption, - renderOption, - ValueContainer, - ...props + options: T[]; + labelField: keyof T; + valueField: keyof T; + placeholder?: string; + disabled?: boolean; + isLoading?: boolean; + isClearable?: boolean; + isSearchable?: boolean; + control?: any; + name?: string; + menuPosition?: "fixed" | "absolute"; + inputId?: string; + className?: string; + renderOption?: (option: OptionProps) => JSX.Element; + ValueContainer?: + | React.ComponentType>> + | undefined; + formatOption?: (data: T) => React.ReactNode; +} & ControlledStateHandler; + +export const selectCustomStyle: StylesConfig = { + control: (styles, state) => ({ + ...styles, + padding: "5px 12px", + borderRadius: 12, + borderColor: "#D0D5DD", + // border: 'none', + // boxShadow: 'none', + + ":hover": { + cursor: "pointer", + borderColor: "#98A2B3", + }, + ":focus-within": { + "--tw-border-opacity": "1", + borderColor: "rgb(179 160 255 / var(--tw-border-opacity))", + outlineColor: "#9E88FF", + "--tw-ring-offset-shadow": + "var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)", + "--tw-ring-shadow": + "var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)", + boxShadow: + "var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)", + "--tw-ring-color": "rgb(179 160 255 / var(--tw-ring-opacity))", + "--tw-ring-opacity": "0.5", + }, + }), + indicatorSeparator: (styles, state) => ({ + ...styles, + display: "none", + }), + input: (styles, state) => ({ + ...styles, + " input": { + boxShadow: "none !important", + }, + }), + menu: (styles, state) => ({ + ...styles, + padding: 8, + borderRadius: "16px !important", + }), +}; + +export default function BasicSelectInput< + T extends Record, + IsMulti extends boolean +>({ + options, + labelField, + valueField, + placeholder = "Select Option...", + isMulti, + menuPosition, + isClearable, + isSearchable, + disabled, + inputId, + className, + value, + onChange, + onBlur, + formatOption, + renderOption, + ValueContainer, + ...props }: Props) { - - - - - return ( -
- o[labelField]} + getOptionValue={(o) => o[valueField]} + formatOptionLabel={formatOption} + menuPosition={menuPosition} + menuPlacement="bottom" + isDisabled={disabled} + value={value as any} + onChange={(v) => onChange?.(v as any)} + onBlur={onBlur} + components={{ + Option: getOptionComponent(renderOption), + ...(ValueContainer && { ValueContainer }), + }} + styles={selectCustomStyle as any} + theme={(theme) => ({ + ...theme, + borderRadius: 8, + colors: { + ...theme.colors, + primary: "var(--primary)", + }, + })} + /> +
+ ); } -function getOptionComponent>(renderOption: Props['renderOption']) { - const _render = renderOption ?? ((option) =>
>( + renderOption: Props["renderOption"] +) { + const _render = + renderOption ?? + ((option) => ( +
+ ${ + !(option.isSelected || option.isFocused) + ? "hover:bg-gray-50" + : option.isSelected + ? "bg-gray-100 text-gray-800" + : "bg-gray-50" + } + `} + > {option.children} -
) +
+ )); - return function OptionComponent(props: OptionProps) { - return ( - - {_render(props)} - - ); - }; + return function OptionComponent(props: OptionProps) { + return ( + + {_render(props)} + + ); + }; } - - diff --git a/src/features/FoundersClub/pages/LandingPage/Components/ApplyForm.tsx b/src/features/FoundersClub/pages/LandingPage/Components/ApplyForm.tsx new file mode 100644 index 00000000..211578c6 --- /dev/null +++ b/src/features/FoundersClub/pages/LandingPage/Components/ApplyForm.tsx @@ -0,0 +1,173 @@ +import React, { useState } from "react"; +import { ValueContainerProps, components } from "react-select"; +import Button from "src/Components/Button/Button"; +import BasicSelectInput from "src/Components/Inputs/Selects/BasicSelectInput/BasicSelectInput"; +import { + MyProjectsQuery, + useApplyToFoundersClubMutation, + useMyProjectsQuery, +} from "src/graphql"; +import { NotificationsService } from "src/services"; +import { extractErrorMessage } from "src/utils/helperFunctions"; +import { useAppSelector } from "src/utils/hooks"; + +type Project = NonNullable["projects"][number]; + +export default function ApplyForm() { + const [reasonInput, setReasonInput] = useState(""); + const [selectedProject, setSelectedProject] = useState(null); + + const isLoggedIn = useAppSelector((state) => !!state.user.me?.id); + + const [sendApplication, { loading }] = useApplyToFoundersClubMutation(); + + const query = useMyProjectsQuery(); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedProject) return; + if (!reasonInput) return; + + sendApplication({ + variables: { + data: { + project_id: selectedProject?.id, + reason: reasonInput, + }, + }, + onCompleted: (data) => { + NotificationsService.success("Your Application was sent successfully!"); + setSelectedProject(null); + setReasonInput(""); + }, + onError: (error) => { + NotificationsService.error( + extractErrorMessage(error) ?? + "Unexpected error happened, please try again", + { error } + ); + }, + }); + }; + + return ( +
+
+

+ Apply for membership +

+

+ You need to already be a maker and have your profile filled out before + applying. +

+
+
+ + setSelectedProject(v)} + options={query.data?.me?.projects ?? []} + ValueContainer={SelectProjectValueContainer} + disabled={!isLoggedIn} + renderOption={(option) => ( +
+ {" "} + {option.data.title} +
+ )} + /> +
+
+ +
+ setReasonInput(e.target.value)} + disabled={!isLoggedIn} + /> +
+
+ +
+ ); +} + +const SelectProjectValueContainer = ({ + children, + ...props +}: ValueContainerProps) => { + const { thumbnail_image, title } = props.getValue()[0] ?? {}; + return ( + +
+ {title ? ( + <> + {" "} + {" "} + {title}{" "} + + ) : ( + <> + {" "} +
{" "} + Select a project{" "} + + )} +
+ {React.cloneElement((children as any)[1])} + + ); +}; diff --git a/src/features/FoundersClub/pages/LandingPage/Components/InvitationStatusCard.tsx b/src/features/FoundersClub/pages/LandingPage/Components/InvitationStatusCard.tsx new file mode 100644 index 00000000..c6e0743b --- /dev/null +++ b/src/features/FoundersClub/pages/LandingPage/Components/InvitationStatusCard.tsx @@ -0,0 +1,172 @@ +import React, { useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import Button, { createButtonStyleClasses } from "src/Components/Button/Button"; +import { + Club_Invitation_Status, + useAcceptOrRejectClubInvitationMutation, +} from "src/graphql"; +import { NotificationsService } from "src/services"; +import { extractErrorMessage } from "src/utils/helperFunctions"; +import { useAppSelector } from "src/utils/hooks"; +import { createRoute, PAGES_ROUTES } from "src/utils/routing"; + +import JohnsAvatar from "../assets/avatar.png"; + +interface Props { + status: Club_Invitation_Status; +} + +export default function InvitationStatusCard(props: Props) { + const [status, setStatus] = useState(props.status); + + const isLoggedIn = useAppSelector((s) => !!s.user.me?.id); + + const [searchParams] = useSearchParams(); + const [acceptOrReject, { loading }] = + useAcceptOrRejectClubInvitationMutation(); + + const clickAcceptOrReject = (isAccepted: boolean) => { + const code = searchParams.get("code"); + if (!code) return; + + let isEmailValid = false; + let email: string | null = ""; + + while (!isEmailValid) { + email = window.prompt( + "Please provide us with an email address that we can use to contact you. We will never share your email with anyone else. We promise!" + ); + + if (!email) return; + isEmailValid = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/.test(email.trim()); + if (!isEmailValid) alert("Please provide a valid email address"); + } + + acceptOrReject({ + variables: { + data: { + isAccepted, + code, + email: email.trim(), + }, + }, + onCompleted: (data) => { + setStatus( + data.acceptOrRejectClubInvitation.is_in_founders_club + ? Club_Invitation_Status.Accepted + : Club_Invitation_Status.Declined + ); + }, + onError: (error) => { + NotificationsService.error( + extractErrorMessage(error) ?? "Something wrong happened...", + { error } + ); + }, + }); + }; + + const invalidInvitationCodeContent = ( +
+

+ Ooops... +

+

+ The invitation code in the URL doesn't seem to be valid. Please make + sure you used the correct link. +

+
+ ); + + const unusedInvitationContent = ( + <> + +

Claim your invitation

+

+ Hey it’s Johns here!
+
We think you’d be a great fit for our new members only community + wanted to see if you wanted to be a part of it before we officially + launch. +
+
Check out the rest of the page and let us know if you’d like to + join! +

+ {isLoggedIn ? ( + <> + + + + ) : ( + <> +

+ Please login first to accept or reject the invitation. +

+ + Connect Account ⚡ + + + )} + + ); + + const acceptedInvitationContent = ( +
+

+ Congratulations! +

+

+ You joined the Founders Club!
Stay tuned for mission#0:
{" "} + Startup Pitch Competition! +

+
+ ); + + const declinedInvitationContent = ( +
+

+ We Understand. +

+

+ We respect your preferences.
If you later decide you want to + join, we will have your spot reserved for you 🥰 +

+
+ ); + + return ( +
+ {status === Club_Invitation_Status.Invalid && + invalidInvitationCodeContent} + {status === Club_Invitation_Status.Unused && unusedInvitationContent} + {status === Club_Invitation_Status.Accepted && acceptedInvitationContent} + {status === Club_Invitation_Status.Declined && declinedInvitationContent} +
+ ); +} diff --git a/src/features/FoundersClub/pages/LandingPage/FounderClubQueries.graphql b/src/features/FoundersClub/pages/LandingPage/FounderClubQueries.graphql new file mode 100644 index 00000000..488a4d81 --- /dev/null +++ b/src/features/FoundersClub/pages/LandingPage/FounderClubQueries.graphql @@ -0,0 +1,20 @@ +query FounderClubLandingPage($invitationCode: String) { + isClubInvitationValid(invitationCode: $invitationCode) + me { + id + is_in_founders_club + } +} + +mutation ApplyToFoundersClub($data: applyToFoundersClubInput) { + applyToFoundersClub(data: $data) +} + +mutation AcceptOrRejectClubInvitation( + $data: acceptOrRejectClubInvitationInput +) { + acceptOrRejectClubInvitation(data: $data) { + id + is_in_founders_club + } +} diff --git a/src/features/FoundersClub/pages/LandingPage/LandingPage.tsx b/src/features/FoundersClub/pages/LandingPage/LandingPage.tsx new file mode 100644 index 00000000..ad615d60 --- /dev/null +++ b/src/features/FoundersClub/pages/LandingPage/LandingPage.tsx @@ -0,0 +1,337 @@ +import OgTags from "src/Components/OgTags/OgTags"; +import FeedTagsFilter from "src/features/Posts/pages/FeedPage/PopularTagsFilter/FeedTagsFilter"; +import Card from "src/Components/Card/Card"; +import BgImage from "./assets/bg.jpg"; +import HangoutImage from "./assets/hangout.jpg"; +import EventsImage from "./assets/events.png"; +import PerksImage from "./assets/perks.png"; +import CirclesImage from "./assets/circles.svg"; +import { FiSmile, FiTarget, FiUsers } from "react-icons/fi"; +import ApplyForm from "./Components/ApplyForm"; +import { useLoaderData } from "react-router-dom"; +import { LoaderData } from "./foundersLandingPage.loader"; +import InvitationStatusCard from "./Components/InvitationStatusCard"; + +export default function LandingPage() { + const loaderData = useLoaderData() as LoaderData; + + const invitationStatus = loaderData?.isClubInvitationValid; + + const showInvitationCard = + invitationStatus != null && !loaderData?.me?.is_in_founders_club; + + return ( + <> + +
+
+ +
+ {loaderData?.me?.is_in_founders_club && ( +
+ You are a valuable member of the Founders Club! ✨ +
+ )} +
+
+ {" "} +
+
+
+
+
+

+ Founders Club +

+

+ The Founders Club is an exclusive space on BOLT.FUN that + offers a safe and supportive environment for makers to + pitch their ideas, connect, learn and share their + experiences. +

+
+ {showInvitationCard && ( + + )} +
+
+
+

+ What's Inside + +

+

+ As a small startup trying to figure out product market fit + ourself, we’ve created The Founders Club so we could be + amongst cool people building sustainable businesses on bitcoin + to learn from. +

+
    +
  • +

    🙌

    +

    + Networking +

    +

    + We only invite founders who are serious about starting + their business, are interested in engineering, design and + entrepreneurship, meeting investors, and getting feedback + from mentors. +

    +
  • {" "} +
  • +

    🧪

    +

    + Missions +

    +

    + Every few months we’ll be having missions for members of + the founders club. Our first mission is to encourage you + to think about your pitch which is important because + presenting your product increases interest and exposure, + which can help to drive growth. +

    +
  • {" "} +
  • +

    ⚡️

    +

    + Deals+perks +

    +

    + It’s costly to run a company, especially when you’re + bootstrapping in the beginning. We gathered discounts on + startup tools so you lower your operating costs. +

    +
  • +
+
+
+
+

+ Why should you care? +

+

+ New businesses are important for growth of any industry or + economy, you might want to be the next unicorn, or to just + be your own boss, and cover living expenses. Whatever the + reason, only the brave few or silly embark on this journey. + Since it can get lonely, and the path may be unclear, why + not support each other, and learn from one another. +

+
+
+
+
+ +
+ + + +

+ Meet bitcoin founders +

+

+ There’s one rule of the Founders club. You do not talk + about the founders club, oh and you have to have started, + or want to start a company, or have a burning desire to + create something amazing. If you have an idea that you + want to share with the world, then you're the perfect fit + for our community. +

+
+
+
+
+
+
+ + + +

+ Missions +

+

+ As founders, there are certain tasks that you might not + like to do. That's why we've come up with a unique way to + make these tasks fun and engaging through our exciting and + incentive-based missions. Think of it as a game where you + can earn rewards and gain exposure for your business while + completing challenges that might have otherwise seemed + tedious. +

+
    +
  • + Pitch Competitions 🍕 +
  • +
  • + Marketing 🛠 +
  • +
  • + Growth 🤝 +
  • +
+
+ +
+
+
+
+
+ + + +

+ Deals + Perks +

+

+ Starting or running a business is expensive so we’ve put + together a set of discounts, deals, and perks from various + bitcoin companies to give you some of the best tools and + services. +

+
+ +
+
+
+
+ + MISSION #0 + +

+ Startup pitch competition +

+

+ Founders will have two months to present their project ideas + in the form of a pitch deck. Participate in our Pitch + Competition, present your idea to the top industry VCs and + judges, get discounts and deals, valuable feedback from the + mentors, win the prize, and start your company. +
+
+
+ The prize is a coupon for registering your company in the US + with FirstBase.io. The winner will also receive $250k worth + of deals and rewards for various tools and services + including server costs, project management, and figma. Don't + miss out on this amazing opportunity to jumpstart your + business. Start crafting your winning pitch now! +

+ + + + + + +
+
+
+
+
+
+

+ Apply for Founder Club +

+

+ An exclusive community to level up founders and their + projects +

+
+
+ + + +
+

+ Networking +

+

+ Get your company’s adverts featured where our + community is most active. +

+
+
+
+ + + +
+

+ Deals & Perks +

+

+ Reach a live audience through our community hangout + spaces! +

+
+
+
+ + + +
+

+ Missions +

+

+ We’ll give you extra love and shoutouts through + podcast appearances, spaces, and more! +

+
+
+
+ +
+
+
+
+
+
+ + ); +} diff --git a/src/features/FoundersClub/pages/LandingPage/assets/avatar.png b/src/features/FoundersClub/pages/LandingPage/assets/avatar.png new file mode 100644 index 00000000..d3048055 Binary files /dev/null and b/src/features/FoundersClub/pages/LandingPage/assets/avatar.png differ diff --git a/src/features/FoundersClub/pages/LandingPage/assets/bg.jpg b/src/features/FoundersClub/pages/LandingPage/assets/bg.jpg new file mode 100644 index 00000000..1e89870a Binary files /dev/null and b/src/features/FoundersClub/pages/LandingPage/assets/bg.jpg differ diff --git a/src/features/FoundersClub/pages/LandingPage/assets/circles.svg b/src/features/FoundersClub/pages/LandingPage/assets/circles.svg new file mode 100644 index 00000000..07a008eb --- /dev/null +++ b/src/features/FoundersClub/pages/LandingPage/assets/circles.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/features/FoundersClub/pages/LandingPage/assets/events.png b/src/features/FoundersClub/pages/LandingPage/assets/events.png new file mode 100644 index 00000000..6d7d9f42 Binary files /dev/null and b/src/features/FoundersClub/pages/LandingPage/assets/events.png differ diff --git a/src/features/FoundersClub/pages/LandingPage/assets/hangout.jpg b/src/features/FoundersClub/pages/LandingPage/assets/hangout.jpg new file mode 100644 index 00000000..7f9c6a84 Binary files /dev/null and b/src/features/FoundersClub/pages/LandingPage/assets/hangout.jpg differ diff --git a/src/features/FoundersClub/pages/LandingPage/assets/perks.png b/src/features/FoundersClub/pages/LandingPage/assets/perks.png new file mode 100644 index 00000000..4ee4d95c Binary files /dev/null and b/src/features/FoundersClub/pages/LandingPage/assets/perks.png differ diff --git a/src/features/FoundersClub/pages/LandingPage/foundersLandingPage.loader.ts b/src/features/FoundersClub/pages/LandingPage/foundersLandingPage.loader.ts new file mode 100644 index 00000000..f19655c7 --- /dev/null +++ b/src/features/FoundersClub/pages/LandingPage/foundersLandingPage.loader.ts @@ -0,0 +1,21 @@ +import { createLoader } from "src/utils/routing/helpers"; +import { + FounderClubLandingPageDocument, + FounderClubLandingPageQuery, + FounderClubLandingPageQueryVariables, +} from "src/graphql"; + +export type LoaderData = FounderClubLandingPageQuery | null; + +export const foundersClubLandingPageLoader = + createLoader(({ request }) => { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + + return { + query: FounderClubLandingPageDocument, + variables: { + invitationCode: code, + }, + }; + }); diff --git a/src/features/Posts/pages/FeedPage/FeedPage.tsx b/src/features/Posts/pages/FeedPage/FeedPage.tsx index e77532fb..f08e44b5 100644 --- a/src/features/Posts/pages/FeedPage/FeedPage.tsx +++ b/src/features/Posts/pages/FeedPage/FeedPage.tsx @@ -103,10 +103,7 @@ export default function FeedPage() {
- +
diff --git a/src/features/Posts/pages/FeedPage/PopularTagsFilter/FeedTagsFilter.tsx b/src/features/Posts/pages/FeedPage/PopularTagsFilter/FeedTagsFilter.tsx index fbe04165..71529208 100644 --- a/src/features/Posts/pages/FeedPage/PopularTagsFilter/FeedTagsFilter.tsx +++ b/src/features/Posts/pages/FeedPage/PopularTagsFilter/FeedTagsFilter.tsx @@ -11,13 +11,12 @@ import Button from "src/Components/Button/Button"; export type FilterTag = Pick; interface Props { - value: FilterTag | null; - onChange?: (newFilter: FilterTag | null) => void; + value?: FilterTag | null; // deprecated } const MAX_SHOWED_TAGS = 10; -export default function FeedTagsFilter({ value, onChange }: Props) { +export default function FeedTagsFilter({ value }: Props) { const tagsQuery = useFeedTagsQuery(); const selectedId = value?.id; diff --git a/src/features/Posts/pages/PostDetailsPage/postDetailsPage.loader.ts b/src/features/Posts/pages/PostDetailsPage/postDetailsPage.loader.ts index b8607d2c..890cfe08 100644 --- a/src/features/Posts/pages/PostDetailsPage/postDetailsPage.loader.ts +++ b/src/features/Posts/pages/PostDetailsPage/postDetailsPage.loader.ts @@ -7,7 +7,7 @@ import { } from "src/graphql"; import { capitalize } from "src/utils/helperFunctions"; -export type LoaderData = PostDetailsQuery; +export type LoaderData = PostDetailsQuery | null; export const postDetailsPageLoader = createLoader< PostDetailsQueryVariables, diff --git a/src/features/Tournaments/pages/OverviewPage/RegisterationModals/LoginModal/LoginModal.tsx b/src/features/Tournaments/pages/OverviewPage/RegisterationModals/LoginModal/LoginModal.tsx index 96172661..e0d0228f 100644 --- a/src/features/Tournaments/pages/OverviewPage/RegisterationModals/LoginModal/LoginModal.tsx +++ b/src/features/Tournaments/pages/OverviewPage/RegisterationModals/LoginModal/LoginModal.tsx @@ -1,186 +1,218 @@ -import { motion } from 'framer-motion' -import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer' +import { motion } from "framer-motion"; +import { + ModalCard, + modalCardVariants, +} from "src/Components/Modals/ModalsContainer/ModalsContainer"; import { FiCopy } from "react-icons/fi"; -import { IoClose, IoRocketOutline } from 'react-icons/io5'; -import { useMeTournamentQuery } from 'src/graphql'; -import Button from 'src/Components/Button/Button'; -import { QRCodeSVG } from 'qrcode.react'; -import { Grid } from 'react-loader-spinner'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { CONSTS } from 'src/utils'; -import useCopyToClipboard from 'src/utils/hooks/useCopyToClipboard'; -import { useLnurlQuery } from 'src/features/Auth/pages/LoginPage/LoginPage'; -import { useAppDispatch } from 'src/utils/hooks'; -import { Direction, replaceModal } from 'src/redux/features/modals.slice'; -import { NotificationsService } from 'src/services'; - +import { IoClose, IoRocketOutline } from "react-icons/io5"; +import { useMeTournamentQuery } from "src/graphql"; +import Button from "src/Components/Button/Button"; +import { QRCodeSVG } from "qrcode.react"; +import { Grid } from "react-loader-spinner"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CONSTS } from "src/utils"; +import useCopyToClipboard from "src/utils/hooks/useCopyToClipboard"; +import { useLnurlQuery } from "src/features/Auth/pages/LoginPage/LoginPage"; +import { useAppDispatch } from "src/utils/hooks"; +import { Direction, replaceModal } from "src/redux/features/modals.slice"; +import { NotificationsService } from "src/services"; interface Props extends ModalCard { - tournamentId: number + tournamentId: number; } -export default function LinkingAccountModal({ onClose, direction, tournamentId, ...props }: Props) { - - const [copied, setCopied] = useState(false); - - const { loadingLnurl, data: { lnurl, session_token }, error } = useLnurlQuery(); - const clipboard = useCopyToClipboard(); - - const canFetchIsLogged = useRef(true) - - const dispatch = useAppDispatch(); - - - useEffect(() => { - setCopied(false); - }, [lnurl]) - - const meQuery = useMeTournamentQuery({ - variables: { - id: tournamentId +export default function LinkingAccountModal({ + onClose, + direction, + tournamentId, + ...props +}: Props) { + const [copied, setCopied] = useState(false); + + const { + loadingLnurl, + data: { lnurl, session_token }, + error, + } = useLnurlQuery(); + const clipboard = useCopyToClipboard(); + + const canFetchIsLogged = useRef(true); + + const dispatch = useAppDispatch(); + + useEffect(() => { + setCopied(false); + }, [lnurl]); + + const meQuery = useMeTournamentQuery({ + variables: { + id: tournamentId, + }, + onCompleted: (data) => { + if (data.me) { + const already_registerd = !!data.tournamentParticipationInfo; + if (already_registerd) { + onClose?.(); + NotificationsService.info("You are already registered"); + } else + dispatch( + replaceModal({ + Modal: "RegisterTournamet_RegistrationDetails", + direction: Direction.NEXT, + props: { tournamentId }, + }) + ); + } + }, + }); + + const copyToClipboard = () => { + setCopied(true); + clipboard(lnurl); + }; + + const refetch = meQuery.refetch; + const startPolling = useCallback(() => { + const interval = setInterval(() => { + if (canFetchIsLogged.current === false) return; + + canFetchIsLogged.current = false; + fetch(CONSTS.apiEndpoint + "/is-logged-in", { + credentials: "include", + headers: { + session_token, }, - onCompleted: (data) => { - if (data.me) { - const already_registerd = !!data.tournamentParticipationInfo; - if (already_registerd) { - onClose?.(); - NotificationsService.info("You are already registered") - } - else dispatch(replaceModal({ - Modal: "RegisterTournamet_RegistrationDetails", - direction: Direction.NEXT, - props: { tournamentId } - })) - - } - - } - }); - - const copyToClipboard = () => { - setCopied(true); - clipboard(lnurl); - } - - const refetch = meQuery.refetch; - const startPolling = useCallback( - () => { - const interval = setInterval(() => { - if (canFetchIsLogged.current === false) return; - - canFetchIsLogged.current = false; - fetch(CONSTS.apiEndpoint + '/is-logged-in', { - credentials: 'include', - headers: { - session_token - } - }) - .then(data => data.json()) - .then(data => { - if (data.logged_in) { - clearInterval(interval) - refetch(); - } - }) - .catch() - .finally(() => { - canFetchIsLogged.current = true; - }) - }, 2000); - - return interval; - } - , [refetch, session_token], - ) - - - - useEffect(() => { - let interval: NodeJS.Timer; - if (lnurl) - interval = startPolling(); - - return () => { - canFetchIsLogged.current = true; - clearInterval(interval) - } - }, [lnurl, startPolling]) - - - - let content = <> - - if (error) - content =
-

Something wrong happened...

- Please try again + }) + .then((data) => data.json()) + .then((data) => { + if (data.logged_in) { + clearInterval(interval); + refetch(); + } + }) + .catch() + .finally(() => { + canFetchIsLogged.current = true; + }); + }, 2000); + + return interval; + }, [refetch, session_token]); + + useEffect(() => { + let interval: NodeJS.Timer; + if (lnurl) interval = startPolling(); + + return () => { + canFetchIsLogged.current = true; + clearInterval(interval); + }; + }, [lnurl, startPolling]); + + let content = <>; + + if (error) + content = ( +
+

+ Something wrong happened... +

+ + Please try again + +
+ ); + else if (loadingLnurl) + content = ( +
+ +

Fetching Lnurl-Auth link

+
+ ); + else + content = ( +
+ + + +

+ To register for this tournament, you need a maker profile. Luckily, + this is very easy! +
+ To sign in or create an account, just scan this QR, or click to + connect using any lightning wallet like{" "} + + Alby + {" "} + or{" "} + + Breez + + . +

+
+ + Click to connect + + + + What is a lightning wallet? +
- - else if (loadingLnurl) - content =
- -

Fetching Lnurl-Auth link

-
- - - else - content =
- - - -

- To register for this tournament, you need a maker profile. Luckily, this is very easy! -
- To sign in or create an account, just scan this QR, or click to connect using any lightning wallet like Alby or Breez. -

-
- Click to connect - - What is a lightning wallet? -
- -
; - - - return ( - -
- -

Connect ⚡️ your maker profile

-
-
-
- {content} -
-
- ) +
+ ); + + return ( + +
+ +

+ Connect ⚡️ your maker profile +

+
+
+
{content}
+
+ ); } - - - diff --git a/src/graphql/index.tsx b/src/graphql/index.tsx index a294130d..8ac4b127 100644 --- a/src/graphql/index.tsx +++ b/src/graphql/index.tsx @@ -48,6 +48,7 @@ export type BaseUser = { github: Maybe; id: Scalars['Int']; in_tournament: Scalars['Boolean']; + is_in_founders_club: Scalars['Boolean']; jobTitle: Maybe; join_date: Scalars['Date']; lightning_address: Maybe; @@ -99,6 +100,13 @@ export type BountyApplication = { workplan: Scalars['String']; }; +export enum Club_Invitation_Status { + Accepted = 'ACCEPTED', + Declined = 'DECLINED', + Invalid = 'INVALID', + Unused = 'UNUSED' +} + export type Capability = { __typename?: 'Capability'; icon: Scalars['String']; @@ -226,7 +234,9 @@ export type MakerSkillInput = { export type Mutation = { __typename?: 'Mutation'; + acceptOrRejectClubInvitation: User; addProjectToTournament: Maybe; + applyToFoundersClub: Scalars['String']; confirmDonation: Donation; confirmVote: Vote; createProject: Maybe; @@ -246,11 +256,21 @@ export type Mutation = { }; +export type MutationAcceptOrRejectClubInvitationArgs = { + data: InputMaybe; +}; + + export type MutationAddProjectToTournamentArgs = { input: InputMaybe; }; +export type MutationApplyToFoundersClubArgs = { + data: InputMaybe; +}; + + export type MutationConfirmDonationArgs = { payment_request: Scalars['String']; preimage: Scalars['String']; @@ -504,6 +524,7 @@ export type Query = { getTournamentToRegister: Array; getTrendingPosts: Array; hottestProjects: Array; + isClubInvitationValid: Maybe; me: Maybe; newProjects: Array; officialTags: Array; @@ -624,6 +645,11 @@ export type QueryHottestProjectsArgs = { }; +export type QueryIsClubInvitationValidArgs = { + invitationCode: InputMaybe; +}; + + export type QueryNewProjectsArgs = { skip?: InputMaybe; take?: InputMaybe; @@ -913,6 +939,7 @@ export type User = BaseUser & { github: Maybe; id: Scalars['Int']; in_tournament: Scalars['Boolean']; + is_in_founders_club: Scalars['Boolean']; jobTitle: Maybe; join_date: Scalars['Date']; lightning_address: Maybe; @@ -979,6 +1006,17 @@ export type WalletKey = { name: Scalars['String']; }; +export type AcceptOrRejectClubInvitationInput = { + code: Scalars['String']; + email?: InputMaybe; + isAccepted: Scalars['Boolean']; +}; + +export type ApplyToFoundersClubInput = { + project_id: Scalars['Int']; + reason: Scalars['String']; +}; + export type OfficialTagsQueryVariables = Exact<{ [key: string]: never; }>; @@ -1028,6 +1066,27 @@ export type ConfirmDonationMutationVariables = Exact<{ export type ConfirmDonationMutation = { __typename?: 'Mutation', confirmDonation: { __typename?: 'Donation', id: number, amount: number, paid: boolean } }; +export type FounderClubLandingPageQueryVariables = Exact<{ + invitationCode: InputMaybe; +}>; + + +export type FounderClubLandingPageQuery = { __typename?: 'Query', isClubInvitationValid: Club_Invitation_Status | null, me: { __typename?: 'User', id: number, is_in_founders_club: boolean } | null }; + +export type ApplyToFoundersClubMutationVariables = Exact<{ + data: InputMaybe; +}>; + + +export type ApplyToFoundersClubMutation = { __typename?: 'Mutation', applyToFoundersClub: string }; + +export type AcceptOrRejectClubInvitationMutationVariables = Exact<{ + data: InputMaybe; +}>; + + +export type AcceptOrRejectClubInvitationMutation = { __typename?: 'Mutation', acceptOrRejectClubInvitation: { __typename?: 'User', id: number, is_in_founders_club: boolean } }; + export type GetHackathonsQueryVariables = Exact<{ sortBy: InputMaybe; tag: InputMaybe; @@ -1766,6 +1825,108 @@ export function useConfirmDonationMutation(baseOptions?: Apollo.MutationHookOpti export type ConfirmDonationMutationHookResult = ReturnType; export type ConfirmDonationMutationResult = Apollo.MutationResult; export type ConfirmDonationMutationOptions = Apollo.BaseMutationOptions; +export const FounderClubLandingPageDocument = gql` + query FounderClubLandingPage($invitationCode: String) { + isClubInvitationValid(invitationCode: $invitationCode) + me { + id + is_in_founders_club + } +} + `; + +/** + * __useFounderClubLandingPageQuery__ + * + * To run a query within a React component, call `useFounderClubLandingPageQuery` and pass it any options that fit your needs. + * When your component renders, `useFounderClubLandingPageQuery` 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 } = useFounderClubLandingPageQuery({ + * variables: { + * invitationCode: // value for 'invitationCode' + * }, + * }); + */ +export function useFounderClubLandingPageQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FounderClubLandingPageDocument, options); + } +export function useFounderClubLandingPageLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FounderClubLandingPageDocument, options); + } +export type FounderClubLandingPageQueryHookResult = ReturnType; +export type FounderClubLandingPageLazyQueryHookResult = ReturnType; +export type FounderClubLandingPageQueryResult = Apollo.QueryResult; +export const ApplyToFoundersClubDocument = gql` + mutation ApplyToFoundersClub($data: applyToFoundersClubInput) { + applyToFoundersClub(data: $data) +} + `; +export type ApplyToFoundersClubMutationFn = Apollo.MutationFunction; + +/** + * __useApplyToFoundersClubMutation__ + * + * To run a mutation, you first call `useApplyToFoundersClubMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApplyToFoundersClubMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [applyToFoundersClubMutation, { data, loading, error }] = useApplyToFoundersClubMutation({ + * variables: { + * data: // value for 'data' + * }, + * }); + */ +export function useApplyToFoundersClubMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApplyToFoundersClubDocument, options); + } +export type ApplyToFoundersClubMutationHookResult = ReturnType; +export type ApplyToFoundersClubMutationResult = Apollo.MutationResult; +export type ApplyToFoundersClubMutationOptions = Apollo.BaseMutationOptions; +export const AcceptOrRejectClubInvitationDocument = gql` + mutation AcceptOrRejectClubInvitation($data: acceptOrRejectClubInvitationInput) { + acceptOrRejectClubInvitation(data: $data) { + id + is_in_founders_club + } +} + `; +export type AcceptOrRejectClubInvitationMutationFn = Apollo.MutationFunction; + +/** + * __useAcceptOrRejectClubInvitationMutation__ + * + * To run a mutation, you first call `useAcceptOrRejectClubInvitationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useAcceptOrRejectClubInvitationMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [acceptOrRejectClubInvitationMutation, { data, loading, error }] = useAcceptOrRejectClubInvitationMutation({ + * variables: { + * data: // value for 'data' + * }, + * }); + */ +export function useAcceptOrRejectClubInvitationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(AcceptOrRejectClubInvitationDocument, options); + } +export type AcceptOrRejectClubInvitationMutationHookResult = ReturnType; +export type AcceptOrRejectClubInvitationMutationResult = Apollo.MutationResult; +export type AcceptOrRejectClubInvitationMutationOptions = Apollo.BaseMutationOptions; export const GetHackathonsDocument = gql` query getHackathons($sortBy: String, $tag: Int) { getAllHackathons(sortBy: $sortBy, tag: $tag) { diff --git a/src/mocks/data/users.ts b/src/mocks/data/users.ts index 2d0a3746..0310a2fe 100644 --- a/src/mocks/data/users.ts +++ b/src/mocks/data/users.ts @@ -175,6 +175,7 @@ export const users: User[] = [ website: "https://mtg-dev.tech", stories: posts.stories, in_tournament: true, + is_in_founders_club: true, roles: randomItems(3, ...allMakersRoles).map((role) => ({ ...role, level: randomItem(...Object.values(RoleLevelEnum)), @@ -228,6 +229,7 @@ export const users: User[] = [ website: "https://mtg-dev.tech", stories: posts.stories, in_tournament: true, + is_in_founders_club: true, roles: randomItems(3, ...allMakersRoles).map((role) => ({ ...role, level: randomItem(...Object.values(RoleLevelEnum)), @@ -282,6 +284,7 @@ export const users: User[] = [ website: "https://mtg-dev.tech", stories: posts.stories, in_tournament: true, + is_in_founders_club: true, roles: randomItems(3, ...allMakersRoles).map((role) => ({ ...role, level: randomItem(...Object.values(RoleLevelEnum)), @@ -336,6 +339,7 @@ export const users: User[] = [ website: "https://mtg-dev.tech", stories: posts.stories, in_tournament: true, + is_in_founders_club: true, roles: randomItems(3, ...allMakersRoles).map((role) => ({ ...role, level: randomItem(...Object.values(RoleLevelEnum)), diff --git a/src/styles/index.scss b/src/styles/index.scss index 7339ce3f..a89a0d92 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -8,6 +8,7 @@ $screen-xs-min: 320px; html { scrollbar-gutter: stable; + scroll-behavior: smooth; } body { diff --git a/src/utils/routing/helpers/createLoader.ts b/src/utils/routing/helpers/createLoader.ts index 74ca6af7..92cc3298 100644 --- a/src/utils/routing/helpers/createLoader.ts +++ b/src/utils/routing/helpers/createLoader.ts @@ -1,6 +1,10 @@ -import { ApolloClient, QueryOptions } from "@apollo/client"; +import { ApolloClient, QueryOptions as BaseQueryOptions } from "@apollo/client"; import { LoaderFunctionArgs } from "react-router-dom"; +type QueryOptions = BaseQueryOptions & { + skip?: boolean; +}; + export function createLoader( createQueryOptions: (args: LoaderFunctionArgs) => QueryOptions ): ( @@ -28,6 +32,8 @@ export function createLoader( const queryOptions = props ? createQueryOptions(args, props) : createQueryOptions(args); + + if (queryOptions.skip) return null; return (await queryClient.query(queryOptions)).data; }; } diff --git a/src/utils/routing/rootRouter.tsx b/src/utils/routing/rootRouter.tsx index 29e9772c..06d87eec 100644 --- a/src/utils/routing/rootRouter.tsx +++ b/src/utils/routing/rootRouter.tsx @@ -19,6 +19,7 @@ import ErrorPage from "src/Components/Errors/ErrorPage/ErrorPage"; import { allTopicsPageLoader } from "src/features/Posts/pages/AllTopicsPage/allTopicsPage.loader"; import { feedPageLoader } from "src/features/Posts/pages/FeedPage/feedPage.loader"; import { Post_Type } from "src/graphql"; +import { foundersClubLandingPageLoader } from "src/features/FoundersClub/pages/LandingPage/foundersLandingPage.loader"; const HomePage = Loadable( React.lazy( @@ -186,6 +187,15 @@ const HangoutPage = Loadable( ) ); +const FoundersClubLandingPage = Loadable( + React.lazy( + () => + import( + /* webpackChunkName: "hangout_page" */ "../../features/FoundersClub/pages/LandingPage/LandingPage" + ) + ) +); + const createRoutes = (queryClient: ApolloClient) => createRoutesFromElements( } errorElement={}> @@ -281,6 +291,12 @@ const createRoutes = (queryClient: ApolloClient) => } /> + } + /> + } diff --git a/src/utils/routing/routes.ts b/src/utils/routing/routes.ts index f75d77f3..691a87a8 100644 --- a/src/utils/routing/routes.ts +++ b/src/utils/routing/routes.ts @@ -152,6 +152,10 @@ export const PAGES_ROUTES = { hangout: { default: "/hangout", }, + + foundersClub: { + default: "/founders-club", + }, blog: { feed: "/feed", postById: "/feed/post/:type/:id/*",