* Crypto events (#1390)

* update schemas, functions & ui to allow creating and updating events with a smart contract property

* remove adding sc address in the dialog that first pops-up when creating a new event, since its an advanced option

* add sc to booking ui

* some more ts && error handling

* fetch erc20s and nfts list in event-type page

* some cleanup within time limit

* ts fix 1

* more ts fixes

* added web3 section to integrations

* added web3 wrapper, needs connection to user_settings db

* extract to api

* Update eventType.ts

* Update components/CryptoSection.tsx

Change comment from // to /** as @zomars suggested

Co-authored-by: Omar López <zomars@me.com>

* convert axios to fetch, change scAddress to smartContractAddress, load bloxy from next_public_env

* Fix branch conflict

* add enable/disable btn web3

* fixed away user causing duplicate entries

* Remove web3 validation

* renamed web3 button in integrations

* remove unused variable

* Add metadata column

* added loader and showToast to the web3 btn

* fix: remove smartContractAddress from info sended

* send to user events when the contract is missing

* use window.web3 instead of web3

* use NEXT_PUBLIC_WEB3_AUTH_MSG

* remove web3 auth from .env

* wip

* wip

* Add metamask not installed msg and success redirect

* add redirect when verified

* styled web3 button and added i18n to web3

* fixed redirect after verification

* wip

* wip

* moved crypto section to ee

Co-authored-by: Yuval Drori <53199044+yuvd@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@richelsen.net>
Co-authored-by: Yuval Drori <yuvald29@protonmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Edward Fernandez <edward.fernandez@rappi.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Edward Fernández 2022-02-01 16:48:40 -05:00 committed by GitHub
parent a81bb67cb1
commit 1d10874890
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 10630 additions and 1409 deletions

View File

@ -90,3 +90,8 @@ CALENDSO_ENCRYPTION_KEY=
# Intercom Config
NEXT_PUBLIC_INTERCOM_APP_ID=
# Web3/Crypto stuff
NEXT_PUBLIC_BLOXY_API_KEY=
# Auth message can be whatever you want, doesn't really matter because it's encrypted with a private key anyway, and will be visible to the signee
NEXT_PUBLIC_WEB3_AUTH_MSG=

View File

@ -17,11 +17,11 @@ const NavTabs: FC<Props> = ({ tabs, linkProps }) => {
const router = useRouter();
return (
<>
<nav className="-mb-px flex space-x-2 sm:space-x-5" aria-label="Tabs">
<nav className="flex -mb-px space-x-2 sm:space-x-5" aria-label="Tabs">
{tabs.map((tab) => {
const isCurrent = router.asPath === tab.href;
return (
<Link {...linkProps} key={tab.name} href={tab.href}>
<Link key={tab.name} href={tab.href} {...linkProps}>
<a
className={classNames(
isCurrent

View File

@ -1,6 +1,7 @@
// Get router variables
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, CreditCardIcon, GlobeIcon } from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useContracts } from "contexts/contractsContext";
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import utc from "dayjs/plugin/utc";
@ -36,6 +37,15 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
const { rescheduleUid } = router.query;
const { isReady } = useTheme(profile.theme);
const { t } = useLocale();
const { contracts } = useContracts();
useEffect(() => {
if (eventType.metadata.smartContractAddress) {
const eventOwner = eventType.users[0];
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
router.replace(`/${eventOwner.username}`);
}
}, [contracts, eventType.metadata.smartContractAddress, router]);
const selectedDate = useMemo(() => {
const dateString = asStringOrNull(router.query.date);

View File

@ -6,11 +6,12 @@ import {
LocationMarkerIcon,
} from "@heroicons/react/solid";
import { EventTypeCustomInputType } from "@prisma/client";
import { useContracts } from "contexts/contractsContext";
import dayjs from "dayjs";
import dynamic from "next/dynamic";
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
@ -42,9 +43,33 @@ const PhoneInput = dynamic(() => import("@components/ui/form/PhoneInput"));
type BookingPageProps = BookPageProps | TeamBookingPageProps;
type BookingFormValues = {
name: string;
email: string;
notes?: string;
locationType?: LocationType;
guests?: string[];
phone?: string;
customInputs?: {
[key: string]: string;
};
};
const BookingPage = (props: BookingPageProps) => {
const { t, i18n } = useLocale();
const router = useRouter();
const { contracts } = useContracts();
const { eventType } = props;
useEffect(() => {
if (eventType.metadata.smartContractAddress) {
const eventOwner = eventType.users[0];
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
router.replace(`/${eventOwner.username}`);
}
}, [contracts, eventType.metadata.smartContractAddress, router]);
/*
* This was too optimistic
* I started, then I remembered what a beast book/event.ts is
@ -55,6 +80,7 @@ const BookingPage = (props: BookingPageProps) => {
// go to success page.
},
});*/
const mutation = useMutation(createBooking, {
onSuccess: async ({ attendees, paymentUid, ...responseData }) => {
if (paymentUid) {
@ -101,6 +127,8 @@ const BookingPage = (props: BookingPageProps) => {
const [guestToggle, setGuestToggle] = useState(props.booking && props.booking.attendees.length > 1);
const eventTypeDetail = { isWeb3Active: false, ...props.eventType };
type Location = { type: LocationType; address?: string };
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
const locations: Location[] = useMemo(
@ -127,18 +155,6 @@ const BookingPage = (props: BookingPageProps) => {
[LocationType.Daily]: "Daily.co Video",
};
type BookingFormValues = {
name: string;
email: string;
notes?: string;
locationType?: LocationType;
guests?: string[];
phone?: string;
customInputs?: {
[key: string]: string;
};
};
const defaultValues = () => {
if (!rescheduleUid) {
return {
@ -216,6 +232,8 @@ const BookingPage = (props: BookingPageProps) => {
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
// @TODO: move to metadata
const metadata = Object.keys(router.query)
.filter((key) => key.startsWith("metadata"))
.reduce(
@ -226,8 +244,17 @@ const BookingPage = (props: BookingPageProps) => {
{}
);
let web3Details;
if (eventTypeDetail.metadata.smartContractAddress) {
web3Details = {
userWallet: web3.currentProvider.selectedAddress,
userSignature: contracts[(eventTypeDetail.metadata.smartContractAddress || null) as number],
};
}
mutation.mutate({
...booking,
web3Details,
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
eventTypeId: props.eventType.id,
@ -312,6 +339,12 @@ const BookingPage = (props: BookingPageProps) => {
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{parseDate(date)}
</p>
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
Requires ownership of a token belonging to the following address:{" "}
{eventType.metadata.smartContractAddress}
</p>
)}
<p className="mb-8 text-gray-600 dark:text-white">{props.eventType.description}</p>
</div>
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">

View File

@ -0,0 +1,42 @@
import { createContext, ReactNode, useContext, useState } from "react";
type contractsContextType = Record<string, string>;
const contractsContextDefaultValue: contractsContextType = {};
const ContractsContext = createContext<contractsContextType>(contractsContextDefaultValue);
export function useContracts() {
return useContext(ContractsContext);
}
type Props = {
children: ReactNode;
};
interface addContractsPayload {
address: string;
signature: string;
}
export function ContractsProvider({ children }: Props) {
const [contracts, setContracts] = useState<Record<string, string>>({});
const addContract = (payload: addContractsPayload) => {
setContracts((prevContracts) => ({
...prevContracts,
[payload.address]: payload.signature,
}));
};
const value = {
contracts,
addContract,
};
return (
<>
<ContractsContext.Provider value={value}>{children}</ContractsContext.Provider>
</>
);
}

View File

@ -0,0 +1,149 @@
import Router from "next/router";
import { useCallback, useMemo, useState } from "react";
import React from "react";
import Web3 from "web3";
import { AbiItem } from "web3-utils";
import verifyAccount, { AUTH_MESSAGE } from "web3/utils/verifyAccount";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { Button } from "@components/ui/Button";
import { useContracts } from "../../../contexts/contractsContext";
import genericAbi from "../../../web3/abis/abiWithGetBalance.json";
interface Window {
ethereum: any;
web3: Web3;
}
interface EvtsToVerify {
[eventId: string]: boolean;
}
declare const window: Window;
interface CryptoSectionProps {
id: number | string;
pathname: string;
smartContractAddress: string;
/** When set to true, there will be only 1 button which will both connect Metamask and verify the user's wallet. Otherwise, it will be in 2 steps with 2 buttons. */
oneStep: boolean;
verified: boolean | undefined;
setEvtsToVerify: React.Dispatch<React.SetStateAction<Record<number | string, boolean>>>;
}
const CryptoSection = (props: CryptoSectionProps) => {
// Crypto section which should be shown on booking page if event type requires a smart contract token.
const [ethEnabled, toggleEthEnabled] = useState<boolean>(false);
const { addContract } = useContracts();
const { t } = useLocale();
const connectMetamask = useCallback(async () => {
if (window.ethereum) {
await window.ethereum.request({ method: "eth_requestAccounts" });
window.web3 = new Web3(window.ethereum);
toggleEthEnabled(true);
} else {
toggleEthEnabled(false);
}
}, []);
const verifyWallet = useCallback(async () => {
try {
if (!window.web3) {
throw new Error("Metamask is not installed");
}
const contract = new window.web3.eth.Contract(genericAbi as AbiItem[], props.smartContractAddress);
const balance = await contract.methods.balanceOf(window.ethereum.selectedAddress).call();
const hasToken = balance > 0;
if (!hasToken) {
throw new Error("Specified wallet does not own any tokens belonging to this smart contract");
} else {
const account = (await web3.eth.getAccounts())[0];
const signature = await window.web3.eth.personal.sign(AUTH_MESSAGE, account);
addContract({ address: props.smartContractAddress, signature });
await verifyAccount(signature, account);
props.setEvtsToVerify((prevState: EvtsToVerify) => {
const changedEvt = { [props.id]: hasToken };
return { ...prevState, ...changedEvt };
});
}
} catch (err) {
err instanceof Error ? showToast(err.message, "error") : showToast("An error has occurred", "error");
}
}, [props, addContract]);
// @TODO: Show error on either of buttons if fails. Yup schema already contains the error message.
const successButton = useMemo(() => {
console.log(props);
if (props.verified) {
Router.push(props.pathname);
}
return <div />;
}, [props.verified]);
const verifyButton = useMemo(() => {
return (
<Button color="secondary" onClick={verifyWallet} type="button" id="hasToken" name="hasToken">
<img className="h-5 mr-1" src="/integrations/metamask.svg" />
{t("verify_wallet")}
</Button>
);
}, [verifyWallet, t]);
const connectButton = useMemo(() => {
return (
<Button color="secondary" onClick={connectMetamask} type="button">
<img className="h-5 mr-1" src="/integrations/metamask.svg" />
{t("connect_metamask")}
</Button>
);
}, [connectMetamask, t]);
const oneStepButton = useMemo(() => {
return (
<Button
color="secondary"
type="button"
onClick={async () => {
await connectMetamask();
await verifyWallet();
}}>
<img className="h-5 mr-1" src="/integrations/metamask.svg" />
{t("verify_wallet")}
</Button>
);
}, [connectMetamask, verifyWallet, t]);
const determineButton = useCallback(() => {
// Did it in an extra function for some added readability, but this can be done in a ternary depending on preference
if (props.oneStep) {
return props.verified ? successButton : oneStepButton;
} else {
if (ethEnabled) {
return props.verified ? successButton : verifyButton;
} else {
return connectButton;
}
}
}, [props.verified, successButton, oneStepButton, connectButton, ethEnabled, props.oneStep, verifyButton]);
return (
<div
className="absolute transition-opacity transform -translate-x-1/2 -translate-y-1/2 opacity-0 top-1/2 left-1/2 group-hover:opacity-100"
id={`crypto-${props.id}`}>
{determineButton()}
</div>
);
};
export default CryptoSection;

View File

@ -22,11 +22,12 @@ export type Integration = {
| "daily_video"
| "caldav_calendar"
| "apple_calendar"
| "stripe_payment";
| "stripe_payment"
| "metamask_web3";
title: string;
imageSrc: string;
description: string;
variant: "calendar" | "conferencing" | "payment";
variant: "calendar" | "conferencing" | "payment" | "web3";
};
export const ALL_INTEGRATIONS = [
@ -90,6 +91,14 @@ export const ALL_INTEGRATIONS = [
description: "Collect payments",
variant: "payment",
},
{
installed: true,
type: "metamask_web3",
title: "Metamask",
imageSrc: "integrations/apple-calendar.svg",
description: "For personal and business calendars",
variant: "web3",
},
] as Integration[];
function getIntegrations(userCredentials: CredentialData[]) {

View File

@ -1,8 +1,10 @@
import { EventType, SchedulingType } from "@prisma/client";
import { JSONObject } from "superjson/dist/types";
import { WorkingHours } from "./schedule";
export type AdvancedOptions = {
metadata?: JSONObject;
eventName?: string;
periodType?: string;
periodDays?: number;

View File

@ -27,6 +27,7 @@
"postinstall": "yarn generate-schemas",
"pre-commit": "lint-staged",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"prepare": "husky install",
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
},
@ -39,8 +40,10 @@
"@daily-co/daily-js": "^0.21.0",
"@headlessui/react": "^1.4.2",
"@heroicons/react": "^1.0.5",
"@hookform/resolvers": "^2.8.3",
"@hookform/error-message": "^2.0.0",
"@hookform/resolvers": "^2.8.5",
"@jitsu/sdk-js": "^2.2.4",
"@metamask/providers": "^8.1.1",
"@next/bundle-analyzer": "11.1.2",
"@prisma/client": "3.0.2",
"@radix-ui/react-avatar": "^0.1.0",
@ -103,6 +106,7 @@
"tsdav": "2.0.0-rc.3",
"tslog": "^3.2.1",
"uuid": "^8.3.2",
"web3": "^1.6.1",
"zod": "^3.8.2"
},
"devDependencies": {

View File

@ -1,12 +1,14 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
import { MoonIcon } from "@heroicons/react/solid";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
import React, { useState } from "react";
import { Toaster } from "react-hot-toast";
import { JSONObject } from "superjson/dist/types";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import showToast from "@lib/notification";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -16,6 +18,12 @@ import Avatar from "@components/ui/Avatar";
import { ssrInit } from "@server/lib/ssr";
import CryptoSection from "../ee/components/web3/CryptoSection";
interface EvtsToVerify {
[evtId: string]: boolean;
}
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
const { isReady } = useTheme(props.user.theme);
const { user, eventTypes } = props;
@ -26,6 +34,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
const nameOrUsername = user.name || user.username || "";
const [evtsToVerify, setEvtsToVerify] = useState<EvtsToVerify>({});
return (
<>
<HeadSeo
@ -50,17 +60,11 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
<p className="text-neutral-500 dark:text-white">{user.bio}</p>
</div>
<div className="space-y-6" data-testid="event-types">
{user.away && (
<div className="relative px-6 py-4 bg-white border rounded-sm group dark:bg-neutral-900 dark:border-0 border-neutral-200">
<MoonIcon className="w-8 h-8 mb-4 text-neutral-800" />
<h2 className="font-semibold text-neutral-900 dark:text-white">{t("user_away")}</h2>
<p className="text-neutral-500 dark:text-white">{t("user_away_description")}</p>
</div>
)}
{!user.away &&
eventTypes.map((type) => (
<div
key={type.id}
style={{ display: "flex" }}
className="relative bg-white border rounded-sm group dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 hover:bg-gray-50 border-neutral-200 hover:border-brand">
<ArrowRightIcon className="absolute w-4 h-4 text-black transition-opacity opacity-0 right-3 top-3 dark:text-white group-hover:opacity-100" />
<Link
@ -68,11 +72,33 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
pathname: `/${user.username}/${type.slug}`,
query,
}}>
<a className="block px-6 py-4" data-testid="event-type-link">
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<a
onClick={(e) => {
// If a token is required for this event type, add a click listener that checks whether the user verified their wallet or not
if (type.metadata.smartContractAddress && !evtsToVerify[type.id]) {
e.preventDefault();
showToast(
"You must verify a wallet with a token belonging to the specified smart contract first",
"error"
);
}
}}
className="block px-6 py-4"
data-testid="event-type-link">
<h2 className="font-semibold grow text-neutral-900 dark:text-white">{type.title}</h2>
<EventTypeDescription eventType={type} />
</a>
</Link>
{type.isWeb3Active && type.metadata.smartContractAddress && (
<CryptoSection
id={type.id}
pathname={`/${user.username}/${type.slug}`}
smartContractAddress={type.metadata.smartContractAddress as string}
verified={evtsToVerify[type.id]}
setEvtsToVerify={setEvtsToVerify}
oneStep
/>
)}
</div>
))}
</div>
@ -87,6 +113,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
</div>
)}
</main>
<Toaster position="bottom-right" />
</div>
)}
</>
@ -121,6 +148,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
}
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
},
select: {
id: true,
type: true,
key: true,
},
});
const web3Credentials = credentials.find((credential) => credential.type.includes("_web3"));
const eventTypesWithHidden = await prisma.eventType.findMany({
where: {
AND: [
@ -161,11 +201,21 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
schedulingType: true,
price: true,
currency: true,
metadata: true,
},
take: user.plan === "FREE" ? 1 : undefined,
});
const eventTypes = eventTypesWithHidden.filter((evt) => !evt.hidden);
const eventTypesRaw = eventTypesWithHidden.filter((evt) => !evt.hidden);
const eventTypes = eventTypesRaw.map((eventType) => ({
...eventType,
metadata: (eventType.metadata || {}) as JSONObject,
isWeb3Active:
web3Credentials && web3Credentials.key
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
: false,
}));
return {
props: {

View File

@ -1,5 +1,6 @@
import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
@ -44,6 +45,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
schedulingType: true,
minimumBookingNotice: true,
timeZone: true,
metadata: true,
slotInterval: true,
users: {
select: {
@ -118,6 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
notFound: true,
};
}
eventTypeBackwardsCompat.users.push({
avatar: user.avatar,
name: user.name,
@ -126,6 +129,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
plan: user.plan,
timeZone: user.timeZone,
});
user.eventTypes.push(eventTypeBackwardsCompat);
}
@ -163,6 +167,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}*/
const eventTypeObject = Object.assign({}, eventType, {
metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});

View File

@ -2,6 +2,7 @@ import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { asStringOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
@ -22,12 +23,12 @@ export default function Book(props: BookPageProps) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const user = await prisma.user.findUnique({
where: {
username: asStringOrThrow(context.query.user),
},
select: {
id: true,
username: true,
name: true,
email: true,
@ -40,7 +41,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!user) return { notFound: true };
const eventType = await prisma.eventType.findUnique({
const eventTypeRaw = await prisma.eventType.findUnique({
where: {
id: parseInt(asStringOrThrow(context.query.type)),
},
@ -56,6 +57,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodDays: true,
periodStartDate: true,
periodEndDate: true,
metadata: true,
periodCountCalendarDays: true,
price: true,
currency: true,
@ -73,7 +75,29 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
});
if (!eventType) return { notFound: true };
if (!eventTypeRaw) return { notFound: true };
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
},
select: {
id: true,
type: true,
key: true,
},
});
const web3Credentials = credentials.find((credential) => credential.type.includes("_web3"));
const eventType = {
...eventTypeRaw,
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
isWeb3Active:
web3Credentials && web3Credentials.key
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
: false,
};
const eventTypeObject = [eventType].map((e) => {
return {

View File

@ -13,17 +13,20 @@ import { withTRPC } from "@trpc/next";
import type { TRPCClientErrorLike } from "@trpc/react";
import { Maybe } from "@trpc/server";
import { ContractsProvider } from "../contexts/contractsContext";
import "../styles/fonts.css";
import "../styles/globals.css";
function MyApp(props: AppProps) {
const { Component, pageProps, err } = props;
return (
<AppProviders {...props}>
<DefaultSeo {...seoConfig.defaultNextSeo} />
<I18nLanguageHandler />
<Component {...pageProps} err={err} />
</AppProviders>
<ContractsProvider>
<AppProviders {...props}>
<DefaultSeo {...seoConfig.defaultNextSeo} />
<I18nLanguageHandler />
<Component {...pageProps} err={err} />
</AppProviders>
</ContractsProvider>
);
}

View File

@ -8,6 +8,7 @@ import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import verifyAccount from "web3/utils/verifyAccount";
import { handlePayment } from "@ee/lib/stripe/server";
@ -102,6 +103,7 @@ function isAvailable(busyTimes: BufferedBusyTimes, time: string, length: number)
function isOutOfBounds(
time: dayjs.ConfigType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, timeZone }: any // FIXME types
): boolean {
const date = dayjs(time);
@ -226,6 +228,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
userId: true,
price: true,
currency: true,
metadata: true,
destinationCalendar: true,
},
});
@ -349,7 +352,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Initialize EventManager with credentials
const rescheduleUid = reqBody.rescheduleUid;
function createBooking() {
async function createBooking() {
// @TODO: check as metadata
if (req.body.web3Details) {
const { web3Details } = req.body;
await verifyAccount(web3Details.userSignature, web3Details.userWallet);
}
return prisma.booking.create({
include: {
user: {

View File

@ -35,11 +35,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
schedulingType: true,
slug: true,
hidden: true,
metadata: true,
},
},
},
});
return res.status(200).json({ message: "Events.", data: user.eventTypes });
return res.status(200).json({ message: "Events.", data: user?.eventTypes });
}
}

View File

@ -7,7 +7,7 @@ import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
}
@ -22,6 +22,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
hidden: true,
price: true,
currency: true,
metadata: true,
users: {
select: {
id: true,
@ -109,7 +110,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({
...et,
$disabled: user.plan === "FREE" && index > 0,
$disabled: user?.plan === "FREE" && index > 0,
}));
return res.status(200).json({ eventTypes: mergedEventTypes });

View File

@ -24,6 +24,7 @@ import React, { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import Select from "react-select";
import { JSONObject } from "superjson/dist/types";
import { StripeData } from "@ee/lib/stripe/server";
@ -55,9 +56,21 @@ import { DateRangePicker } from "@components/ui/form/DateRangePicker";
import MinutesField from "@components/ui/form/MinutesField";
import * as RadioArea from "@components/ui/form/radio-area";
import bloxyApi from "../../web3/dummyResps/bloxyApi";
dayjs.extend(utc);
dayjs.extend(timezone);
interface Token {
name?: string;
address: string;
symbol: string;
}
interface NFT extends Token {
// Some OpenSea NFTs have several contracts
contracts: Array<Token>;
}
type AvailabilityInput = Pick<Availability, "days" | "startTime" | "endTime">;
type OptionTypeBase = {
@ -145,6 +158,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
eventType.customInputs.sort((a, b) => a.id - b.id) || []
);
const [tokensList, setTokensList] = useState<Array<Token>>([]);
const periodType =
PERIOD_TYPES.find((s) => s.type === eventType.periodType) ||
@ -153,6 +167,41 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
useEffect(() => {
const fetchTokens = async () => {
// Get a list of most popular ERC20s and ERC777s, combine them into a single list, set as tokensList
try {
const erc20sList: Array<Token> =
// await axios.get(`https://api.bloxy.info/token/list?key=${process.env.BLOXY_API_KEY}`)
// ).data
bloxyApi.slice(0, 100).map((erc20: Token) => {
const { name, address, symbol } = erc20;
return { name, address, symbol };
});
const exodiaList = await (await fetch(`https://exodia.io/api/trending?page=1`)).json();
const nftsList: Array<Token> = exodiaList.map((nft: NFT) => {
const { name, contracts } = nft;
if (nft.contracts[0]) {
const { address, symbol } = contracts[0];
return { name, address, symbol };
}
});
const unifiedList: Array<Token> = [...erc20sList, ...nftsList];
setTokensList(unifiedList);
} catch (err) {
showToast("Failed to load ERC20s & NFTs list. Please enter an address manually.", "error");
}
};
console.log(tokensList); // Just here to make sure it passes the gc hook. Can remove once actual use is made of tokensList.
fetchTokens();
}, []);
useEffect(() => {
setSelectedTimeZone(eventType.timeZone || "");
}, []);
@ -261,6 +310,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const formMethods = useForm<{
title: string;
eventTitle: string;
smartContractAddress: string;
eventName: string;
slug: string;
length: number;
@ -512,9 +563,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<Form
form={formMethods}
handleSubmit={async (values) => {
const { periodDates, periodCountCalendarDays, ...input } = values;
const { periodDates, periodCountCalendarDays, smartContractAddress, ...input } = values;
const metadata = {
smartContractAddress: smartContractAddress,
};
updateMutation.mutate({
...input,
metadata,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
@ -728,6 +783,30 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
</div>
</div>
{eventType.isWeb3Active && (
<div className="items-center block sm:flex">
<div className="mb-4 min-w-48 sm:mb-0">
<label
htmlFor="smartContractAddress"
className="flex text-sm font-medium text-neutral-700">
{t("Smart Contract Address")}
</label>
</div>
<div className="w-full">
<div className="relative mt-1 rounded-sm shadow-sm">
{
<input
type="text"
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder={t("Example: 0x71c7656ec7ab88b098defb751b7401b5f6d8976f")}
defaultValue={(eventType.metadata.smartContractAddress || "") as string}
{...formMethods.register("smartContractAddress")}
/>
}
</div>
</div>
</div>
)}
<div className="items-center block sm:flex">
<div className="mb-4 min-w-48 sm:mb-0">
<label
@ -1385,6 +1464,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
customInputs: true,
timeZone: true,
periodType: true,
metadata: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
@ -1426,10 +1506,27 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
address?: string;
};
const { locations, ...restEventType } = rawEventType;
const credentials = await prisma.credential.findMany({
where: {
userId: session.user.id,
},
select: {
id: true,
type: true,
key: true,
},
});
const web3Credentials = credentials.find((credential) => credential.type.includes("_web3"));
const { locations, metadata, ...restEventType } = rawEventType;
const eventType = {
...restEventType,
locations: locations as unknown as Location[],
metadata: (metadata || {}) as JSONObject,
isWeb3Active:
web3Credentials && web3Credentials.key
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
: false,
};
// backwards compat
@ -1444,17 +1541,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventType.users.push(fallbackUser);
}
const credentials = await prisma.credential.findMany({
where: {
userId: session.user.id,
},
select: {
id: true,
type: true,
key: true,
},
});
const integrations = getIntegrations(credentials);
const locationOptions: OptionTypeBase[] = [];

View File

@ -2,11 +2,13 @@ import { ChevronRightIcon, PencilAltIcon, SwitchHorizontalIcon, TrashIcon } from
import { ClipboardIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import Image from "next/image";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { JSONObject } from "superjson/dist/types";
import { QueryCell } from "@lib/QueryCell";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { inferQueryOutput, trpc } from "@lib/trpc";
@ -543,6 +545,86 @@ function IntegrationsContainer() {
);
}
function Web3Container() {
const { t } = useLocale();
return (
<>
<ShellSubHeading title="Web3" subtitle={t("meet_people_with_the_same_tokens")} />
<div className="lg:pb-8 lg:col-span-9">
<List>
<ListItem className={classNames("flex-col")}>
<div className={classNames("flex flex-1 space-x-2 w-full p-3 items-center")}>
<Image width={40} height={40} src="/integrations/metamask.svg" alt="Embed" />
<div className="flex-grow pl-2 truncate">
<ListItemTitle component="h3">
MetaMask (
<a className="text-blue-500" target="_blank" href="https://cal.com/web3" rel="noreferrer">
Read more
</a>
)
</ListItemTitle>
<ListItemText component="p">{t("only_book_people_and_allow")}</ListItemText>
</div>
<Web3ConnectBtn />
</div>
</ListItem>
</List>
</div>
</>
);
}
function Web3ConnectBtn() {
const { t } = useLocale();
const utils = trpc.useContext();
const [connectionBtn, setConnection] = useState(false);
const result = trpc.useQuery(["viewer.web3Integration"]);
const mutation = trpc.useMutation("viewer.enableOrDisableWeb3", {
onSuccess: async (result) => {
const { key = {} } = result as JSONObject;
if ((key as JSONObject).isWeb3Active) {
showToast(t("web3_metamask_added"), "success");
} else {
showToast(t("web3_metamask_disconnected"), "success");
}
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
useEffect(() => {
if (result.data) {
setConnection(result.data.isWeb3Active as boolean);
}
}, [result]);
const enableOrDisableWeb3 = async (mutation: any) => {
const result = await mutation.mutateAsync({});
setConnection(result.key.isWeb3Active);
utils.invalidateQueries("viewer.web3Integration");
};
if (mutation.isLoading) {
return <Loader />;
}
return (
<Button
color={connectionBtn ? "warn" : "secondary"}
disabled={result.isLoading || mutation.isLoading}
onClick={async () => await enableOrDisableWeb3(mutation)}
data-testid="metamask">
{connectionBtn ? t("remove") : t("add")}
</Button>
);
}
export default function IntegrationsPage() {
const { t } = useLocale();
@ -553,6 +635,7 @@ export default function IntegrationsPage() {
<CalendarListContainer />
<WebhookListContainer />
<IframeEmbedContainer />
<Web3Container />
</ClientSuspense>
</Shell>
);

View File

@ -1,4 +1,5 @@
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
@ -64,6 +65,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
currency: true,
timeZone: true,
slotInterval: true,
metadata: true,
},
},
},
@ -85,6 +87,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
);
const eventTypeObject = Object.assign({}, eventType, {
metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});

View File

@ -1,4 +1,5 @@
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { asStringOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
@ -40,6 +41,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
disableGuests: true,
price: true,
currency: true,
metadata: true,
team: {
select: {
slug: true,
@ -61,6 +63,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventTypeObject = [eventType].map((e) => {
return {
...e,
metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: e.periodStartDate?.toString() ?? null,
periodEndDate: e.periodEndDate?.toString() ?? null,
};

View File

@ -0,0 +1,38 @@
-- RenameIndex
ALTER INDEX "Booking_uid_key" RENAME TO "Booking.uid_unique";
-- RenameIndex
ALTER INDEX "DestinationCalendar_bookingId_key" RENAME TO "DestinationCalendar.bookingId_unique";
-- RenameIndex
ALTER INDEX "DestinationCalendar_eventTypeId_key" RENAME TO "DestinationCalendar.eventTypeId_unique";
-- RenameIndex
ALTER INDEX "DestinationCalendar_userId_key" RENAME TO "DestinationCalendar.userId_unique";
-- RenameIndex
ALTER INDEX "EventType_userId_slug_key" RENAME TO "EventType.userId_slug_unique";
-- RenameIndex
ALTER INDEX "Payment_externalId_key" RENAME TO "Payment.externalId_unique";
-- RenameIndex
ALTER INDEX "Payment_uid_key" RENAME TO "Payment.uid_unique";
-- RenameIndex
ALTER INDEX "Team_slug_key" RENAME TO "Team.slug_unique";
-- RenameIndex
ALTER INDEX "VerificationRequest_identifier_token_key" RENAME TO "VerificationRequest.identifier_token_unique";
-- RenameIndex
ALTER INDEX "VerificationRequest_token_key" RENAME TO "VerificationRequest.token_unique";
-- RenameIndex
ALTER INDEX "Webhook_id_key" RENAME TO "Webhook.id_unique";
-- RenameIndex
ALTER INDEX "users_email_key" RENAME TO "users.email_unique";
-- RenameIndex
ALTER INDEX "users_username_key" RENAME TO "users.username_unique";

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "scAddress" TEXT;

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "EventType" DROP COLUMN "scAddress",
ADD COLUMN "smartContractAddress" TEXT;

View File

@ -0,0 +1 @@
ALTER TABLE "EventType" ADD COLUMN "metadata" JSONB;

View File

@ -63,6 +63,7 @@ model EventType {
price Int @default(0)
currency String @default("usd")
slotInterval Int?
metadata Json?
@@unique([userId, slug])
}
@ -216,7 +217,7 @@ model DailyEventReference {
dailyurl String @default("dailycallurl")
dailytoken String @default("dailytoken")
booking Booking? @relation(fields: [bookingId], references: [id])
bookingId Int?
bookingId Int? @unique
}
model Booking {

View File

@ -52,6 +52,7 @@ export const _EventTypeModel = z.object({
price: z.number().int(),
currency: z.string(),
slotInterval: z.number().int().nullish(),
metadata: jsonSchema,
});
export interface CompleteEventType extends z.infer<typeof _EventTypeModel> {

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns:ev="http://www.w3.org/2001/xml-events"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 318.6 318.6"
style="enable-background:new 0 0 318.6 318.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#E2761B;stroke:#E2761B;stroke-linecap:round;stroke-linejoin:round;}
.st1{fill:#E4761B;stroke:#E4761B;stroke-linecap:round;stroke-linejoin:round;}
.st2{fill:#D7C1B3;stroke:#D7C1B3;stroke-linecap:round;stroke-linejoin:round;}
.st3{fill:#233447;stroke:#233447;stroke-linecap:round;stroke-linejoin:round;}
.st4{fill:#CD6116;stroke:#CD6116;stroke-linecap:round;stroke-linejoin:round;}
.st5{fill:#E4751F;stroke:#E4751F;stroke-linecap:round;stroke-linejoin:round;}
.st6{fill:#F6851B;stroke:#F6851B;stroke-linecap:round;stroke-linejoin:round;}
.st7{fill:#C0AD9E;stroke:#C0AD9E;stroke-linecap:round;stroke-linejoin:round;}
.st8{fill:#161616;stroke:#161616;stroke-linecap:round;stroke-linejoin:round;}
.st9{fill:#763D16;stroke:#763D16;stroke-linecap:round;stroke-linejoin:round;}
</style>
<polygon class="st0" points="274.1,35.5 174.6,109.4 193,65.8 "/>
<g>
<polygon class="st1" points="44.4,35.5 143.1,110.1 125.6,65.8 "/>
<polygon class="st1" points="238.3,206.8 211.8,247.4 268.5,263 284.8,207.7 "/>
<polygon class="st1" points="33.9,207.7 50.1,263 106.8,247.4 80.3,206.8 "/>
<polygon class="st1" points="103.6,138.2 87.8,162.1 144.1,164.6 142.1,104.1 "/>
<polygon class="st1" points="214.9,138.2 175.9,103.4 174.6,164.6 230.8,162.1 "/>
<polygon class="st1" points="106.8,247.4 140.6,230.9 111.4,208.1 "/>
<polygon class="st1" points="177.9,230.9 211.8,247.4 207.1,208.1 "/>
</g>
<g>
<polygon class="st2" points="211.8,247.4 177.9,230.9 180.6,253 180.3,262.3 "/>
<polygon class="st2" points="106.8,247.4 138.3,262.3 138.1,253 140.6,230.9 "/>
</g>
<polygon class="st3" points="138.8,193.5 110.6,185.2 130.5,176.1 "/>
<polygon class="st3" points="179.7,193.5 188,176.1 208,185.2 "/>
<g>
<polygon class="st4" points="106.8,247.4 111.6,206.8 80.3,207.7 "/>
<polygon class="st4" points="207,206.8 211.8,247.4 238.3,207.7 "/>
<polygon class="st4" points="230.8,162.1 174.6,164.6 179.8,193.5 188.1,176.1 208.1,185.2 "/>
<polygon class="st4" points="110.6,185.2 130.6,176.1 138.8,193.5 144.1,164.6 87.8,162.1 "/>
</g>
<g>
<polygon class="st5" points="87.8,162.1 111.4,208.1 110.6,185.2 "/>
<polygon class="st5" points="208.1,185.2 207.1,208.1 230.8,162.1 "/>
<polygon class="st5" points="144.1,164.6 138.8,193.5 145.4,227.6 146.9,182.7 "/>
<polygon class="st5" points="174.6,164.6 171.9,182.6 173.1,227.6 179.8,193.5 "/>
</g>
<polygon class="st6" points="179.8,193.5 173.1,227.6 177.9,230.9 207.1,208.1 208.1,185.2 "/>
<polygon class="st6" points="110.6,185.2 111.4,208.1 140.6,230.9 145.4,227.6 138.8,193.5 "/>
<polygon class="st7" points="180.3,262.3 180.6,253 178.1,250.8 140.4,250.8 138.1,253 138.3,262.3 106.8,247.4 117.8,256.4
140.1,271.9 178.4,271.9 200.8,256.4 211.8,247.4 "/>
<polygon class="st8" points="177.9,230.9 173.1,227.6 145.4,227.6 140.6,230.9 138.1,253 140.4,250.8 178.1,250.8 180.6,253 "/>
<g>
<polygon class="st9" points="278.3,114.2 286.8,73.4 274.1,35.5 177.9,106.9 214.9,138.2 267.2,153.5 278.8,140 273.8,136.4
281.8,129.1 275.6,124.3 283.6,118.2 "/>
<polygon class="st9" points="31.8,73.4 40.3,114.2 34.9,118.2 42.9,124.3 36.8,129.1 44.8,136.4 39.8,140 51.3,153.5 103.6,138.2
140.6,106.9 44.4,35.5 "/>
</g>
<polygon class="st6" points="267.2,153.5 214.9,138.2 230.8,162.1 207.1,208.1 238.3,207.7 284.8,207.7 "/>
<polygon class="st6" points="103.6,138.2 51.3,153.5 33.9,207.7 80.3,207.7 111.4,208.1 87.8,162.1 "/>
<polygon class="st6" points="174.6,164.6 177.9,106.9 193.1,65.8 125.6,65.8 140.6,106.9 144.1,164.6 145.3,182.8 145.4,227.6
173.1,227.6 173.3,182.8 "/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,5 @@
<svg width="390" height="390" viewBox="0 0 390 390" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="390" height="390" rx="60" fill="#4F46E5"/>
<path d="M179.559 284.969C156.31 280.584 138.756 272.318 126.897 260.171C115.243 248.063 111.065 233.265 114.364 215.776L157.19 223.855C154.047 240.518 163.386 250.908 185.207 255.024C194.792 256.832 202.523 256.155 208.399 252.992C214.48 249.868 218.096 245.253 219.247 239.149C220.834 230.734 218.801 223.602 213.146 217.751C207.523 211.736 199.291 207.791 188.451 205.917L166.685 202.068L172.054 173.607L193.773 177.704C204.378 179.705 213.097 179.385 219.931 176.744C226.765 174.103 230.851 169.236 232.189 162.141C233.372 155.872 231.889 150.296 227.742 145.413C223.829 140.403 217.183 137.014 207.801 135.244C198.013 133.398 189.889 133.916 183.432 136.798C177.21 139.554 173.399 144.645 171.998 152.069L130.701 144.279C133.813 127.78 142.767 116.142 157.562 109.365C172.592 102.461 191.221 101.106 213.45 105.299C227.93 108.031 239.95 112.604 249.51 119.021C259.306 125.311 266.241 132.77 270.315 141.398C274.39 150.026 275.54 159.042 273.767 168.446C270.499 185.77 259.748 196.3 241.514 200.037C258.751 211.66 265.394 227.948 261.441 248.902C259.667 258.306 255.209 266.264 248.068 272.776C240.957 279.124 231.475 283.486 219.622 285.863C208.004 288.114 194.65 287.816 179.559 284.969Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M230.875 87.5969L201.204 82L193.942 120.498H201.338C209.298 120.498 216.895 122.048 223.846 124.862L230.875 87.5969ZM194.674 279.51H186.596C178.878 279.51 171.5 278.053 164.723 275.398L159.669 302.187L189.341 307.784L194.674 279.51Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -468,6 +468,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} Ereignistyp erfolgreich erstellt",
"event_type_updated_successfully": "Ereignistyp erfolgreich aktualisiert",
"event_type_deleted_successfully": "Event-Typ erfolgreich gelöscht",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Stunden",
"your_email": "Ihre Emailadresse",
"change_avatar": "Profilbild ändern",

View File

@ -468,6 +468,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
"event_type_deleted_successfully": "Event type deleted successfully",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Hours",
"your_email": "Your Email",
"change_avatar": "Change Avatar",
@ -583,7 +585,8 @@
"set_as_free": "Disable away status",
"user_away": "This user is currently away.",
"user_away_description": "The person you are trying to book has set themselves to away, and therefore is not accepting new bookings.",
"saml_config_updated_successfully": "SAML configuration updated successfully",
"meet_people_with_the_same_tokens": "Meet people with the same tokens",
"only_book_people_and_allow": "Only book and allow bookings from people who share the same tokens, DAOs, or NFTs.",
"saml_config_deleted_successfully": "SAML configuration deleted successfully",
"account_created_with_identity_provider": "Your account was created using an Identity Provider.",
"account_managed_by_identity_provider": "Your account is managed by {{provider}}",
@ -604,5 +607,9 @@
"import": "Import",
"import_from": "Import from",
"access_token": "Access token",
"visit_roadmap": "Roadmap"
"visit_roadmap": "Roadmap",
"remove": "Remove",
"add": "Add",
"verify_wallet": "Verify wallet",
"connect_metamask": "Connect Metamask"
}

View File

@ -458,6 +458,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} tipo de evento creado con éxito",
"event_type_updated_successfully": "{{eventTypeTitle}} tipo de evento actualizado con éxito",
"event_type_deleted_successfully": "Tipo de evento eliminado con éxito",
"web3_metamask_added": "Metamask ha sido añadido exitosamente",
"web3_metamask_disconnected": "Metamask ha sido removido exitosamente",
"hours": "Horas",
"your_email": "Tu Email",
"change_avatar": "Cambiar Avatar",

View File

@ -426,6 +426,8 @@
"event_type_created_successfully": "Type d'événement {{eventTypeTitle}} créé avec succès",
"event_type_updated_successfully": "Type d'événement {{eventTypeTitle}} mis à jour avec succès",
"event_type_deleted_successfully": "Type d'événement supprimé avec succès",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Heures",
"your_email": "Votre adresse e-mail",
"change_avatar": "Changer d'avatar",

View File

@ -452,6 +452,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} tipo di evento creato con successo",
"event_type_updated_successfully": "{{eventTypeTitle}} tipo di evento aggiornato con successo",
"event_type_deleted_successfully": "Tipo di evento eliminato con successo",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Ore",
"your_email": "La Tua Email",
"change_avatar": "Cambia Avatar",

View File

@ -425,6 +425,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} イベント種別が正常に作成されました",
"event_type_updated_successfully": "{{eventTypeTitle}} イベント種別が正常に更新されました",
"event_type_deleted_successfully": "イベント種別が正常に削除されました",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "時間",
"your_email": "あなたのメールアドレス",
"change_avatar": "アバターを変更",

View File

@ -447,6 +447,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} 이벤트 타입이 성공적으로 생성되었습니다.",
"event_type_updated_successfully": "{{eventTypeTitle}} 이벤트 타입이 성공적으로 업데이트되었습니다.",
"event_type_deleted_successfully": "이벤트 타입이 성공적으로 삭제되었습니다.",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "시간",
"your_email": "이메일 주소",
"change_avatar": "아바타 변경하기",

View File

@ -418,6 +418,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} evenement met succes aangemaakt",
"event_type_updated_successfully": "Evenement is bijgewerkt",
"event_type_deleted_successfully": "Evenement is verwijderd",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Uren",
"your_email": "Uw E-mailadres",
"change_avatar": "Wijzig avatar",

View File

@ -462,6 +462,8 @@
"event_type_created_successfully": "Utworzono {{eventTypeTitle}} typ wydarzenia pomyślnie",
"event_type_updated_successfully": "{{eventTypeTitle}} typ wydarzenia został pomyślnie zaktualizowany",
"event_type_deleted_successfully": "Typ wydarzenia usunięty pomyślnie",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Godziny",
"your_email": "Twój e-mail",
"change_avatar": "Zmień Awatar",

View File

@ -462,6 +462,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} evento criado com sucesso",
"event_type_updated_successfully": "Tipo de evento atualizado com sucesso",
"event_type_deleted_successfully": "Tipo de evento removido com sucesso",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Horas",
"your_email": "Seu Email",
"change_avatar": "Alterar Avatar",

View File

@ -468,6 +468,8 @@
"event_type_created_successfully": "Tipo de evento {{eventTypeTitle}} criado com sucesso",
"event_type_updated_successfully": "Tipo de evento atualizado com sucesso",
"event_type_deleted_successfully": "Tipo de evento eliminado com sucesso",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Horas",
"your_email": "Seu Email",
"change_avatar": "Alterar Avatar",

View File

@ -425,6 +425,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} tip de eveniment creat cu succes",
"event_type_updated_successfully": "{{eventTypeTitle}} tip de eveniment actualizat cu succes",
"event_type_deleted_successfully": "Tipul de eveniment șters cu succes",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Ore",
"your_email": "E-mailul tău",
"change_avatar": "Schimbă avatarul",

View File

@ -462,6 +462,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} тип мероприятия успешно создан",
"event_type_updated_successfully": "Шаблон события успешно обновлён",
"event_type_deleted_successfully": "Тип события успешно удален",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "Часы",
"your_email": "Ваш адрес электронной почты",
"change_avatar": "Изменить аватар",

View File

@ -468,6 +468,8 @@
"event_type_created_successfully": "{{eventTypeTitle}} 事件类型创建成功",
"event_type_updated_successfully": "{{eventTypeTitle}} 事件类型更新成功",
"event_type_deleted_successfully": "事件类型删除成功",
"web3_metamask_added": "Metamask added successfully",
"web3_metamask_disconnected": "Metamask disconnected successfully",
"hours": "小时数",
"your_email": "您的邮箱",
"change_avatar": "修改头像",

View File

@ -1,5 +1,6 @@
import { BookingStatus, MembershipRole, Prisma } from "@prisma/client";
import _ from "lodash";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
@ -463,6 +464,44 @@ const loggedInViewerRouter = createProtectedRouter()
});
},
})
.mutation("enableOrDisableWeb3", {
input: z.object({}),
async resolve({ ctx }) {
const { user } = ctx;
const where = { userId: user.id, type: "metamask_web3" };
const web3Credential = await ctx.prisma.credential.findFirst({
where,
select: {
id: true,
key: true,
},
});
if (web3Credential) {
return ctx.prisma.credential.update({
where: {
id: web3Credential.id,
},
data: {
key: {
isWeb3Active: !(web3Credential.key as JSONObject).isWeb3Active,
},
},
});
} else {
return ctx.prisma.credential.create({
data: {
type: "metamask_web3",
key: {
isWeb3Active: true,
} as unknown as Prisma.InputJsonObject,
userId: user.id,
},
});
}
},
})
.query("integrations", {
async resolve({ ctx }) {
const { user } = ctx;
@ -498,6 +537,24 @@ const loggedInViewerRouter = createProtectedRouter()
};
},
})
.query("web3Integration", {
async resolve({ ctx }) {
const { user } = ctx;
const where = { userId: user.id, type: "metamask_web3" };
const web3Credential = await ctx.prisma.credential.findFirst({
where,
select: {
key: true,
},
});
return {
isWeb3Active: web3Credential ? (web3Credential.key as JSONObject).isWeb3Active : false,
};
},
})
.query("availability", {
async resolve({ ctx }) {
const { prisma, user } = ctx;

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
[
{
"inputs": [{ "internalType": "address", "name": "owner", "type": "address" }],
"name": "balanceOf",
"outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }],
"stateMutability": "view",
"type": "function"
}
]

7004
web3/dummyResps/bloxyApi.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
import Web3 from "web3";
export const AUTH_MESSAGE =
"I authorize the use of my Ethereum address for the purposes of this application.";
const verifyAccount = async (signature: string, address: string) => {
const web3 = new Web3();
const signingAddress = await web3.eth.accounts.recover(AUTH_MESSAGE, signature);
if (!(address.toLowerCase() === signingAddress.toLowerCase())) throw new Error("Failed to verify address");
};
export default verifyAccount;

4171
yarn.lock

File diff suppressed because it is too large Load Diff