From 59716a6a93117a84297e70a2df8d5a6fdb5d2446 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 8 Jan 2025 11:03:08 -0500 Subject: [PATCH] Add RSVP blocks with whatsapp messaging Squashed commit of the following: commit 5bed2bbd67dc76959aa4b5378fb83132bef7a986 Author: Jared Pereira Date: Tue Jan 7 21:02:29 2025 -0500 store country code and base number seperately in db commit 6b7754375008cd1ad367fd5095443562a094114b Author: Jared Pereira Date: Tue Jan 7 16:27:39 2025 -0500 move migration up commit 0ff4f2c33f444f7c2ee9ec69a3f0223bef285554 Author: Jared Pereira Date: Tue Jan 7 16:11:03 2025 -0500 fix disabled button check commit c5cf6a6ed623f1cf7eb20f1c88f5e696d938452e Author: Jared Pereira Date: Tue Jan 7 15:31:50 2025 -0500 remove unusued imports commit a84414e25d2c888e867c686abf0b25638a025c74 Author: Jared Pereira Date: Tue Jan 7 15:07:47 2025 -0500 add country code picker and get default from ip commit 1bbd2ebbdff3c50bec4effe620ad5a10ebe7a951 Merge: 3e51a3e 7d0a5bd Author: Jared Pereira Date: Mon Jan 6 17:45:41 2025 -0500 Merge branch 'main' into feature/rsvp-blocks-2 commit 3e51a3e12c52e08d747fc0e10b32e35649fd3e2e Author: celine Date: Mon Dec 16 14:16:00 2024 -0500 checkbox width bug fixed commit ee9f56e3ce951a4e634da757910c6597a5bbb6c2 Author: celine Date: Mon Dec 16 14:12:20 2024 -0500 button is disabled if checkbox is not checked commit 7d6e90e1a3e40168d3d91a01b5510db3016c00e6 Author: celine Date: Mon Dec 16 14:07:38 2024 -0500 added more explicit consent form commit 744a36d011b48f2ff293612f7e1f1bdc2433295c Author: Jared Pereira Date: Thu Dec 12 00:37:12 2024 -0500 add whatsapp sending! commit 360b295bb3b11d8c08b472919aa48be0fa05efa8 Author: celine Date: Mon Dec 9 21:56:17 2024 -0500 added a success state to the message composer commit 158eb0d7f56d8ee7f0acda3fb47c5a1dbb7a358e Author: celine Date: Mon Dec 9 21:17:04 2024 -0500 added more styling stuff in the message composer in rsvp commit f5d8e20387c29a208aedd49f011dcd82f38d7380 Author: celine Date: Thu Dec 5 19:00:55 2024 -0500 styled verify code UI, added tweak to smoker to get it to center on the mouse click commit 7ebdfc2b27f3ee72122e9ece06fe7417223b41ee Author: celine Date: Thu Dec 5 17:54:18 2024 -0500 tweaks to rsvp message composer styling commit c1e4f359097ad430beb6040990d7633bf72fda16 Author: celine Date: Thu Dec 5 16:06:03 2024 -0500 styled write an update modal, tweaked popover logic to accept buttons as triggers commit 7675e17b76a044a90a5de86aedc9f89088640d77 Merge: 66170f5 60039c6 Author: celine Date: Wed Dec 4 22:51:30 2024 -0500 Merge branch 'main' of https://github.com/hyperlink-academy/minilink into feature/rsvp-blocks-2 commit 66170f507da1578b2c053a3631aa5b5e534842dd Author: celine Date: Wed Dec 4 17:55:23 2024 -0500 added an icon for rsvp in the block commands and also added some dividers to separate out the event stuff commit c1cc37961ce2b150c86aa4d678a992d1f57362ff Author: celine Date: Wed Dec 4 17:28:55 2024 -0500 styled the rsvp form and also added a tooltip with a consent message in it commit d7a07cd5d46f04c384ede6fc7f08efa58649f578 Author: celine Date: Tue Dec 3 22:37:23 2024 -0500 inital styling for RSVP commit ff80f740a2652f7531596972eca22838de75d1e0 Author: Jared Pereira Date: Mon Dec 2 15:48:45 2024 -0500 add migration for new tables commit 2c9951bc3421896ef0c62b6fb7bcf176ade1c111 Author: Jared Pereira Date: Fri Nov 29 02:00:24 2024 -0500 add a bunch of styling and names commit 527f324dbd56f709c66199097b5fa5cbefecc1bd Author: Jared Pereira Date: Thu Nov 28 18:59:21 2024 -0500 render attendee data properly commit 00100e609cbc1c0d2b9cffeda9ed564bf952fe61 Author: Jared Pereira Date: Wed Nov 27 23:49:48 2024 -0500 create top level identity provider commit 48be121a4402bd59884010e0668e3e63685b853d Author: Jared Pereira Date: Wed Nov 27 21:54:20 2024 -0500 add super basic RSVP flow commit 41e9dd9578072e5ad5a74a69175235248e0d0b42 Author: Jared Pereira Date: Wed Nov 27 20:23:51 2024 -0500 WIP commit 1d6d867b68521ecc1d641b881d8d33fab694ee34 Author: Jared Pereira Date: Sun Nov 24 01:06:12 2024 -0500 add rsvp block type and dummy block --- actions/getRSVPData.ts | 56 +++ actions/get_phone_rsvp_to_event_state.ts | 45 +++ .../phone_auth/confirm_phone_auth_token.ts | 55 +++ .../phone_auth/request_phone_auth_token.ts | 52 +++ actions/phone_rsvp_to_event.ts | 60 +++ actions/sendUpdateToRSVPS.ts | 62 +++ app/[leaflet_id]/page.tsx | 23 +- app/globals.css | 3 + app/layout.tsx | 7 +- components/Blocks/Block.tsx | 2 + components/Blocks/BlockCommands.tsx | 9 +- components/Blocks/EmbedBlock.tsx | 8 +- components/Blocks/ExternalLinkBlock.tsx | 17 +- .../Blocks/RSVPBlock/ContactDetailsForm.tsx | 262 +++++++++++++ components/Blocks/RSVPBlock/index.tsx | 367 ++++++++++++++++++ components/Blocks/useBlockKeyboardHandlers.ts | 2 +- components/Buttons.tsx | 53 +++ components/Checkbox.tsx | 2 +- components/Icons.tsx | 40 ++ components/Providers/IPLocationProvider.tsx | 14 + components/RSVPDataProvider.tsx | 14 + components/ShareOptions/index.tsx | 2 +- components/Toast.tsx | 20 +- drizzle/relations.ts | 38 +- drizzle/schema.ts | 49 ++- package-lock.json | 219 ++++++++++- package.json | 1 + src/constants/countryCodes.ts | 324 ++++++++++++++++ src/hooks/useRSVPData.ts | 12 + src/replicache/attributes.ts | 1 + supabase/database.types.ts | 67 +++- .../20250107212502_add_phone_auth_tables.sql | 124 ++++++ 32 files changed, 1938 insertions(+), 72 deletions(-) create mode 100644 actions/getRSVPData.ts create mode 100644 actions/get_phone_rsvp_to_event_state.ts create mode 100644 actions/phone_auth/confirm_phone_auth_token.ts create mode 100644 actions/phone_auth/request_phone_auth_token.ts create mode 100644 actions/phone_rsvp_to_event.ts create mode 100644 actions/sendUpdateToRSVPS.ts create mode 100644 components/Blocks/RSVPBlock/ContactDetailsForm.tsx create mode 100644 components/Blocks/RSVPBlock/index.tsx create mode 100644 components/Providers/IPLocationProvider.tsx create mode 100644 components/RSVPDataProvider.tsx create mode 100644 src/constants/countryCodes.ts create mode 100644 src/hooks/useRSVPData.ts create mode 100644 supabase/migrations/20250107212502_add_phone_auth_tables.sql diff --git a/actions/getRSVPData.ts b/actions/getRSVPData.ts new file mode 100644 index 00000000..fe1ef937 --- /dev/null +++ b/actions/getRSVPData.ts @@ -0,0 +1,56 @@ +"use server"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { and, eq, inArray } from "drizzle-orm"; +import postgres from "postgres"; +import { + entities, + phone_number_auth_tokens, + phone_rsvps_to_entity, +} from "drizzle/schema"; +import { cookies } from "next/headers"; + +export async function getRSVPData(entity_sets: string[]) { + const token = cookies().get("phone_auth_token"); + if (!token) { + return null; + } + + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + const [authToken] = await db + .select() + .from(phone_number_auth_tokens) + .where(eq(phone_number_auth_tokens.id, token.value)); + + const rsvps = await db + .select() + .from(phone_rsvps_to_entity) + .innerJoin(entities, eq(entities.id, phone_rsvps_to_entity.entity)) + .where(and(inArray(entities.set, entity_sets))); + + client.end(); + return { + authToken, + rsvps: rsvps.map((rsvp) => { + if ( + rsvp.phone_rsvps_to_entity.phone_number === authToken?.phone_number && + rsvp.phone_rsvps_to_entity.country_code === authToken.country_code + ) + return { + phone_number: rsvp.phone_rsvps_to_entity.phone_number, + country_code: rsvp.phone_rsvps_to_entity.country_code, + name: rsvp.phone_rsvps_to_entity.name, + entity: rsvp.entities.id, + status: rsvp.phone_rsvps_to_entity.status, + }; + else + return { + name: rsvp.phone_rsvps_to_entity.name, + entity: rsvp.entities.id, + status: rsvp.phone_rsvps_to_entity.status, + }; + }), + }; +} diff --git a/actions/get_phone_rsvp_to_event_state.ts b/actions/get_phone_rsvp_to_event_state.ts new file mode 100644 index 00000000..8233cccc --- /dev/null +++ b/actions/get_phone_rsvp_to_event_state.ts @@ -0,0 +1,45 @@ +"use server"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { and, eq } from "drizzle-orm"; +import postgres from "postgres"; +import { + phone_number_auth_tokens, + phone_rsvps_to_entity, +} from "drizzle/schema"; +import { cookies } from "next/headers"; +import { Database } from "supabase/database.types"; + +export async function getPhoneRSVPToEventState(entityId: string) { + const token = cookies().get("phone_auth_token"); + + if (!token) { + return null; + } + + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + const [authToken] = await db + .select() + .from(phone_number_auth_tokens) + .where(eq(phone_number_auth_tokens.id, token.value)); + + if (!authToken || !authToken.confirmed) { + client.end(); + return null; + } + + const [rsvp] = await db + .select() + .from(phone_rsvps_to_entity) + .where( + and( + eq(phone_rsvps_to_entity.phone_number, authToken.phone_number), + eq(phone_rsvps_to_entity.entity, entityId), + ), + ); + + client.end(); + return rsvp; +} diff --git a/actions/phone_auth/confirm_phone_auth_token.ts b/actions/phone_auth/confirm_phone_auth_token.ts new file mode 100644 index 00000000..a564d92b --- /dev/null +++ b/actions/phone_auth/confirm_phone_auth_token.ts @@ -0,0 +1,55 @@ +"use server"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { and, eq } from "drizzle-orm"; +import postgres from "postgres"; +import { phone_number_auth_tokens } from "drizzle/schema"; +import { cookies } from "next/headers"; + +export async function confirmPhoneAuthToken(tokenId: string, code: string) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + const [token] = await db + .select() + .from(phone_number_auth_tokens) + .where(eq(phone_number_auth_tokens.id, tokenId)); + + if (!token) { + client.end(); + throw new Error("Invalid token"); + } + + if (token.confirmation_code !== code) { + client.end(); + throw new Error("Invalid confirmation code"); + } + + if (token.confirmed) { + client.end(); + throw new Error("Token already confirmed"); + } + + const [confirmedToken] = await db + .update(phone_number_auth_tokens) + .set({ + confirmed: true, + }) + .where( + and( + eq(phone_number_auth_tokens.id, tokenId), + eq(phone_number_auth_tokens.confirmation_code, code), + ), + ) + .returning(); + + cookies().set("phone_auth_token", confirmedToken.id, { + maxAge: 60 * 60 * 24 * 30, + secure: process.env.NODE_ENV === "production", + httpOnly: true, + sameSite: "strict", + }); + + client.end(); + return confirmedToken; +} diff --git a/actions/phone_auth/request_phone_auth_token.ts b/actions/phone_auth/request_phone_auth_token.ts new file mode 100644 index 00000000..55b97c56 --- /dev/null +++ b/actions/phone_auth/request_phone_auth_token.ts @@ -0,0 +1,52 @@ +"use server"; + +import { randomBytes } from "crypto"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { phone_number_auth_tokens } from "drizzle/schema"; +import twilio from "twilio"; + +async function sendAuthCode(phoneNumber: string, code: string) { + const accountSid = process.env.TWILIO_ACCOUNT_SID; + const authToken = process.env.TWILIO_AUTH_TOKEN; + const client = twilio(accountSid, authToken); + + const message = await client.messages.create({ + contentSid: "HX5ebfae4d2a423808486e773e8a22488d", + contentVariables: JSON.stringify({ 1: code }), + from: "whatsapp:+18449523391", + messagingServiceSid: "MGffbf9a66770350b25caf3b80b9aac481", + to: `whatsapp:${phoneNumber}`, + }); + console.log(message.body); +} + +export async function createPhoneAuthToken({ + phone_number, + country_code, +}: { + phone_number: string; + country_code: string; +}) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + const code = randomBytes(3).toString("hex").toUpperCase(); + + const [token] = await db + .insert(phone_number_auth_tokens) + .values({ + phone_number, + country_code, + confirmation_code: code, + confirmed: false, + }) + .returning({ + id: phone_number_auth_tokens.id, + }); + + await sendAuthCode(`+${country_code}${phone_number}`, code); + + client.end(); + return token.id; +} diff --git a/actions/phone_rsvp_to_event.ts b/actions/phone_rsvp_to_event.ts new file mode 100644 index 00000000..cdcb8d94 --- /dev/null +++ b/actions/phone_rsvp_to_event.ts @@ -0,0 +1,60 @@ +"use server"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { + entities, + phone_number_auth_tokens, + phone_rsvps_to_entity, +} from "drizzle/schema"; +import { redirect } from "next/navigation"; +import postgres from "postgres"; +import { v7 } from "uuid"; +import { eq, sql } from "drizzle-orm"; +import { Database } from "supabase/database.types"; +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +export async function submitRSVP(args: { + entity: string; + status: Database["public"]["Enums"]["rsvp_status"]; + name: string; +}) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + let token = cookies().get("phone_auth_token"); + if (!token) throw new Error("No auth token found"); + + let [auth_token] = await db + .select() + .from(phone_number_auth_tokens) + .where(eq(phone_number_auth_tokens.id, token.value)); + if (!auth_token) throw new Error("Invalid auth token"); + if (!auth_token.confirmed) throw new Error("Auth token not confirmed"); + + await db.transaction(async (tx) => { + await tx + .insert(phone_rsvps_to_entity) + .values([ + { + status: args.status, + entity: args.entity, + phone_number: auth_token.phone_number, + country_code: auth_token.country_code, + name: args.name, + }, + ]) + .onConflictDoUpdate({ + target: [ + phone_rsvps_to_entity.entity, + phone_rsvps_to_entity.phone_number, + ], + set: { + name: args.name, + status: args.status, + }, + }); + }); + + client.end(); + return { success: true }; +} diff --git a/actions/sendUpdateToRSVPS.ts b/actions/sendUpdateToRSVPS.ts new file mode 100644 index 00000000..fb14e278 --- /dev/null +++ b/actions/sendUpdateToRSVPS.ts @@ -0,0 +1,62 @@ +"use server"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { eq } from "drizzle-orm"; +import postgres from "postgres"; +import { + entities, + permission_token_rights, + phone_rsvps_to_entity, +} from "drizzle/schema"; +import { createClient } from "@supabase/supabase-js"; +import { Database } from "supabase/database.types"; +import twilio from "twilio"; +import { Message } from "twilio/lib/twiml/MessagingResponse"; + +const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); +let supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, +); +const db = drizzle(client); + +export async function sendUpdateToRSVPS( + token: { id: string }, + { + entity, + message, + eventName, + }: { entity: string; message: string; eventName: string }, +) { + let token_rights = await db + .select() + .from(permission_token_rights) + .where(eq(permission_token_rights.token, token.id)); + + let RSVPS = db + .select() + .from(phone_rsvps_to_entity) + .innerJoin(entities, eq(phone_rsvps_to_entity.entity, entities.id)) + .where(eq(phone_rsvps_to_entity.entity, entity)); + + if (!token_rights[0]?.write) return; + let rsvps = await RSVPS; + let entity_set = rsvps[0]?.entities.set; + if (!token_rights.find((r) => r.entity_set === entity_set)) { + return; + } + + const accountSid = process.env.TWILIO_ACCOUNT_SID; + const authToken = process.env.TWILIO_AUTH_TOKEN; + const client = twilio(accountSid, authToken); + + for (let rsvp of rsvps) { + const result = await client.messages.create({ + contentSid: "HX2755dab400476ade5effd90e2d964e6c", + contentVariables: JSON.stringify({ 1: eventName, 2: message }), + from: "whatsapp:+18449523391", + messagingServiceSid: "MGffbf9a66770350b25caf3b80b9aac481", + to: `whatsapp:${rsvp.phone_rsvps_to_entity.phone_number}`, + }); + console.log(result); + } +} diff --git a/app/[leaflet_id]/page.tsx b/app/[leaflet_id]/page.tsx index 578790fc..d8a617a0 100644 --- a/app/[leaflet_id]/page.tsx +++ b/app/[leaflet_id]/page.tsx @@ -9,6 +9,8 @@ import { createServerClient } from "@supabase/ssr"; import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; import { Leaflet } from "./Leaflet"; import { scanIndexLocal } from "src/replicache/utils"; +import { getRSVPData } from "actions/getRSVPData"; +import { RSVPDataProvider } from "components/RSVPDataProvider"; export const preferredRegion = ["sfo1"]; export const dynamic = "force-dynamic"; @@ -48,16 +50,21 @@ export default async function LeafletPage(props: Props) { ); - let { data } = await supabase.rpc("get_facts", { - root: rootEntity, - }); + let [{ data }, identity_data] = await Promise.all([ + supabase.rpc("get_facts", { + root: rootEntity, + }), + getRSVPData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), + ]); let initialFacts = (data as unknown as Fact[]) || []; return ( - + + + ); } diff --git a/app/globals.css b/app/globals.css index dd679bda..dd46c0d7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -180,16 +180,19 @@ input[type="number"] { @apply px-1; @apply py-0.5; @apply hover:border-tertiary; + @apply focus:border-tertiary; @apply focus:outline; @apply focus:outline-tertiary; @apply focus:outline-2; @apply focus:outline-offset-1; + @apply focus-within:border-tertiary; @apply focus-within:outline; @apply focus-within:outline-tertiary; @apply focus-within:outline-2; @apply focus-within:outline-offset-1; + @apply disabled:border-border-light; @apply disabled:bg-border-light; @apply disabled:text-tertiary; diff --git a/app/layout.tsx b/app/layout.tsx index 2af97907..f89c1707 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,8 @@ import "./globals.css"; import localFont from "next/font/local"; import { PopUpProvider } from "components/Toast"; import { IdentityProviderServer } from "components/IdentityProviderServer"; +import { headers } from "next/headers"; +import { IPLocationProvider } from "components/Providers/IPLocationProvider"; export const metadata = { title: "Leaflet", @@ -44,6 +46,7 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { + let ipLocation = headers().get("X-Vercel-IP-Country"); return ( @@ -65,7 +68,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 67673686..33fbbaf0 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -27,6 +27,7 @@ import { useEntitySetContext } from "components/EntitySetProvider"; import { Media } from "components/Media"; import { useIsMobile } from "src/hooks/isMobile"; import { DateTimeBlock } from "./DateTimeBlock"; +import { RSVPBlock } from "./RSVPBlock"; import { elementId } from "src/utils/elementId"; export type Block = { @@ -160,6 +161,7 @@ const BlockTypeComponents: { embed: EmbedBlock, mailbox: MailboxBlock, datetime: DateTimeBlock, + rsvp: RSVPBlock, }; export const BlockMultiselectIndicator = (props: BlockProps) => { diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx index 7bcf1571..bf197ce0 100644 --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -12,6 +12,7 @@ import { LinkSmall, BlockEmbedSmall, BlockCalendarSmall, + RSVPSmall, } from "components/Icons"; import { generateKeyBetween } from "fractional-indexing"; import { focusPage } from "components/Pages"; @@ -197,10 +198,16 @@ export const blockCommands: Command[] = [ // EVENT STUFF + { + name: "RSVP", + icon: , + type: "event", + onSelect: (rep, props) => createBlockWithType(rep, props, "rsvp"), + }, { name: "Date and Time", icon: , - type: "block", + type: "event", onSelect: (rep, props) => createBlockWithType(rep, props, "datetime"), }, diff --git a/components/Blocks/EmbedBlock.tsx b/components/Blocks/EmbedBlock.tsx index 8807c08b..0dd9e967 100644 --- a/components/Blocks/EmbedBlock.tsx +++ b/components/Blocks/EmbedBlock.tsx @@ -128,7 +128,7 @@ const BlockLinkInput = (props: BlockProps) => { }, }); }; - let smoke = useSmoker(); + let smoker = useSmoker(); return (
@@ -153,7 +153,7 @@ const BlockLinkInput = (props: BlockProps) => { if (!linkValue) return; if (!isUrl(linkValue)) { let rect = e.currentTarget.getBoundingClientRect(); - smoke({ + smoker({ error: true, text: "invalid url!", position: { x: rect.left, y: rect.top - 8 }, @@ -170,7 +170,7 @@ const BlockLinkInput = (props: BlockProps) => { onMouseDown={(e) => { e.preventDefault(); if (!linkValue || linkValue === "") { - smoke({ + smoker({ error: true, text: "no url!", position: { x: e.clientX, y: e.clientY }, @@ -178,7 +178,7 @@ const BlockLinkInput = (props: BlockProps) => { return; } if (!isUrl(linkValue)) { - smoke({ + smoker({ error: true, text: "invalid url!", position: { x: e.clientX, y: e.clientY }, diff --git a/components/Blocks/ExternalLinkBlock.tsx b/components/Blocks/ExternalLinkBlock.tsx index 09765efc..8a8fcf02 100644 --- a/components/Blocks/ExternalLinkBlock.tsx +++ b/components/Blocks/ExternalLinkBlock.tsx @@ -42,9 +42,9 @@ export const ExternalLinkBlock = ( id={props.preview ? undefined : elementId.block(props.entityID).input} className={` w-full h-[104px] p-2 - text-tertiary hover:text-accent-contrast hover:cursor-pointer - flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg - ${isSelected ? "border-2 border-tertiary" : "border border-border"} + text-tertiary hover:text-accent-contrast hover:cursor-pointer + flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg + ${isSelected ? "border-2 border-tertiary" : "border border-border"} ${props.pageType === "canvas" && "bg-bg-page"}`} onMouseDown={() => { focusBlock( @@ -153,7 +153,7 @@ const BlockLinkInput = (props: BlockProps) => { { type: "start" }, ); }; - let smoke = useSmoker(); + let smoker = useSmoker(); return (
@@ -178,7 +178,8 @@ const BlockLinkInput = (props: BlockProps) => { if (!linkValue) return; if (!isUrl(linkValue)) { let rect = e.currentTarget.getBoundingClientRect(); - smoke({ + smoker({ + alignOnMobile: "left", error: true, text: "invalid url!", position: { x: rect.left, y: rect.top - 8 }, @@ -196,7 +197,8 @@ const BlockLinkInput = (props: BlockProps) => { onMouseDown={(e) => { e.preventDefault(); if (!linkValue || linkValue === "") { - smoke({ + smoker({ + alignOnMobile: "left", error: true, text: "no url!", position: { x: e.clientX, y: e.clientY }, @@ -204,7 +206,8 @@ const BlockLinkInput = (props: BlockProps) => { return; } if (!isUrl(linkValue)) { - smoke({ + smoker({ + alignOnMobile: "left", error: true, text: "invalid url!", position: { x: e.clientX, y: e.clientY }, diff --git a/components/Blocks/RSVPBlock/ContactDetailsForm.tsx b/components/Blocks/RSVPBlock/ContactDetailsForm.tsx new file mode 100644 index 00000000..e4e143e2 --- /dev/null +++ b/components/Blocks/RSVPBlock/ContactDetailsForm.tsx @@ -0,0 +1,262 @@ +"use client"; +import { useSmoker } from "components/Toast"; +import { RSVP_Status, State, useRSVPNameState } from "."; +import { createContext, useContext, useState } from "react"; +import { useRSVPData } from "src/hooks/useRSVPData"; +import { confirmPhoneAuthToken } from "actions/phone_auth/confirm_phone_auth_token"; +import { submitRSVP } from "actions/phone_rsvp_to_event"; + +import { countryCodes } from "src/constants/countryCodes"; +import { Checkbox } from "components/Checkbox"; +import { ButtonPrimary } from "components/Buttons"; +import { Separator } from "components/Layout"; +import { createPhoneAuthToken } from "actions/phone_auth/request_phone_auth_token"; +import { Input } from "components/Input"; +import { IPLocationContext } from "components/Providers/IPLocationProvider"; + +export function ContactDetailsForm({ + status, + entityID, +}: { + status: RSVP_Status; + entityID: string; + setState: (s: State) => void; +}) { + let focusWithinStyles = + "focus-within:border-tertiary focus-within:outline focus-within:outline-2 focus-within:outline-tertiary focus-within:outline-offset-1"; + let [checked, setChecked] = useState(false); + + let { data, mutate } = useRSVPData(); + let [state, setState] = useState< + { state: "details" } | { state: "confirm"; token: string } + >({ state: "details" }); + let { name, setName } = useRSVPNameState(); + let ipLocation = useContext(IPLocationContext) || "US"; + const [formState, setFormState] = useState({ + country_code: + countryCodes.find((c) => c[1].toUpperCase() === ipLocation)?.[2] || "1", + phone_number: "", + confirmationCode: "", + }); + let [enterNewNumber, setEnterNewNumber] = useState(false); + + let submit = async ( + token: Awaited>, + ) => { + try { + await submitRSVP({ + status, + name: name, + entity: entityID, + }); + } catch (e) { + //handle failed confirm + return false; + } + + mutate({ + authToken: token, + rsvps: [ + ...(data?.rsvps || []).filter((r) => r.entity !== entityID), + { + name: name, + status, + entity: entityID, + phone_number: token.phone_number, + country_code: token.country_code, + }, + ], + }); + return true; + }; + return state.state === "details" ? ( +
+
+ +
+ +
+ Non-US numbers will receive messages through{" "} + WhatsApp +
+
+
+ +
+
+ + { + if (data?.authToken) { + submit(data.authToken); + } else { + let tokenId = await createPhoneAuthToken(formState); + setState({ state: "confirm", token: tokenId }); + } + }} + > + RSVP as {status === "GOING" ? "Going" : "Maybe"} + +
+
+ ) : ( + + setFormState((state) => ({ ...state, confirmationCode: value })) + } + /> + ); +} + +const ConfirmationForm = (props: { + value: string; + token: string; + submit: ( + token: Awaited>, + ) => Promise; + onChange: (v: string) => void; +}) => { + let smoker = useSmoker(); + return ( +
+ +
+ + { + try { + let token = await confirmPhoneAuthToken(props.token, props.value); + props.submit(token); + } catch (error) { + smoker({ + alignOnMobile: "left", + error: true, + text: "invalid code!", + position: { x: e.clientX, y: e.clientY }, + }); + return; + } + }} + > + Confirm + +
+ ); +}; + +const ConsentPopover = (props: { + checked: boolean; + setChecked: (checked: boolean) => void; +}) => { + return ( + { + props.setChecked(!props.checked); + }} + > +
+ Clicking RSVP means that you are consenting to receive WhatsApp messages + from the host of this event, via Leaflet! +
+
+ ); +}; diff --git a/components/Blocks/RSVPBlock/index.tsx b/components/Blocks/RSVPBlock/index.tsx new file mode 100644 index 00000000..7fe11cd2 --- /dev/null +++ b/components/Blocks/RSVPBlock/index.tsx @@ -0,0 +1,367 @@ +"use client"; +import { Database } from "supabase/database.types"; +import { BlockProps } from "components/Blocks/Block"; +import { createContext, useContext, useState } from "react"; +import { submitRSVP } from "actions/phone_rsvp_to_event"; +import { useRSVPData } from "src/hooks/useRSVPData"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { + ButtonPrimary, + ButtonSecondary, + ButtonTertiary, +} from "components/Buttons"; +import { UpdateSmall } from "components/Icons"; +import { Popover } from "components/Popover"; +import { create } from "zustand"; +import { combine, createJSONStorage, persist } from "zustand/middleware"; +import { useUIState } from "src/useUIState"; +import { Separator } from "components/Layout"; +import { theme } from "tailwind.config"; +import { useToaster } from "components/Toast"; +import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; +import { useReplicache } from "src/replicache"; +import { ContactDetailsForm } from "./ContactDetailsForm"; + +export type RSVP_Status = Database["public"]["Enums"]["rsvp_status"]; +let Statuses = ["GOING", "NOT_GOING", "MAYBE"]; +export type State = + | { + state: "default"; + } + | { state: "contact_details"; status: RSVP_Status }; + +export function RSVPBlock(props: BlockProps) { + let isSelected = useUIState((s) => + s.selectedBlocks.find((b) => b.value === props.entityID), + ); + return ( +
+ +
+ ); +} + +function RSVPForm(props: { entityID: string }) { + let [state, setState] = useState({ state: "default" }); + let { permissions } = useEntitySetContext(); + let { data, mutate } = useRSVPData(); + let setStatus = (status: RSVP_Status) => { + setState({ status, state: "contact_details" }); + }; + + let rsvpStatus = data?.rsvps?.find( + (rsvp) => + data.authToken && + rsvp.entity === props.entityID && + data.authToken.country_code === rsvp.country_code && + data.authToken.phone_number === rsvp.phone_number, + )?.status; + + // IF YOU HAVE ALREADY RSVP'D + if (rsvpStatus) + return permissions.write ? ( + //AND YOU'RE A HOST + <> +
+ +
+ + +
+
+ + + ) : ( + // AND YOU'RE A GUEST +
+ +
+ +
+ ); + + // IF YOU HAVEN'T RSVP'D + if (state.state === "default") + return permissions.write ? ( + //YOU'RE A HOST + <> +
+
+ setStatus("GOING")}> + Going! + + setStatus("MAYBE")}> + Maybe + + setStatus("NOT_GOING")}> + Can't Go + +
+
+ + +
+
+ + + ) : ( + //YOU'RE A GUEST +
+
+ setStatus("GOING")}> + Going! + + setStatus("MAYBE")}> + Maybe + + setStatus("NOT_GOING")}> + Can't Go + +
+
+ + +
+ ); + + // IF YOU ARE CURRENTLY CONFIRMING YOUR CONTACT DETAILS + if (state.state === "contact_details") + return ( + + ); +} + +function YourRSVPStatus(props: { entityID: string; compact?: boolean }) { + let { data, mutate } = useRSVPData(); + let { name } = useRSVPNameState(); + + let rsvpStatus = data?.rsvps?.find( + (rsvp) => + data.authToken && + rsvp.entity === props.entityID && + data.authToken.phone_number === rsvp.phone_number, + )?.status; + + let updateStatus = async (status: RSVP_Status) => { + if (!data?.authToken) return; + await submitRSVP({ + status, + name: name, + entity: props.entityID, + }); + + mutate({ + authToken: data.authToken, + rsvps: [ + ...(data?.rsvps || []).filter((r) => r.entity !== props.entityID), + { + name: name, + status, + entity: props.entityID, + phone_number: data.authToken.phone_number, + country_code: data.authToken.country_code, + }, + ], + }); + }; + return ( +
+ {rsvpStatus !== undefined && + { + GOING: `You're Going!`, + MAYBE: "You're a Maybe", + NOT_GOING: "Can't Make It", + }[rsvpStatus]} + + {rsvpStatus !== "GOING" && ( + updateStatus("GOING")} + > + Going + + )} + {rsvpStatus !== "MAYBE" && ( + updateStatus("MAYBE")} + > + Maybe + + )} + {rsvpStatus !== "NOT_GOING" && ( + updateStatus("NOT_GOING")} + > + Can't Go + + )} +
+ ); +} + +function Attendees(props: { entityID: string; className?: string }) { + let { data, mutate } = useRSVPData(); + let attendees = + data?.rsvps.filter((rsvp) => rsvp.entity === props.entityID) || []; + let going = attendees.filter((rsvp) => rsvp.status === "GOING"); + let maybe = attendees.filter((rsvp) => rsvp.status === "MAYBE"); + let notGoing = attendees.filter((rsvp) => rsvp.status === "NOT_GOING"); + + return ( + + No RSVPs yet + + ) : ( + + {going.length > 0 && `${going.length} Going`} + {maybe.length > 0 && + `${going.length > 0 ? ", " : ""}${maybe.length} Maybe`} + + ) + } + > + {going.length === 0 && maybe.length === 0 && notGoing.length === 0 && ( +
No RSVPs yet
+ )} + {going.length > 0 && ( +
+
Going ({going.length})
+ {going.map((rsvp) => ( +
{rsvp.name}
+ ))} +
+ )} + {maybe.length > 0 && ( +
+
Maybe ({maybe.length})
+ {maybe.map((rsvp) => ( +
{rsvp.name}
+ ))} +
+ )} + {notGoing.length > 0 && ( +
+
+ Can't Go ({notGoing.length}) +
+ {notGoing.map((rsvp) => ( +
{rsvp.name}
+ ))} +
+ )} +
+ ); +} + +function SendUpdateButton(props: { entityID: string }) { + let { permissions } = useEntitySetContext(); + let { permission_token } = useReplicache(); + let [input, setInput] = useState(""); + let toaster = useToaster(); + let [open, setOpen] = useState(false); + + if (!!!permissions.write) return; + return ( + setOpen(open)} + trigger={ + + Send an Update + + } + > +
+