a tool for shared writing and social publishing
at update/reader 366 lines 12 kB view raw
1"use client"; 2import { useSmoker, useToaster } from "components/Toast"; 3import { RSVP_Status, RSVPButtons, State, useRSVPNameState } from "."; 4import { createContext, useContext, useState } from "react"; 5import { useRSVPData } from "components/PageSWRDataProvider"; 6import { confirmPhoneAuthToken } from "actions/phone_auth/confirm_phone_auth_token"; 7import { submitRSVP } from "actions/phone_rsvp_to_event"; 8 9import { countryCodes } from "src/constants/countryCodes"; 10import { Checkbox } from "components/Checkbox"; 11import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 12import { Separator } from "components/Layout"; 13import { createPhoneAuthToken } from "actions/phone_auth/request_phone_auth_token"; 14import { Input, InputWithLabel } from "components/Input"; 15import { RequestHeadersContext } from "components/Providers/RequestHeadersProvider"; 16import { Popover } from "components/Popover"; 17import { theme } from "tailwind.config"; 18import { InfoSmall } from "components/Icons/InfoSmall"; 19 20export function ContactDetailsForm(props: { 21 status: RSVP_Status; 22 entityID: string; 23 setState: (s: State) => void; 24 setStatus: (s: RSVP_Status) => void; 25}) { 26 let { status, entityID, setState, setStatus } = props; 27 let focusWithinStyles = 28 "focus-within:border-tertiary focus-within:outline-solid focus-within:outline-2 focus-within:outline-tertiary focus-within:outline-offset-1"; 29 let toaster = useToaster(); 30 let { data, mutate } = useRSVPData(); 31 let [contactFormState, setContactFormState] = useState< 32 { state: "details" } | { state: "confirm"; token: string } 33 >({ state: "details" }); 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 ); 44 let requestHeaders = useContext(RequestHeadersContext); 45 const [formState, setFormState] = useState({ 46 country_code: 47 countryCodes.find((c) => c[1].toUpperCase() === (requestHeaders.country || "US"))?.[2] || "1", 48 phone_number: "", 49 confirmationCode: "", 50 }); 51 52 let submit = async ( 53 token: Awaited<ReturnType<typeof confirmPhoneAuthToken>>, 54 ) => { 55 try { 56 await submitRSVP({ 57 status, 58 name: name, 59 entity: entityID, 60 plus_ones, 61 }); 62 } catch (e) { 63 //handle failed confirm 64 return false; 65 } 66 67 mutate({ 68 authToken: token, 69 rsvps: [ 70 ...(data?.rsvps || []).filter((r) => r.entity !== entityID), 71 { 72 name: name, 73 status, 74 plus_ones, 75 entity: entityID, 76 phone_number: token.phone_number, 77 country_code: token.country_code, 78 }, 79 ], 80 }); 81 props.setState({ state: "default" }); 82 return true; 83 }; 84 return contactFormState.state === "details" ? ( 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 "> 113 <label 114 htmlFor="rsvp-name-input" 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 onKeyDown={(e) => { 127 if (e.key === "Backspace" && !e.currentTarget.value) 128 e.preventDefault(); 129 }} 130 onChange={(e) => setName(e.target.value)} 131 /> 132 </label> 133 <div 134 className={`rsvpPhoneInputWrapper relative flex flex-col gap-0.5 w-full basis-2/3`} 135 > 136 <label 137 htmlFor="rsvp-phone-input" 138 className={` 139 rsvpPhoneInput input-with-border 140 flex flex-col ${focusWithinStyles} 141 ${!!data?.authToken?.phone_number && "bg-border-light border-border-light text-tertiary"}`} 142 > 143 <div className=" text-xs font-bold italic text-tertiary"> 144 Phone Number 145 </div> 146 <div className="flex gap-2 "> 147 <div className="flex items-center gap-1"> 148 <span 149 style={{ 150 color: 151 formState.country_code === "" || 152 !!data?.authToken?.phone_number 153 ? theme.colors.tertiary 154 : theme.colors.primary, 155 }} 156 > 157 + 158 </span> 159 <Input 160 onKeyDown={(e) => { 161 if (e.key === "Backspace" && !e.currentTarget.value) 162 e.preventDefault(); 163 }} 164 disabled={!!data?.authToken?.phone_number} 165 className="w-10 bg-transparent appearance-none focus:outline-0" 166 placeholder="1" 167 maxLength={4} 168 inputMode="numeric" 169 pattern="[0-9]*" 170 value={formState.country_code} 171 onChange={(e) => 172 setFormState((s) => ({ 173 ...s, 174 country_code: e.target.value.replace(/[^0-9]/g, ""), 175 })) 176 } 177 /> 178 </div> 179 <Separator /> 180 181 <Input 182 id="rsvp-phone-input" 183 inputMode="numeric" 184 placeholder="0000000000" 185 pattern="[0-9]*" 186 className=" bg-transparent disabled:text-tertiary w-full appearance-none focus:outline-0" 187 disabled={!!data?.authToken?.phone_number} 188 onKeyDown={(e) => { 189 if (e.key === "Backspace" && !e.currentTarget.value) 190 e.preventDefault(); 191 }} 192 value={ 193 data?.authToken?.phone_number || formState.phone_number 194 } 195 onChange={(e) => 196 setFormState((state) => ({ 197 ...state, 198 phone_number: e.target.value.replace(/[^0-9]/g, ""), 199 })) 200 } 201 /> 202 </div> 203 </label> 204 <div className="text-xs italic text-tertiary leading-tight"> 205 {formState.country_code !== "1" ? ( 206 <> 207 Messages to non-US/Canada numbers will be sent via{" "} 208 <strong>WhatsApp</strong> 209 </> 210 ) : null} 211 </div> 212 </div> 213 <div className="flex flex-row gap-2 w-full sm:w-32 h-fit"> 214 <InputWithLabel 215 className="appearance-none!" 216 placeholder="0" 217 label="Plus ones?" 218 type="number" 219 min={0} 220 max={4} 221 value={plus_ones} 222 onChange={(e) => setPlusOnes(parseInt(e.currentTarget.value))} 223 onKeyDown={(e) => { 224 if (e.key === "Backspace" && !e.currentTarget.value) 225 e.preventDefault(); 226 }} 227 /> 228 </div> 229 </div> 230 231 <hr className="border-border" /> 232 <div className="flex flex-row gap-2 w-full items-center justify-end"> 233 <ConsentPopover country_code={formState.country_code} /> 234 <ButtonTertiary 235 onMouseDown={() => { 236 setState({ state: "default" }); 237 }} 238 > 239 Back 240 </ButtonTertiary> 241 <ButtonPrimary 242 disabled={ 243 (!data?.authToken?.phone_number && 244 (!formState.phone_number || !formState.country_code)) || 245 !name 246 } 247 className="place-self-end" 248 type="submit" 249 > 250 RSVP as{" "} 251 {status === "GOING" 252 ? "Going" 253 : status === "MAYBE" 254 ? "Maybe" 255 : "Can't Go"} 256 </ButtonPrimary> 257 </div> 258 </form> 259 </> 260 ) : ( 261 <ConfirmationForm 262 country_code={formState.country_code} 263 phoneNumber={formState.phone_number} 264 token={contactFormState.token} 265 value={formState.confirmationCode} 266 submit={submit} 267 status={status} 268 onChange={(value) => 269 setFormState((state) => ({ ...state, confirmationCode: value })) 270 } 271 /> 272 ); 273} 274 275const ConfirmationForm = (props: { 276 country_code: string; 277 phoneNumber: string; 278 value: string; 279 token: string; 280 status: RSVP_Status; 281 submit: ( 282 token: Awaited<ReturnType<typeof confirmPhoneAuthToken>>, 283 ) => Promise<boolean>; 284 onChange: (v: string) => void; 285}) => { 286 let smoker = useSmoker(); 287 let toaster = useToaster(); 288 return ( 289 <form 290 className="flex flex-col gap-3 w-full" 291 onSubmit={async (e) => { 292 e.preventDefault(); 293 let rect = document 294 .getElementById("rsvp-code-confirm-button") 295 ?.getBoundingClientRect(); 296 try { 297 let token = await confirmPhoneAuthToken(props.token, props.value); 298 props.submit(token); 299 toaster({ 300 content: ( 301 <div className="font-bold"> 302 {props.status === "GOING" 303 ? "Yay! You're Going!" 304 : props.status === "MAYBE" 305 ? "You're a Maybe" 306 : "Sorry you can't make it D:"} 307 </div> 308 ), 309 type: "success", 310 }); 311 } catch (error) { 312 smoker({ 313 alignOnMobile: "left", 314 error: true, 315 text: "invalid code!", 316 position: { 317 x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 318 y: rect ? rect.top + 26 : 0, 319 }, 320 }); 321 return; 322 } 323 }} 324 > 325 <label className="rsvpNameInput relative w-full flex flex-col gap-0.5"> 326 <div className="absolute top-0.5 left-[6px] text-xs font-bold italic text-tertiary"> 327 confirmation code 328 </div> 329 330 <Input 331 autoFocus 332 placeholder="000000" 333 className="input-with-border pt-5! w-full " 334 value={props.value} 335 autoComplete="one-time-code" 336 onChange={(e) => props.onChange(e.target.value)} 337 /> 338 <div className="text-sm italic text-tertiary leading-tight"> 339 Code was sent to your{" "} 340 {props.country_code === "1" ? "phone" : <strong>WhatsApp</strong>}{" "} 341 number: +{props.country_code} {props.phoneNumber}! 342 </div> 343 </label> 344 345 <ButtonPrimary 346 id="rsvp-code-confirm-button" 347 className="place-self-end" 348 type="submit" 349 > 350 Confirm 351 </ButtonPrimary> 352 </form> 353 ); 354}; 355 356const ConsentPopover = (props: { country_code: string }) => { 357 return ( 358 <Popover trigger={<InfoSmall className="text-accent-contrast" />}> 359 <div className="text-sm text-secondary"> 360 By RSVPing I to consent to receive 361 {props.country_code === "1" ? "" : " WhatsApp"} messages from the event 362 host, via Leaflet! 363 </div> 364 </Popover> 365 ); 366};