a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add plus ones to rsvps

Squashed commit of the following:

commit 8c096a786c3e19eaa256b759fd38bdee72bba647
Author: Jared Pereira <jared@awarm.space>
Date: Fri Jan 17 15:46:09 2025 -0500

don't set mouse down if button

commit e4a725fd0dbbd8c137157a8cc99af9a830118041
Author: Jared Pereira <jared@awarm.space>
Date: Fri Jan 17 15:45:22 2025 -0500

add plus ones to rsvp count

commit 03c0316dd2d80b86346c7b93d01e0b9eaca428d4
Author: celine <celine@hyperlink.academy>
Date: Fri Jan 17 15:38:10 2025 -0500

styling the plus ones

commit c87652a8fd47ca9b2995f6ee0830ab6ca6ad8be5
Author: Jared Pereira <jared@awarm.space>
Date: Thu Jan 16 14:08:16 2025 -0500

add plus_ones and edit state to rsvps

+508 -446
+2
actions/getRSVPData.ts
··· 52 52 name: rsvp.phone_rsvps_to_entity.name, 53 53 entity: rsvp.entities.id, 54 54 status: rsvp.phone_rsvps_to_entity.status, 55 + plus_ones: rsvp.phone_rsvps_to_entity.plus_ones, 55 56 }; 56 57 else 57 58 return { 58 59 name: rsvp.phone_rsvps_to_entity.name, 59 60 entity: rsvp.entities.id, 60 61 status: rsvp.phone_rsvps_to_entity.status, 62 + plus_ones: rsvp.phone_rsvps_to_entity.plus_ones, 61 63 }; 62 64 }), 63 65 };
+2
actions/phone_auth/request_phone_auth_token.ts
··· 7 7 import twilio from "twilio"; 8 8 9 9 async function sendAuthCode(phoneNumber: string, code: string) { 10 + console.log("HERE IS THE CODE: " + code); 11 + return; 10 12 const accountSid = process.env.TWILIO_ACCOUNT_SID; 11 13 const authToken = process.env.TWILIO_AUTH_TOKEN; 12 14 const client = twilio(accountSid, authToken);
+3
actions/phone_rsvp_to_event.ts
··· 18 18 entity: string; 19 19 status: Database["public"]["Enums"]["rsvp_status"]; 20 20 name: string; 21 + plus_ones: number; 21 22 }) { 22 23 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 23 24 const db = drizzle(client); ··· 41 42 phone_number: auth_token.phone_number, 42 43 country_code: auth_token.country_code, 43 44 name: args.name, 45 + plus_ones: args.plus_ones, 44 46 }, 45 47 ]) 46 48 .onConflictDoUpdate({ ··· 51 53 set: { 52 54 name: args.name, 53 55 status: args.status, 56 + plus_ones: args.plus_ones, 54 57 }, 55 58 }); 56 59 });
+68
components/Blocks/RSVPBlock/Atendees.tsx
··· 1 + "use client"; 2 + import { useRSVPData } from "src/hooks/useRSVPData"; 3 + import { ButtonTertiary } from "components/Buttons"; 4 + import { Popover } from "components/Popover"; 5 + 6 + export function Attendees(props: { entityID: string; className?: string }) { 7 + let { data } = useRSVPData(); 8 + let attendees = 9 + data?.rsvps?.filter((rsvp) => rsvp.entity === props.entityID) || []; 10 + let going = attendees.filter((rsvp) => rsvp.status === "GOING"); 11 + let maybe = attendees.filter((rsvp) => rsvp.status === "MAYBE"); 12 + let notGoing = attendees.filter((rsvp) => rsvp.status === "NOT_GOING"); 13 + 14 + return ( 15 + <Popover 16 + align="start" 17 + className="text-sm text-secondary flex flex-col gap-2 max-w-sm" 18 + asChild 19 + trigger={ 20 + going.length === 0 && maybe.length === 0 ? ( 21 + <button 22 + className={`text-sm font-normal w-max text-tertiary italic hover:underline ${props.className}`} 23 + > 24 + No RSVPs yet 25 + </button> 26 + ) : ( 27 + <ButtonTertiary className={`text-sm font-normal ${props.className}`}> 28 + {going.length > 0 && 29 + `${going.reduce((acc, g) => acc + 1 + g.plus_ones, 0)} Going`} 30 + {maybe.length > 0 && 31 + `${going.length > 0 ? ", " : ""}${maybe.reduce((acc, m) => acc + 1 + m.plus_ones, 0)} Maybe`} 32 + </ButtonTertiary> 33 + ) 34 + } 35 + > 36 + {going.length === 0 && maybe.length === 0 && notGoing.length === 0 && ( 37 + <div className="text-tertiary italic">No RSVPs yet</div> 38 + )} 39 + <AttendeeStatusList rsvps={going} title="Going" /> 40 + <AttendeeStatusList rsvps={maybe} title="Maybe" /> 41 + <AttendeeStatusList rsvps={notGoing} title="Can't Go" /> 42 + </Popover> 43 + ); 44 + } 45 + 46 + function AttendeeStatusList(props: { 47 + rsvps: Array<{ 48 + name: string; 49 + phone_number?: string; 50 + plus_ones: number; 51 + status: string; 52 + }>; 53 + title: string; 54 + }) { 55 + if (props.rsvps.length === 0) return null; 56 + return ( 57 + <div className="flex flex-col gap-0.5"> 58 + <div className="font-bold text-tertiary"> 59 + {props.title} ({props.rsvps.length}) 60 + </div> 61 + {props.rsvps.map((rsvp) => ( 62 + <div key={rsvp.phone_number}> 63 + {rsvp.name} {rsvp.plus_ones > 0 ? `+${rsvp.plus_ones}` : ""} 64 + </div> 65 + ))} 66 + </div> 67 + ); 68 + }
+170 -131
components/Blocks/RSVPBlock/ContactDetailsForm.tsx
··· 1 1 "use client"; 2 2 import { useSmoker, useToaster } from "components/Toast"; 3 - import { RSVP_Status, State, useRSVPNameState } from "."; 3 + import { RSVP_Status, RSVPButtons, State, useRSVPNameState } from "."; 4 4 import { createContext, useContext, useState } from "react"; 5 5 import { useRSVPData } from "src/hooks/useRSVPData"; 6 6 import { confirmPhoneAuthToken } from "actions/phone_auth/confirm_phone_auth_token"; ··· 9 9 import { countryCodes } from "src/constants/countryCodes"; 10 10 import { Checkbox } from "components/Checkbox"; 11 11 import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 12 - import { Separator } from "components/Layout"; 12 + import { InputWithLabel, Separator } from "components/Layout"; 13 13 import { createPhoneAuthToken } from "actions/phone_auth/request_phone_auth_token"; 14 14 import { Input } from "components/Input"; 15 15 import { IPLocationContext } from "components/Providers/IPLocationProvider"; ··· 21 21 status: RSVP_Status; 22 22 entityID: string; 23 23 setState: (s: State) => void; 24 + setStatus: (s: RSVP_Status) => void; 24 25 }) { 25 - let { status, entityID, setState } = props; 26 + let { status, entityID, setState, setStatus } = props; 26 27 let focusWithinStyles = 27 28 "focus-within:border-tertiary focus-within:outline focus-within:outline-2 focus-within:outline-tertiary focus-within:outline-offset-1"; 28 29 let toaster = useToaster(); ··· 31 32 { state: "details" } | { state: "confirm"; token: string } 32 33 >({ state: "details" }); 33 34 let { name, setName } = useRSVPNameState(); 35 + let [plus_ones, setPlusOnes] = useState( 36 + data?.rsvps?.find( 37 + (rsvp) => 38 + data.authToken && 39 + rsvp.entity === props.entityID && 40 + data.authToken.country_code === rsvp.country_code && 41 + data.authToken.phone_number === rsvp.phone_number, 42 + )?.plus_ones || 0, 43 + ); 34 44 let ipLocation = useContext(IPLocationContext) || "US"; 35 45 const [formState, setFormState] = useState({ 36 46 country_code: ··· 47 57 status, 48 58 name: name, 49 59 entity: entityID, 60 + plus_ones, 50 61 }); 51 62 } catch (e) { 52 63 //handle failed confirm ··· 60 71 { 61 72 name: name, 62 73 status, 74 + plus_ones, 63 75 entity: entityID, 64 76 phone_number: token.phone_number, 65 77 country_code: token.country_code, 66 78 }, 67 79 ], 68 80 }); 81 + props.setState({ state: "default" }); 69 82 return true; 70 83 }; 71 84 return contactFormState.state === "details" ? ( 72 - <form 73 - className="rsvpForm flex flex-col gap-2" 74 - onSubmit={async (e) => { 75 - e.preventDefault(); 76 - if (data?.authToken) { 77 - submit(data.authToken); 78 - toaster({ 79 - content: ( 80 - <div className="font-bold"> 81 - {status === "GOING" 82 - ? "Yay! You're Going!" 83 - : status === "MAYBE" 84 - ? "You're a Maybe" 85 - : "Sorry you can't make it D:"} 86 - </div> 87 - ), 88 - type: "success", 89 - }); 90 - } else { 91 - let tokenId = await createPhoneAuthToken(formState); 92 - setContactFormState({ state: "confirm", token: tokenId }); 93 - } 94 - }} 95 - > 96 - <div className="rsvpInputs flex sm:flex-row flex-col gap-2 w-fit place-self-center "> 97 - <label 98 - htmlFor="rsvp-name-input" 99 - className={` 100 - rsvpNameInput input-with-border basis-1/3 h-fit 101 - flex flex-col ${focusWithinStyles}`} 102 - > 103 - <div className="text-xs font-bold italic text-tertiary">name</div> 104 - <Input 105 - autoFocus 106 - id="rsvp-name-input" 107 - placeholder="..." 108 - className=" bg-transparent disabled:text-tertiary w-full appearance-none focus:outline-0" 109 - value={name} 110 - onChange={(e) => setName(e.target.value)} 111 - /> 112 - </label> 113 - <div 114 - className={`rsvpPhoneInputWrapper relative flex flex-col gap-0.5 w-full basis-2/3`} 115 - > 85 + <> 86 + <form 87 + className="rsvpForm flex flex-col gap-2" 88 + onSubmit={async (e) => { 89 + e.preventDefault(); 90 + if (data?.authToken) { 91 + submit(data.authToken); 92 + toaster({ 93 + content: ( 94 + <div className="font-bold"> 95 + {status === "GOING" 96 + ? "Yay! You're Going!" 97 + : status === "MAYBE" 98 + ? "You're a Maybe" 99 + : "Sorry you can't make it D:"} 100 + </div> 101 + ), 102 + type: "success", 103 + }); 104 + } else { 105 + let tokenId = await createPhoneAuthToken(formState); 106 + setContactFormState({ state: "confirm", token: tokenId }); 107 + } 108 + }} 109 + > 110 + <RSVPButtons setStatus={props.setStatus} status={props.status} /> 111 + 112 + <div className="rsvpInputs flex sm:flex-row flex-col gap-2 w-fit place-self-center "> 116 113 <label 117 - htmlFor="rsvp-phone-input" 114 + htmlFor="rsvp-name-input" 118 115 className={` 116 + rsvpNameInput input-with-border h-fit 117 + flex flex-col ${focusWithinStyles}`} 118 + > 119 + <div className="text-xs font-bold italic text-tertiary">name</div> 120 + <Input 121 + autoFocus 122 + id="rsvp-name-input" 123 + placeholder="..." 124 + className=" bg-transparent disabled:text-tertiary w-full appearance-none focus:outline-0" 125 + value={name} 126 + onChange={(e) => setName(e.target.value)} 127 + /> 128 + </label> 129 + <div 130 + className={`rsvpPhoneInputWrapper relative flex flex-col gap-0.5 w-full basis-2/3`} 131 + > 132 + <label 133 + htmlFor="rsvp-phone-input" 134 + className={` 119 135 rsvpPhoneInput input-with-border 120 136 flex flex-col ${focusWithinStyles} 121 137 ${!!data?.authToken?.phone_number && "bg-border-light border-border-light text-tertiary"}`} 122 - > 123 - <div className=" text-xs font-bold italic text-tertiary"> 124 - WhatsApp Number 125 - </div> 126 - <div className="flex gap-2 "> 127 - <div className="flex items-center gap-1"> 128 - <span 129 - style={{ 130 - color: 131 - formState.country_code === "" 132 - ? theme.colors.tertiary 133 - : theme.colors.primary, 134 - }} 135 - > 136 - + 137 - </span> 138 + > 139 + <div className=" text-xs font-bold italic text-tertiary"> 140 + WhatsApp Number 141 + </div> 142 + <div className="flex gap-2 "> 143 + <div className="flex items-center gap-1"> 144 + <span 145 + style={{ 146 + color: 147 + formState.country_code === "" || 148 + !!data?.authToken?.phone_number 149 + ? theme.colors.tertiary 150 + : theme.colors.primary, 151 + }} 152 + > 153 + + 154 + </span> 155 + <Input 156 + onKeyDown={(e) => { 157 + if (e.key === "Backspace" && !e.currentTarget.value) 158 + e.preventDefault(); 159 + }} 160 + disabled={!!data?.authToken?.phone_number} 161 + className="w-10 bg-transparent appearance-none focus:outline-0" 162 + placeholder="1" 163 + maxLength={4} 164 + inputMode="numeric" 165 + pattern="[0-9]*" 166 + value={formState.country_code} 167 + onChange={(e) => 168 + setFormState((s) => ({ 169 + ...s, 170 + country_code: e.target.value.replace(/[^0-9]/g, ""), 171 + })) 172 + } 173 + /> 174 + </div> 175 + <Separator /> 176 + 138 177 <Input 178 + id="rsvp-phone-input" 179 + inputMode="numeric" 180 + placeholder="0000000000" 181 + pattern="[0-9]*" 182 + className=" bg-transparent disabled:text-tertiary w-full appearance-none focus:outline-0" 183 + disabled={!!data?.authToken?.phone_number} 139 184 onKeyDown={(e) => { 140 185 if (e.key === "Backspace" && !e.currentTarget.value) 141 186 e.preventDefault(); 142 187 }} 143 - disabled={!!data?.authToken?.phone_number} 144 - className="w-10 bg-transparent appearance-none focus:outline-0" 145 - placeholder="1" 146 - maxLength={4} 147 - inputMode="numeric" 148 - pattern="[0-9]*" 149 - value={formState.country_code} 188 + value={ 189 + data?.authToken?.phone_number || formState.phone_number 190 + } 150 191 onChange={(e) => 151 - setFormState((s) => ({ 152 - ...s, 153 - country_code: e.target.value.replace(/[^0-9]/g, ""), 192 + setFormState((state) => ({ 193 + ...state, 194 + phone_number: e.target.value.replace(/[^0-9]/g, ""), 154 195 })) 155 196 } 156 197 /> 157 198 </div> 158 - <Separator /> 159 - 160 - <Input 161 - id="rsvp-phone-input" 162 - inputMode="numeric" 163 - placeholder="0000000000" 164 - pattern="[0-9]*" 165 - className=" bg-transparent disabled:text-tertiary w-full appearance-none focus:outline-0" 166 - disabled={!!data?.authToken?.phone_number} 167 - onKeyDown={(e) => { 168 - if (e.key === "Backspace" && !e.currentTarget.value) 169 - e.preventDefault(); 170 - }} 171 - value={data?.authToken?.phone_number || formState.phone_number} 172 - onChange={(e) => 173 - setFormState((state) => ({ 174 - ...state, 175 - phone_number: e.target.value.replace(/[^0-9]/g, ""), 176 - })) 177 - } 178 - /> 199 + </label> 200 + <div className="text-xs italic text-tertiary leading-tight"> 201 + Currently, all communication will be routed through{" "} 202 + <strong>WhatsApp</strong>. SMS coming soon! 179 203 </div> 180 - </label> 181 - <div className="text-xs italic text-tertiary leading-tight"> 182 - Currently, all communication will be routed through{" "} 183 - <strong>WhatsApp</strong>. SMS coming soon! 204 + </div> 205 + <div className="flex flex-row gap-2 w-full sm:w-32 h-fit"> 206 + <InputWithLabel 207 + className="!appearance-none" 208 + placeholder="0" 209 + label="Plus ones?" 210 + type="number" 211 + min={0} 212 + max={4} 213 + value={plus_ones} 214 + onChange={(e) => setPlusOnes(parseInt(e.currentTarget.value))} 215 + onKeyDown={(e) => { 216 + if (e.key === "Backspace" && !e.currentTarget.value) 217 + e.preventDefault(); 218 + }} 219 + /> 184 220 </div> 185 221 </div> 186 - </div> 187 222 188 - <hr className="border-border" /> 189 - <div className="flex flex-row gap-2 w-full items-center justify-end"> 190 - <ConsentPopover /> 191 - <ButtonTertiary 192 - onMouseDown={() => { 193 - setState({ state: "default" }); 194 - }} 195 - > 196 - Back 197 - </ButtonTertiary> 198 - <ButtonPrimary 199 - disabled={ 200 - (!data?.authToken?.phone_number && 201 - (!formState.phone_number || !formState.country_code)) || 202 - !name 203 - } 204 - className="place-self-end" 205 - type="submit" 206 - > 207 - RSVP as{" "} 208 - {status === "GOING" 209 - ? "Going" 210 - : status === "MAYBE" 211 - ? "Maybe" 212 - : "Can't Go"} 213 - </ButtonPrimary> 214 - </div> 215 - </form> 223 + <hr className="border-border" /> 224 + <div className="flex flex-row gap-2 w-full items-center justify-end"> 225 + <ConsentPopover /> 226 + <ButtonTertiary 227 + onMouseDown={() => { 228 + setState({ state: "default" }); 229 + }} 230 + > 231 + Back 232 + </ButtonTertiary> 233 + <ButtonPrimary 234 + disabled={ 235 + (!data?.authToken?.phone_number && 236 + (!formState.phone_number || !formState.country_code)) || 237 + !name 238 + } 239 + className="place-self-end" 240 + type="submit" 241 + > 242 + RSVP as{" "} 243 + {status === "GOING" 244 + ? "Going" 245 + : status === "MAYBE" 246 + ? "Maybe" 247 + : "Can't Go"} 248 + </ButtonPrimary> 249 + </div> 250 + </form> 251 + </> 216 252 ) : ( 217 253 <ConfirmationForm 254 + phoneNumber={formState.phone_number} 218 255 token={contactFormState.token} 219 256 value={formState.confirmationCode} 220 257 submit={submit} ··· 227 264 } 228 265 229 266 const ConfirmationForm = (props: { 267 + phoneNumber: string; 230 268 value: string; 231 269 token: string; 232 270 status: RSVP_Status; ··· 239 277 let toaster = useToaster(); 240 278 return ( 241 279 <form 242 - className="flex flex-col gap-3" 280 + className="flex flex-col gap-3 w-full" 243 281 onSubmit={async (e) => { 244 282 e.preventDefault(); 245 283 let rect = document ··· 278 316 <div className="absolute top-0.5 left-[6px] text-xs font-bold italic text-tertiary"> 279 317 confirmation code 280 318 </div> 319 + 281 320 <Input 282 321 autoFocus 283 322 placeholder="000000" ··· 285 324 value={props.value} 286 325 onChange={(e) => props.onChange(e.target.value)} 287 326 /> 288 - <div className="text-xs italic text-tertiary leading-tight"> 289 - we texted a confirmation code to your phone number! 327 + <div className="text-sm italic text-tertiary leading-tight"> 328 + Code was sent to <strong>{props.phoneNumber}</strong>! 290 329 </div> 291 330 </label> 292 331
+165
components/Blocks/RSVPBlock/SendUpdate.tsx
··· 1 + "use client"; 2 + import { useState } from "react"; 3 + import { useRSVPData } from "src/hooks/useRSVPData"; 4 + import { useEntitySetContext } from "components/EntitySetProvider"; 5 + import { ButtonPrimary } from "components/Buttons"; 6 + import { UpdateSmall } from "components/Icons"; 7 + import { Popover } from "components/Popover"; 8 + import { theme } from "tailwind.config"; 9 + import { useToaster } from "components/Toast"; 10 + import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; 11 + import { useReplicache } from "src/replicache"; 12 + import { Checkbox } from "components/Checkbox"; 13 + import { usePublishLink } from "components/ShareOptions"; 14 + 15 + export function SendUpdateButton(props: { entityID: string }) { 16 + let publishLink = usePublishLink(); 17 + let { permissions } = useEntitySetContext(); 18 + let { permission_token } = useReplicache(); 19 + let [input, setInput] = useState(""); 20 + let toaster = useToaster(); 21 + let [open, setOpen] = useState(false); 22 + let [checkedRecipients, setCheckedRecipients] = useState({ 23 + GOING: true, 24 + MAYBE: true, 25 + NOT_GOING: false, 26 + }); 27 + 28 + let { data, mutate } = useRSVPData(); 29 + let attendees = 30 + data?.rsvps?.filter((rsvp) => rsvp.entity === props.entityID) || []; 31 + let going = attendees.filter((rsvp) => rsvp.status === "GOING"); 32 + let maybe = attendees.filter((rsvp) => rsvp.status === "MAYBE"); 33 + let notGoing = attendees.filter((rsvp) => rsvp.status === "NOT_GOING"); 34 + 35 + let allRecipients = 36 + ((checkedRecipients.GOING && going.length) || 0) + 37 + ((checkedRecipients.MAYBE && maybe.length) || 0) + 38 + ((checkedRecipients.NOT_GOING && notGoing.length) || 0); 39 + 40 + if (!!!permissions.write) return; 41 + return ( 42 + <Popover 43 + asChild 44 + open={open} 45 + onOpenChange={(open) => setOpen(open)} 46 + trigger={ 47 + <ButtonPrimary fullWidth className="mb-2"> 48 + <UpdateSmall /> Send a Text Blast 49 + </ButtonPrimary> 50 + } 51 + > 52 + <div className="rsvpMessageComposer flex flex-col gap-2 w-[1000px] max-w-full sm:max-w-md"> 53 + <div className="flex flex-col font-bold text-secondary"> 54 + <h3>Send a Text Blast to</h3> 55 + <RecipientPicker 56 + checked={checkedRecipients} 57 + setChecked={setCheckedRecipients} 58 + /> 59 + 60 + <textarea 61 + id="rsvp-message-input" 62 + value={input} 63 + onChange={(e) => { 64 + setInput(e.target.value); 65 + }} 66 + className="input-with-border w-full h-[150px] mt-3 pt-0.5 font-normal text-primary" 67 + /> 68 + </div> 69 + <div className="flex justify-between items-start"> 70 + <div 71 + className={`rsvpMessageCharCounter text-sm text-tertiary`} 72 + style={ 73 + input.length > 300 74 + ? { 75 + color: theme.colors["accent-1"], 76 + fontWeight: "bold", 77 + } 78 + : { 79 + color: theme.colors["tertiary"], 80 + } 81 + } 82 + > 83 + {input.length}/300 {input.length > 300 && " (too long!)"} 84 + </div> 85 + <ButtonPrimary 86 + disabled={input.length > 300} 87 + className="place-self-end " 88 + onClick={async () => { 89 + if (!permission_token || !publishLink) return; 90 + await sendUpdateToRSVPS(permission_token, { 91 + entity: props.entityID, 92 + message: input, 93 + eventName: document.title, 94 + sendto: checkedRecipients, 95 + publicLeafletID: publishLink, 96 + }); 97 + toaster({ 98 + content: <div className="font-bold">Update sent!</div>, 99 + type: "success", 100 + }); 101 + setOpen(false); 102 + }} 103 + > 104 + Text {allRecipients} {allRecipients === 1 ? "Person" : "People"}! 105 + </ButtonPrimary> 106 + </div> 107 + </div> 108 + </Popover> 109 + ); 110 + } 111 + 112 + const RecipientPicker = (props: { 113 + checked: { GOING: boolean; MAYBE: boolean; NOT_GOING: boolean }; 114 + setChecked: (checked: { 115 + GOING: boolean; 116 + MAYBE: boolean; 117 + NOT_GOING: boolean; 118 + }) => void; 119 + }) => { 120 + return ( 121 + <div className="flex flex-col gap-0.5"> 122 + {/* <small className="font-normal"> 123 + Send a text to everyone who RSVP&apos;d: 124 + </small> */} 125 + <div className="flex gap-4 text-secondary"> 126 + <Checkbox 127 + className="!w-fit" 128 + checked={props.checked.GOING} 129 + onChange={() => { 130 + props.setChecked({ 131 + ...props.checked, // Spread the existing values 132 + GOING: !props.checked.GOING, 133 + }); 134 + }} 135 + > 136 + Going 137 + </Checkbox> 138 + <Checkbox 139 + className="!w-fit" 140 + checked={props.checked.MAYBE} 141 + onChange={() => { 142 + props.setChecked({ 143 + ...props.checked, // Spread the existing values 144 + MAYBE: !props.checked.MAYBE, 145 + }); 146 + }} 147 + > 148 + Maybe 149 + </Checkbox> 150 + <Checkbox 151 + className="!w-fit" 152 + checked={props.checked.NOT_GOING} 153 + onChange={() => { 154 + props.setChecked({ 155 + ...props.checked, // Spread the existing values 156 + NOT_GOING: !props.checked.NOT_GOING, 157 + }); 158 + }} 159 + > 160 + Can&apos;t Go 161 + </Checkbox> 162 + </div> 163 + </div> 164 + ); 165 + };
+89 -304
components/Blocks/RSVPBlock/index.tsx
··· 1 1 "use client"; 2 2 import { Database } from "supabase/database.types"; 3 3 import { BlockProps } from "components/Blocks/Block"; 4 - import { createContext, useContext, useState } from "react"; 4 + import { useState } from "react"; 5 5 import { submitRSVP } from "actions/phone_rsvp_to_event"; 6 6 import { useRSVPData } from "src/hooks/useRSVPData"; 7 7 import { useEntitySetContext } from "components/EntitySetProvider"; 8 - import { 9 - ButtonPrimary, 10 - ButtonSecondary, 11 - ButtonTertiary, 12 - } from "components/Buttons"; 13 - import { UpdateSmall } from "components/Icons"; 14 - import { Popover } from "components/Popover"; 8 + import { ButtonSecondary } from "components/Buttons"; 15 9 import { create } from "zustand"; 16 10 import { combine, createJSONStorage, persist } from "zustand/middleware"; 17 11 import { useUIState } from "src/useUIState"; 18 12 import { theme } from "tailwind.config"; 19 13 import { useToaster } from "components/Toast"; 20 - import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; 21 - import { useReplicache } from "src/replicache"; 22 14 import { ContactDetailsForm } from "./ContactDetailsForm"; 23 - import { Checkbox } from "components/Checkbox"; 24 15 import styles from "./RSVPBackground.module.css"; 25 - import { usePublishLink } from "components/ShareOptions"; 16 + import { Attendees } from "./Atendees"; 17 + import { SendUpdateButton } from "./SendUpdate"; 26 18 27 19 export type RSVP_Status = Database["public"]["Enums"]["rsvp_status"]; 28 20 let Statuses = ["GOING", "NOT_GOING", "MAYBE"]; ··· 49 41 ); 50 42 } 51 43 52 - const RSVPBackground = () => { 53 - return ( 54 - <div className="overflow-hidden absolute top-0 bottom-0 left-0 right-0 "> 55 - <div 56 - className={`rsvp-background w-full h-full bg-accent-1 z-0 ${styles.RSVPWavyBG} `} 57 - /> 58 - </div> 59 - ); 60 - }; 61 - 62 44 function RSVPForm(props: { entityID: string }) { 63 45 let [state, setState] = useState<State>({ state: "default" }); 64 46 let { permissions } = useEntitySetContext(); ··· 66 48 let setStatus = (status: RSVP_Status) => { 67 49 setState({ status, state: "contact_details" }); 68 50 }; 51 + let [editing, setEditting] = useState(false); 69 52 70 53 let rsvpStatus = data?.rsvps?.find( 71 54 (rsvp) => ··· 76 59 )?.status; 77 60 78 61 // IF YOU HAVE ALREADY RSVP'D 79 - if (rsvpStatus) 62 + if (rsvpStatus && !editing) 80 63 return ( 81 64 <> 82 65 {permissions.write && <SendUpdateButton entityID={props.entityID} />} 83 66 84 - <YourRSVPStatus entityID={props.entityID} /> 85 - <Attendees entityID={props.entityID} /> 67 + <YourRSVPStatus 68 + entityID={props.entityID} 69 + setEditting={() => { 70 + setEditting(true); 71 + }} 72 + /> 73 + <div className="w-full flex justify-between"> 74 + <Attendees entityID={props.entityID} /> 75 + <button 76 + className="hover:underline text-accent-contrast text-sm" 77 + onClick={() => { 78 + setStatus(rsvpStatus); 79 + setEditting(true); 80 + }} 81 + > 82 + Change RSVP 83 + </button> 84 + </div> 86 85 </> 87 86 ); 88 87 ··· 91 90 return ( 92 91 <> 93 92 {permissions.write && <SendUpdateButton entityID={props.entityID} />} 94 - <RSVPButtons setStatus={setStatus} /> 93 + <RSVPButtons setStatus={setStatus} status={undefined} /> 95 94 <Attendees entityID={props.entityID} className="" /> 96 95 </> 97 96 ); ··· 99 98 // IF YOU ARE CURRENTLY CONFIRMING YOUR CONTACT DETAILS 100 99 if (state.state === "contact_details") 101 100 return ( 102 - <ContactDetailsForm 103 - status={state.status} 104 - setState={setState} 105 - entityID={props.entityID} 106 - /> 101 + <> 102 + <ContactDetailsForm 103 + status={state.status} 104 + setStatus={setStatus} 105 + setState={(newState) => { 106 + if (newState.state === "default" && editing) setEditting(false); 107 + setState(newState); 108 + }} 109 + entityID={props.entityID} 110 + /> 111 + </> 107 112 ); 108 113 } 109 114 110 - const RSVPButtons = (props: { setStatus: (status: RSVP_Status) => void }) => { 115 + export const RSVPButtons = (props: { 116 + setStatus: (status: RSVP_Status) => void; 117 + status: RSVP_Status | undefined; 118 + }) => { 111 119 return ( 112 120 <div className="relative w-full sm:p-6 py-4 px-3 rounded-md border-[1.5px] border-accent-1"> 113 121 <RSVPBackground /> 114 122 <div className="relative flex flex-row gap-2 items-center mx-auto z-[1] w-fit"> 115 - <ButtonSecondary className="" onClick={() => props.setStatus("MAYBE")}> 123 + <ButtonSecondary 124 + type="button" 125 + className={ 126 + props.status === "MAYBE" 127 + ? "!text-accent-2 !bg-accent-1 text-lg" 128 + : "" 129 + } 130 + onClick={() => props.setStatus("MAYBE")} 131 + > 116 132 Maybe 117 133 </ButtonSecondary> 118 - <ButtonPrimary 119 - className="text-lg" 134 + <ButtonSecondary 135 + type="button" 136 + className={ 137 + props.status === "GOING" 138 + ? "!text-accent-2 !bg-accent-1 text-lg" 139 + : props.status === undefined 140 + ? "text-lg" 141 + : "" 142 + } 120 143 onClick={() => props.setStatus("GOING")} 121 144 > 122 145 Going! 123 - </ButtonPrimary> 146 + </ButtonSecondary> 124 147 125 148 <ButtonSecondary 126 - className="" 149 + type="button" 150 + className={ 151 + props.status === "NOT_GOING" 152 + ? "!text-accent-2 !bg-accent-1 text-lg" 153 + : "" 154 + } 127 155 onClick={() => props.setStatus("NOT_GOING")} 128 156 > 129 157 Can&apos;t Go ··· 133 161 ); 134 162 }; 135 163 136 - function YourRSVPStatus(props: { entityID: string; compact?: boolean }) { 164 + function YourRSVPStatus(props: { 165 + entityID: string; 166 + compact?: boolean; 167 + setEditting: (e: boolean) => void; 168 + }) { 137 169 let { data, mutate } = useRSVPData(); 138 170 let { name } = useRSVPNameState(); 139 171 let toaster = useToaster(); 140 172 141 - let rsvpStatus = data?.rsvps?.find( 173 + let existingRSVP = data?.rsvps?.find( 142 174 (rsvp) => 143 175 data.authToken && 144 176 rsvp.entity === props.entityID && 145 177 data.authToken.phone_number === rsvp.phone_number, 146 - )?.status; 178 + ); 179 + let rsvpStatus = existingRSVP?.status; 147 180 148 181 let updateStatus = async (status: RSVP_Status) => { 149 182 if (!data?.authToken) return; ··· 151 184 status, 152 185 name: name, 153 186 entity: props.entityID, 187 + plus_ones: existingRSVP?.plus_ones || 0, 154 188 }); 155 189 156 190 mutate({ ··· 163 197 entity: props.entityID, 164 198 phone_number: data.authToken.phone_number, 165 199 country_code: data.authToken.country_code, 200 + plus_ones: existingRSVP?.plus_ones || 0, 166 201 }, 167 202 ], 168 203 }); ··· 172 207 className={`relative w-full p-4 pb-5 rounded-md border-[1.5px] border-accent-1 font-bold items-center`} 173 208 > 174 209 <RSVPBackground /> 175 - <div className=" relative flex flex-col gap-1 sm:gap-2 z-[1] justify-center"> 210 + <div className=" relative flex flex-col gap-1 sm:gap-2 z-[1] justify-center w-fit mx-auto"> 176 211 <div 177 - className="text-xl text-center text-accent-2 text-with-outline" 212 + className=" w-fit text-xl text-center text-accent-2 text-with-outline" 178 213 style={{ 179 214 WebkitTextStroke: `3px ${theme.colors["accent-1"]}`, 180 215 textShadow: `-4px 3px 0 ${theme.colors["accent-1"]}`, ··· 188 223 NOT_GOING: "Can't Make It", 189 224 }[rsvpStatus]} 190 225 </div> 191 - <div className="flex gap-4 place-items-center justify-center"> 192 - {rsvpStatus !== "GOING" && ( 193 - <ButtonSecondary 194 - className={props.compact ? "text-sm !font-normal" : ""} 195 - compact 196 - onClick={() => { 197 - updateStatus("GOING"); 198 - toaster({ 199 - content: ( 200 - <div className="font-bold">Yay! You&apos;re Going!</div> 201 - ), 202 - type: "success", 203 - }); 204 - }} 205 - > 206 - Going 207 - </ButtonSecondary> 208 - )} 209 - {rsvpStatus !== "MAYBE" && ( 210 - <ButtonSecondary 211 - className={props.compact ? "text-sm !font-normal" : ""} 212 - compact 213 - onClick={() => { 214 - updateStatus("MAYBE"); 215 - toaster({ 216 - content: <div className="font-bold">You&apos;re a Maybe</div>, 217 - type: "success", 218 - }); 219 - }} 220 - > 221 - Maybe 222 - </ButtonSecondary> 223 - )} 224 - {rsvpStatus !== "NOT_GOING" && ( 225 - <ButtonSecondary 226 - compact 227 - className={props.compact ? "text-sm !font-normal" : ""} 228 - onClick={() => { 229 - updateStatus("NOT_GOING"); 230 - toaster({ 231 - content: ( 232 - <div className="font-bold"> 233 - Sorry you can&apos;t make it D: 234 - </div> 235 - ), 236 - type: "success", 237 - }); 238 - }} 239 - > 240 - Can&apos;t Go 241 - </ButtonSecondary> 242 - )} 243 - </div> 226 + {existingRSVP?.plus_ones && existingRSVP?.plus_ones > 0 ? ( 227 + <div className="absolute -top-2 -right-6 rotate-12 h-fit w-10 bg-accent-1 font-bold text-accent-2 rounded-full -z-10"> 228 + <div className="w-full text-center pr-[4px] pb-[1px]"> 229 + +{existingRSVP?.plus_ones} 230 + </div> 231 + </div> 232 + ) : null} 244 233 </div> 245 234 </div> 246 235 ); 247 236 } 248 237 249 - function Attendees(props: { entityID: string; className?: string }) { 250 - let { data } = useRSVPData(); 251 - let attendees = 252 - data?.rsvps?.filter((rsvp) => rsvp.entity === props.entityID) || []; 253 - let going = attendees.filter((rsvp) => rsvp.status === "GOING"); 254 - let maybe = attendees.filter((rsvp) => rsvp.status === "MAYBE"); 255 - let notGoing = attendees.filter((rsvp) => rsvp.status === "NOT_GOING"); 256 - 238 + const RSVPBackground = () => { 257 239 return ( 258 - <Popover 259 - align="start" 260 - className="text-sm text-secondary flex flex-col gap-2 max-w-sm" 261 - asChild 262 - trigger={ 263 - going.length === 0 && maybe.length === 0 ? ( 264 - <button 265 - className={`text-sm font-normal w-max text-tertiary italic hover:underline ${props.className}`} 266 - > 267 - No RSVPs yet 268 - </button> 269 - ) : ( 270 - <ButtonTertiary className={`text-sm font-normal ${props.className}`}> 271 - {going.length > 0 && `${going.length} Going`} 272 - {maybe.length > 0 && 273 - `${going.length > 0 ? ", " : ""}${maybe.length} Maybe`} 274 - </ButtonTertiary> 275 - ) 276 - } 277 - > 278 - {going.length === 0 && maybe.length === 0 && notGoing.length === 0 && ( 279 - <div className="text-tertiary italic">No RSVPs yet</div> 280 - )} 281 - {going.length > 0 && ( 282 - <div className="flex flex-col gap-0.5"> 283 - <div className="font-bold text-tertiary">Going ({going.length})</div> 284 - {going.map((rsvp) => ( 285 - <div key={rsvp.phone_number}>{rsvp.name}</div> 286 - ))} 287 - </div> 288 - )} 289 - {maybe.length > 0 && ( 290 - <div className="flex flex-col gap-0"> 291 - <div className="font-bold text-tertiary">Maybe ({maybe.length})</div> 292 - {maybe.map((rsvp) => ( 293 - <div key={rsvp.phone_number}>{rsvp.name}</div> 294 - ))} 295 - </div> 296 - )} 297 - {notGoing.length > 0 && ( 298 - <div className="flex flex-col gap-0"> 299 - <div className="font-bold text-tertiary"> 300 - Can&apos;t Go ({notGoing.length}) 301 - </div> 302 - {notGoing.map((rsvp) => ( 303 - <div key={rsvp.phone_number}>{rsvp.name}</div> 304 - ))} 305 - </div> 306 - )} 307 - </Popover> 308 - ); 309 - } 310 - 311 - function SendUpdateButton(props: { entityID: string }) { 312 - let publishLink = usePublishLink(); 313 - let { permissions } = useEntitySetContext(); 314 - let { permission_token } = useReplicache(); 315 - let [input, setInput] = useState(""); 316 - let toaster = useToaster(); 317 - let [open, setOpen] = useState(false); 318 - let [checkedRecipients, setCheckedRecipients] = useState({ 319 - GOING: true, 320 - MAYBE: true, 321 - NOT_GOING: false, 322 - }); 323 - 324 - let { data, mutate } = useRSVPData(); 325 - let attendees = 326 - data?.rsvps?.filter((rsvp) => rsvp.entity === props.entityID) || []; 327 - let going = attendees.filter((rsvp) => rsvp.status === "GOING"); 328 - let maybe = attendees.filter((rsvp) => rsvp.status === "MAYBE"); 329 - let notGoing = attendees.filter((rsvp) => rsvp.status === "NOT_GOING"); 330 - 331 - let allRecipients = 332 - ((checkedRecipients.GOING && going.length) || 0) + 333 - ((checkedRecipients.MAYBE && maybe.length) || 0) + 334 - ((checkedRecipients.NOT_GOING && notGoing.length) || 0); 335 - 336 - if (!!!permissions.write) return; 337 - return ( 338 - <Popover 339 - asChild 340 - open={open} 341 - onOpenChange={(open) => setOpen(open)} 342 - trigger={ 343 - <ButtonPrimary fullWidth className="mb-2"> 344 - <UpdateSmall /> Send a Text Blast 345 - </ButtonPrimary> 346 - } 347 - > 348 - <div className="rsvpMessageComposer flex flex-col gap-2 w-[1000px] max-w-full sm:max-w-md"> 349 - <div className="flex flex-col font-bold text-secondary"> 350 - <h3>Send a Text Blast to</h3> 351 - <RecipientPicker 352 - checked={checkedRecipients} 353 - setChecked={setCheckedRecipients} 354 - /> 355 - 356 - <textarea 357 - id="rsvp-message-input" 358 - value={input} 359 - onChange={(e) => { 360 - setInput(e.target.value); 361 - }} 362 - className="input-with-border w-full h-[150px] mt-3 pt-0.5 font-normal text-primary" 363 - /> 364 - </div> 365 - <div className="flex justify-between items-start"> 366 - <div 367 - className={`rsvpMessageCharCounter text-sm text-tertiary`} 368 - style={ 369 - input.length > 300 370 - ? { 371 - color: theme.colors["accent-1"], 372 - fontWeight: "bold", 373 - } 374 - : { 375 - color: theme.colors["tertiary"], 376 - } 377 - } 378 - > 379 - {input.length}/300 {input.length > 300 && " (too long!)"} 380 - </div> 381 - <ButtonPrimary 382 - disabled={input.length > 300} 383 - className="place-self-end " 384 - onClick={async () => { 385 - if (!permission_token || !publishLink) return; 386 - await sendUpdateToRSVPS(permission_token, { 387 - entity: props.entityID, 388 - message: input, 389 - eventName: document.title, 390 - sendto: checkedRecipients, 391 - publicLeafletID: publishLink, 392 - }); 393 - toaster({ 394 - content: <div className="font-bold">Update sent!</div>, 395 - type: "success", 396 - }); 397 - setOpen(false); 398 - }} 399 - > 400 - Text {allRecipients} {allRecipients === 1 ? "Person" : "People"}! 401 - </ButtonPrimary> 402 - </div> 403 - </div> 404 - </Popover> 405 - ); 406 - } 407 - 408 - const RecipientPicker = (props: { 409 - checked: { GOING: boolean; MAYBE: boolean; NOT_GOING: boolean }; 410 - setChecked: (checked: { 411 - GOING: boolean; 412 - MAYBE: boolean; 413 - NOT_GOING: boolean; 414 - }) => void; 415 - }) => { 416 - return ( 417 - <div className="flex flex-col gap-0.5"> 418 - {/* <small className="font-normal"> 419 - Send a text to everyone who RSVP&apos;d: 420 - </small> */} 421 - <div className="flex gap-4 text-secondary"> 422 - <Checkbox 423 - className="!w-fit" 424 - checked={props.checked.GOING} 425 - onChange={() => { 426 - props.setChecked({ 427 - ...props.checked, // Spread the existing values 428 - GOING: !props.checked.GOING, 429 - }); 430 - }} 431 - > 432 - Going 433 - </Checkbox> 434 - <Checkbox 435 - className="!w-fit" 436 - checked={props.checked.MAYBE} 437 - onChange={() => { 438 - props.setChecked({ 439 - ...props.checked, // Spread the existing values 440 - MAYBE: !props.checked.MAYBE, 441 - }); 442 - }} 443 - > 444 - Maybe 445 - </Checkbox> 446 - <Checkbox 447 - className="!w-fit" 448 - checked={props.checked.NOT_GOING} 449 - onChange={() => { 450 - props.setChecked({ 451 - ...props.checked, // Spread the existing values 452 - NOT_GOING: !props.checked.NOT_GOING, 453 - }); 454 - }} 455 - > 456 - Can&apos;t Go 457 - </Checkbox> 458 - </div> 240 + <div className="overflow-hidden absolute top-0 bottom-0 left-0 right-0 "> 241 + <div 242 + className={`rsvp-background w-full h-full bg-accent-1 z-0 ${styles.RSVPWavyBG} `} 243 + /> 459 244 </div> 460 245 ); 461 246 };
+1
components/Blocks/useBlockMouseHandlers.ts
··· 17 17 let onMouseDown = useCallback( 18 18 (e: MouseEvent) => { 19 19 if ((e.target as Element).getAttribute("data-draggable")) return; 20 + if ((e.target as Element).tagName === "BUTTON") return; 20 21 if (isMobile) return; 21 22 if (!entity_set.permissions.write) return; 22 23 useSelectingMouse.setState({ start: props.value });
+7 -11
components/Layout.tsx
··· 93 93 ) => { 94 94 let { label, ...inputProps } = props; 95 95 return ( 96 - <div> 97 - <div className="input-with-border flex flex-col"> 98 - <label className="text-sm text-tertiary font-bold italic"> 99 - {props.label} 100 - <Input 101 - {...inputProps} 102 - className={`appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 ${props.className}`} 103 - /> 104 - </label> 105 - </div> 106 - </div> 96 + <label className="text-xs text-tertiary font-bold italic input-with-border flex flex-col w-full"> 97 + {props.label} 98 + <Input 99 + {...inputProps} 100 + className={`appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 ${props.className}`} 101 + /> 102 + </label> 107 103 ); 108 104 }; 109 105
+1
supabase/migrations/20250116190638_add_plus_one_to_rsvp.sql
··· 1 + alter table "public"."phone_rsvps_to_entity" add column "plus_ones" smallint not null default '0'::smallint;