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 + + } + > +
+