diff --git a/components/core/ClickAway.tsx b/components/core/ClickAway.tsx index 83eb7af7..72fed15f 100644 --- a/components/core/ClickAway.tsx +++ b/components/core/ClickAway.tsx @@ -26,7 +26,11 @@ const ClickAway = ({ children, onClickAway, className }: ClickAwayProps) => { }, [onClickAway, wrapperRef]) return ( -
+
{children}
) diff --git a/components/core/Dropdown.tsx b/components/core/Dropdown.tsx index 54024441..e60da5ea 100644 --- a/components/core/Dropdown.tsx +++ b/components/core/Dropdown.tsx @@ -1,5 +1,6 @@ -import styled from 'styled-components' +import React, { useEffect, useState } from 'react' import useDropdownLogic, { SelectOption } from '../hooks/useDropdownLogic' +import styled from 'styled-components' import { ChevronButton } from './ChevronButton' import ClickAway from './ClickAway' import Icon from './Icon' @@ -9,7 +10,6 @@ import { Menu, MenuPopup, MenuSection } from './Menu' import { Subline2 } from './Typography' import { InputText } from './InputText' import { Avatar } from './Avatar' -import { useEffect, useState } from 'react' const Container = styled.div` position: relative; diff --git a/components/core/ListingCard.tsx b/components/core/ListingCard.tsx index 8f2b58e3..79119f68 100644 --- a/components/core/ListingCard.tsx +++ b/components/core/ListingCard.tsx @@ -10,7 +10,10 @@ import Link from 'next/link' import { ImageFlex } from './gallery/ImageFlex' import { HorizontalDivider } from './Divider' import events from '@/lib/googleAnalytics/events' -import { LocationFragment, ShortAddressFragment } from '@/utils/types/location' +import { + LocationFragment, + ShortAddressFragmentType, +} from '@/utils/types/location' import { formatShortAddress } from '@/lib/address' import { getImageUrlByIpfsHash } from '@/lib/image' @@ -22,7 +25,7 @@ interface ListingCardProps { bannerImageIpfsHash: string | null | undefined voteCount: number | null | undefined recentVoters: LocationFragment['recentVoters'] | null | undefined - address: ShortAddressFragment | null | undefined + address: ShortAddressFragmentType | null | undefined sleepCapacity: number | null | undefined offerCount: number | null | undefined } diff --git a/components/core/LocationAutocompleteInput.tsx b/components/core/LocationAutocompleteInput.tsx index a1dfe088..a24ffe16 100644 --- a/components/core/LocationAutocompleteInput.tsx +++ b/components/core/LocationAutocompleteInput.tsx @@ -1,11 +1,11 @@ import { useState } from 'react' import { usePlacesWidget } from 'react-google-autocomplete' -import { AddressFragment } from '@/utils/types/location' +import { AddressFragmentType } from '@/utils/types/location' import { InputText } from '@/components/core/InputText' interface LocationAutocompleteInputProps { - onLocationChange: (value: AddressFragment) => void - initialValue?: AddressFragment | null + onLocationChange: (value: AddressFragmentType) => void + initialValue?: AddressFragmentType | null error: boolean errorMessage?: string bottomHelpText?: string @@ -36,7 +36,6 @@ export const LocationAutocompleteInput = ({ ], }, onPlaceSelected: (place) => { - console.log(place) const addr = getFragment(place) setValue(addr.formattedAddress || '') onLocationChange(addr) @@ -65,7 +64,7 @@ export const LocationAutocompleteInput = ({ const getFragment = ( place: google.maps.places.PlaceResult -): AddressFragment => { +): AddressFragmentType => { return { formattedAddress: place.formatted_address || null, lat: place.geometry?.location?.lat() || null, diff --git a/components/core/LocationCard.tsx b/components/core/LocationCard.tsx index 4a750219..728f83ab 100644 --- a/components/core/LocationCard.tsx +++ b/components/core/LocationCard.tsx @@ -1,7 +1,10 @@ import Link from 'next/link' import Image from 'next/image' -import { LocationFragment, ShortAddressFragment } from '@/utils/types/location' -import { CaretakerFragment } from '@/utils/types/profile' +import { + LocationFragment, + ShortAddressFragmentType, +} from '@/utils/types/location' +import { ProfileBasicFragment } from '@/utils/types/profile' import { useDeviceSize } from '../hooks/useDeviceSize' import { formatShortAddress } from '@/lib/address' import { EMPTY, truncate } from '@/utils/display-utils' @@ -26,10 +29,10 @@ interface LocationCardProps { bannerImageIpfsHash: string | null | undefined voteCount: number | null | undefined recentVoters: LocationFragment['recentVoters'] | null | undefined - address: ShortAddressFragment | null | undefined + address: ShortAddressFragmentType | null | undefined sleepCapacity: number | null | undefined offerCount: number | null | undefined - caretaker: CaretakerFragment + caretaker: ProfileBasicFragment } editMode?: boolean hideVerifiedTag?: boolean diff --git a/components/layouts/OnboardingLayout.tsx b/components/layouts/OnboardingLayout.tsx deleted file mode 100644 index 9c0e6019..00000000 --- a/components/layouts/OnboardingLayout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import styled from 'styled-components' -import { FixedWidthMainContent } from './common.styles' - -interface LayoutProps { - children: React.ReactNode -} - -const Container = styled.div` - display: flex; - flex-direction: column; - min-height: 100vh; - min-width: 100vw; - justify-content: flex-start; - align-items: center; - gap: 4.8rem; - padding: 2.5rem 1.6rem; - - ${({ theme }) => theme.bp.md} { - padding: 2.5rem 8rem; - } - - ${({ theme }) => theme.bp.lg} { - padding: 0 1.6rem; - justify-content: center; - } -` - -export const OnboardingLayout = ({ children }: LayoutProps) => { - return ( - - {children} - - ) -} diff --git a/components/neighborhoods/edit-location/BasicDetailStep.tsx b/components/neighborhoods/edit-location/BasicDetailStep.tsx index 563f4234..b7c1c3ef 100644 --- a/components/neighborhoods/edit-location/BasicDetailStep.tsx +++ b/components/neighborhoods/edit-location/BasicDetailStep.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' import { useUpdateLocation } from '../useUpdateLocation' -import { AddressFragment, LocationEditParams } from '@/utils/types/location' +import { AddressFragmentType, LocationEditParams } from '@/utils/types/location' import { useError } from '@/components/hooks/useError' import { MAX_LOCATION_BIO_LENGTH, @@ -35,7 +35,7 @@ export const BasicDetailStep = ({ }: StepProps) => { const { updateLocation } = useUpdateLocation(location.externId) - const [address, setAddress] = useState( + const [address, setAddress] = useState( location?.address ) @@ -93,7 +93,7 @@ export const BasicDetailStep = ({ })) } - const handleLocationChange = (value: AddressFragment) => { + const handleLocationChange = (value: AddressFragmentType) => { setAddress(value) } diff --git a/components/profile/AboutInput.tsx b/components/profile/AboutInput.tsx index d676f504..72e582e2 100644 --- a/components/profile/AboutInput.tsx +++ b/components/profile/AboutInput.tsx @@ -4,18 +4,18 @@ import { InputTextArea } from '../core/InputTextArea' import { MAX_BIO_LENGTH } from './constants' import { isValidAddress, isValidBio } from './validations' import { AvatarSetup } from '@/components/profile/AvatarSetup' -import { AvatarFragment } from '@/utils/types/profile' +import { AvatarFragmentType } from '@/utils/types/profile' import { LocationAutocompleteInput } from '@/components/core/LocationAutocompleteInput' import { ADDRESS_ERROR } from '@/utils/validate' -import { AddressFragment } from '@/utils/types/location' +import { AddressFragmentType } from '@/utils/types/location' interface AboutInputProps { bio: string onBioChange: (bio: string) => void - address?: AddressFragment - onAddressChange: (location: AddressFragment) => void - avatar?: AvatarFragment - onAvatarChange?: (avatar: AvatarFragment | undefined) => void + address?: AddressFragmentType + onAddressChange: (location: AddressFragmentType) => void + avatar?: AvatarFragmentType + onAvatarChange?: (avatar: AvatarFragmentType | undefined) => void } export const AboutInput = ({ diff --git a/components/profile/RegistrationForm.tsx b/components/profile/RegistrationForm.tsx index 912a881c..87d2f006 100644 --- a/components/profile/RegistrationForm.tsx +++ b/components/profile/RegistrationForm.tsx @@ -1,11 +1,19 @@ import React, { useEffect, useState } from 'react' import { usePrivy } from '@privy-io/react-auth' -import { useProfile } from '../auth/useProfile' -import { useExternalUser } from '../auth/useExternalUser' +import { useProfile } from '@/components/auth/useProfile' +import { useExternalUser } from '@/components/auth/useExternalUser' +import { useBackend } from '@/components/hooks/useBackend' +import { + NeighborhoodFragment, + NeighborhoodListParamsType, + NeighborhoodListResponse, +} from '@/utils/types/neighborhood' +import { SelectOption } from '@/components/hooks/useDropdownLogic' import styled from 'styled-components' -import { AvatarFragment } from '@/utils/types/profile' -import { Button } from '../core/Button' -import { InputText } from '../core/InputText' +import { AvatarFragmentType } from '@/utils/types/profile' +import { Button } from '@/components/core/Button' +import { Dropdown } from '@/components/core/Dropdown' +import { InputText } from '@/components/core/InputText' import { AvatarSetup } from './AvatarSetup' import { MAX_DISPLAY_NAME_LENGTH } from './constants' import { RegistrationParams } from './RegistrationView' @@ -16,21 +24,28 @@ import { } from './validations' import { LocationAutocompleteInput } from '@/components/core/LocationAutocompleteInput' import { ADDRESS_ERROR } from '@/utils/validate' -import { AddressFragment } from '@/utils/types/location' +import { AddressFragmentType } from '@/utils/types/location' +import LoadingSpinner from '@/components/core/LoadingSpinner' +import { InputLabel } from '@/components/core/InputLabel' +import { Caption } from '@/components/core/Typography' +import { ContactUsLink } from '@/components/core/ContactUsLink' -interface RegistrationFormProps { +export function RegistrationForm({ + onSubmit, +}: { onSubmit: (params: RegistrationParams) => void -} - -export const RegistrationForm = ({ onSubmit }: RegistrationFormProps) => { +}) { const { linkEmail } = usePrivy() const { externalUser } = useExternalUser() const { user } = useProfile() const [email, setEmail] = useState('') - const [avatar, setAvatar] = useState() + const [avatar, setAvatar] = useState() const [name, setName] = useState('') - const [address, setAddress] = useState() + const [address, setAddress] = useState() + const [neighborhood, setNeighborhood] = useState< + NeighborhoodFragment | null | undefined + >() const [canShowNameError, setCanShowNameError] = useState(false) const [canShowAddressError, setCanShowAddressError] = useState(false) @@ -38,36 +53,48 @@ export const RegistrationForm = ({ onSubmit }: RegistrationFormProps) => { const [submitted, setSubmitted] = useState(false) useEffect(() => { - if ( - externalUser?.email?.address && - externalUser.email.address !== email && - !submitted && - isValidName(name) && - address && - isValidAddress(address) && - !user - ) { - onSubmit({ - name: name.trim(), - email: email.trim(), - address, - avatar, - }) - setSubmitted(true) - return - } + // if ( + // externalUser?.email?.address && + // externalUser.email.address !== email && + // !submitted && + // isValidName(name) && + // address && + // isValidAddress(address) && + // !user + // ) { + // onSubmit({ + // name: name.trim(), + // email: email.trim(), + // address, + // avatar, + // neighborhoodExternId: neighborhood?.externId, + // }) + // setSubmitted(true) + // return + // } if (externalUser?.email?.address) { - setEmail(externalUser.email?.address) + setEmail(externalUser.email.address) } - }, [externalUser, avatar, address, name, email, onSubmit, user, submitted]) + }, [ + externalUser, + setEmail, + // avatar, + // address, + // name, + // email, + // onSubmit, + // user, + // submitted, + // neighborhood?.externId, + ]) const onNameChange = (e: React.ChangeEvent) => { setCanShowNameError(false) setName(e.target.value) } - const onAddressChange = (address: AddressFragment) => { + const onAddressChange = (address: AddressFragmentType) => { setCanShowAddressError(false) setAddress(address) } @@ -78,11 +105,13 @@ export const RegistrationForm = ({ onSubmit }: RegistrationFormProps) => { if (isValidName(name) && address && isValidAddress(address)) { if (externalUser?.email?.address) { + setSubmitted(true) onSubmit({ email: email.trim(), name: name.trim(), address, avatar, + neighborhoodExternId: neighborhood?.externId, }) } else { linkEmail() @@ -114,7 +143,6 @@ export const RegistrationForm = ({ onSubmit }: RegistrationFormProps) => { disabled={!!user || submitted} initialValue={address} onLocationChange={onAddressChange} - // placeholder={'Start typing a address'} bottomHelpText={ 'Precise location will not be public. If nomadic, what city do you spend the biggest chunk of time?' } @@ -124,6 +152,12 @@ export const RegistrationForm = ({ onSubmit }: RegistrationFormProps) => { + + { ) } +function NeighborhoodSelect({ + address, + selected, + onNeighborhoodChange, +}: { + address: AddressFragmentType | undefined + selected: NeighborhoodFragment | null | undefined + onNeighborhoodChange: (n: NeighborhoodFragment | null | undefined) => void +}) { + const label = 'Cabin Neighborhood' + const { useGet } = useBackend() + const { data, isLoading } = useGet( + address ? 'NEIGHBORHOOD_LIST' : null, + { + lat: address?.lat, + lng: address?.lng, + maxDistance: 200, + } as NeighborhoodListParamsType + ) + const neighborhoods = !data || 'error' in data ? [] : data.neighborhoods + + const neighborhoodSelectOptions = neighborhoods.map((n) => { + return { + label: n.name, + value: n.externId, + } + }) + + if (neighborhoodSelectOptions.length > 0) { + neighborhoodSelectOptions.push({ + label: 'none', + value: '', + }) + } + + const [selectedOption, setSelectedOption] = useState< + SelectOption | undefined + >() + + const selectNeighborhood = (o: SelectOption | undefined) => { + setSelectedOption(o) + + if (!o) { + onNeighborhoodChange(undefined) + return + } + + if (o?.value == '') { + onNeighborhoodChange(null) + return + } + + const n = o && neighborhoods.find((ne) => ne.externId === o.value) + onNeighborhoodChange(n) + } + + useEffect(() => { + if (!neighborhoodSelectOptions.length) { + selectNeighborhood(undefined) + return + } + + if (selected === undefined) { + selectNeighborhood(neighborhoodSelectOptions[0]) + return + } + + const selectedInOptions = neighborhoodSelectOptions.find( + (n) => n.value === (selected === null ? '' : selected.externId) + ) + + if (!selectedInOptions) { + selectNeighborhood(neighborhoodSelectOptions[0]) + } + }, [neighborhoodSelectOptions, selected]) + + if (!address || !neighborhoods.length) { + return ( + + + + + {!address ? ( + 'Enter a location' + ) : ( + <> + No Cabin neighborhoods near you yet. Want to start one?{' '} + + Contact us + + + )} + + + + ) + } + + return ( + + + {isLoading ? ( + <> + + + + ) : ( + selectNeighborhood(n)} + /> + )} + + + ) +} + const Container = styled.div` margin: 1.6rem; display: flex; diff --git a/components/profile/RegistrationView.tsx b/components/profile/RegistrationView.tsx index ee4af5b4..d044d292 100644 --- a/components/profile/RegistrationView.tsx +++ b/components/profile/RegistrationView.tsx @@ -3,23 +3,28 @@ import { useRouter } from 'next/router' import { usePrivy } from '@privy-io/react-auth' import { useProfile } from '../auth/useProfile' import { useBackend } from '@/components/hooks/useBackend' -import { ProfileNewParams } from '@/pages/api/v2/profile/new' -import { AvatarFragment, ProfileNewResponse } from '@/utils/types/profile' +import { + ProfileNewParamsType, + AvatarFragmentType, + ProfileNewResponse, +} from '@/utils/types/profile' import { useConfirmLoggedIn } from '../auth/useConfirmLoggedIn' import { useExternalUser } from '../auth/useExternalUser' import { useModal } from '../hooks/useModal' -import { OnboardingLayout } from '../layouts/OnboardingLayout' +import styled from 'styled-components' +import { MainContent } from '@/components/layouts/common.styles' import { TitleCard } from '../core/TitleCard' import { ContentCard } from '../core/ContentCard' import { RegistrationForm } from './RegistrationForm' import { ErrorModal } from '../ErrorModal' -import { AddressFragment } from '@/utils/types/location' +import { AddressFragmentType } from '@/utils/types/location' export interface RegistrationParams { email: string name: string - address: AddressFragment - avatar: AvatarFragment | undefined + address: AddressFragmentType + avatar: AvatarFragmentType | undefined + neighborhoodExternId?: string } export const RegistrationView = () => { @@ -51,12 +56,13 @@ export const RegistrationView = () => { return } - const createProfileBody: ProfileNewParams = { + const createProfileBody: ProfileNewParamsType = { walletAddress: externalUser.wallet?.address || '', name, email: externalUser.email?.address || email, address, avatar, + neighborhoodExternId: params.neighborhoodExternId, } try { @@ -86,11 +92,35 @@ export const RegistrationView = () => { } return ( - - - - - - + + + + + + + + ) } + +// TODO: instead of and , we should be using SingleColumnLayout and hiding the footer and nav sidebar + +const Container = styled.div` + display: flex; + flex-direction: column; + min-height: 100vh; + min-width: 100vw; + justify-content: flex-start; + align-items: center; + gap: 4.8rem; + padding: 2.5rem 1.6rem; + + ${({ theme }) => theme.bp.md} { + padding: 2.5rem 8rem; + } + + ${({ theme }) => theme.bp.lg} { + padding: 0 1.6rem; + justify-content: center; + } +` diff --git a/components/profile/validations.ts b/components/profile/validations.ts index 7e5a191c..8e32e81d 100644 --- a/components/profile/validations.ts +++ b/components/profile/validations.ts @@ -2,7 +2,7 @@ import { ProfileEditParams } from '@/utils/types/profile' import { MAX_DISPLAY_NAME_LENGTH, MAX_BIO_LENGTH } from './constants' import { EMAIL_VALID_REGEX } from '@/utils/validate' import { isAddress } from 'viem' -import { AddressFragment } from '@/utils/types/location' +import { AddressFragmentType } from '@/utils/types/location' export const validateProfileInput = ( editProfileInput: ProfileEditParams['data'] @@ -25,7 +25,7 @@ export const isValidName = (name: ConditionalString) => { } export const isValidAddress = ( - address: AddressFragment | null | undefined + address: AddressFragmentType | null | undefined ): boolean => { return !!(address && address.admininstrativeAreaLevel1) } diff --git a/pages/_app.tsx b/pages/_app.tsx index bf5a2379..e435347c 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -17,6 +17,7 @@ import { Analytics } from '@vercel/analytics/react' import { BackendProvider } from '@/components/contexts/BackendContext' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { WagmiProvider } from '@privy-io/wagmi' +import { isProd } from '@/utils/dev' export default function App({ Component, pageProps }: AppProps) { const { handleLogin } = useAuth() @@ -40,7 +41,9 @@ export default function App({ Component, pageProps }: AppProps) { appearance: { theme: theme.colors.yellow100 as `#${string}`, accentColor: theme.colors.green800 as `#${string}`, - logo: `${appDomainWithProto}/images/cabin-auth.png`, + logo: isProd + ? `${appDomainWithProto}/images/cabin-auth.png` + : `${appDomainWithProto}/images/cabin-auth-dev.png`, showWalletLoginFirst: true, walletList: [ 'detected_wallets', diff --git a/pages/admin.tsx b/pages/admin.tsx index f9e8e7b5..39e99d20 100644 --- a/pages/admin.tsx +++ b/pages/admin.tsx @@ -19,7 +19,7 @@ import { import { ErrorModal } from '@/components/ErrorModal' import { useBackend } from '@/components/hooks/useBackend' import { useModal } from '@/components/hooks/useModal' -import { AddressFragment } from '@/utils/types/location' +import { AddressFragmentType } from '@/utils/types/location' import { formatShortAddress } from '@/lib/address' import { Button } from '@/components/core/Button' import { List } from '@/components/core/List' @@ -106,7 +106,7 @@ const Row = ({ profile }: { profile: ProfileListFragment }) => { const { post } = useBackend() const { showModal } = useModal() - const [address, setAddress] = useState({ + const [address, setAddress] = useState({ lat: null, lng: null, formattedAddress: formatShortAddress(profile.address), @@ -125,7 +125,7 @@ const Row = ({ profile }: { profile: ProfileListFragment }) => { const [submitting, setSubmitting] = useState(false) const [canShowAddressError, setCanShowAddressError] = useState(false) - const onAddressChange = (a: AddressFragment) => { + const onAddressChange = (a: AddressFragmentType) => { setCanShowAddressError(false) setAddress(a) } diff --git a/pages/api/v2/neighborhood/list.ts b/pages/api/v2/neighborhood/list.ts new file mode 100644 index 00000000..7aef8de3 --- /dev/null +++ b/pages/api/v2/neighborhood/list.ts @@ -0,0 +1,90 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import { withAuth } from '@/utils/api/withAuth' +import { prisma } from '@/lib/prisma' +import { Prisma } from '@prisma/client' +import { + NeighborhoodFragment, + NeighborhoodListParamsType, + NeighborhoodListParams, + NeighborhoodListResponse, + NeighborhoodQueryInclude, + NeighborhoodWithRelations, +} from '@/utils/types/neighborhood' +import { toErrorString } from '@/utils/api/error' + +export default withAuth(handler) + +async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method != 'GET') { + res.setHeader('Allow', ['GET']) + res.status(405).send({ error: 'Method not allowed' }) + return + } + + let params: NeighborhoodListParamsType + try { + params = NeighborhoodListParams.parse(req.query) + } catch (e) { + res.status(400).send({ error: toErrorString(e) }) + return + } + + const idsInOrder = await sortByDistancePrequery( + params.lat, + params.lng, + params.maxDistance + ) + + const query: Prisma.NeighborhoodFindManyArgs = { + where: { id: { in: idsInOrder } }, + include: NeighborhoodQueryInclude, + } + + const neighborhoods = await prisma.neighborhood.findMany(query) + + const sorted = neighborhoods.sort((a, b) => { + return idsInOrder.indexOf(a.id) - idsInOrder.indexOf(b.id) + }) + + res.status(200).send({ + neighborhoods: sorted.map((n) => + neighborhoodToFragment(n as NeighborhoodWithRelations) + ), + }) +} + +async function sortByDistancePrequery( + lat: number, + lng: number, + maxDistance?: number +) { + const rows = await prisma.$queryRaw<{ id: number; distance_in_km: number }[]>` + SELECT n.id, (6371 * 2 * ASIN(SQRT( + POWER(SIN((radians(n.lat) - radians(${lat})) / 2), 2) + + COS(radians(${lat})) * COS(radians(n.lat)) * + POWER(SIN((radians(n.lng) - radians(${lng})) / 2), 2) + ))) AS distance_in_km + FROM "Neighborhood" n + ORDER BY distance_in_km ASC, n.name ASC, n."createdAt" ASC + LIMIT 5 + ` + + return rows + .filter((row) => !maxDistance || row.distance_in_km <= maxDistance) + .reduce((ids: number[], row) => [...ids, row.id], []) +} + +function neighborhoodToFragment( + n: NeighborhoodWithRelations +): NeighborhoodFragment { + return { + createdAt: n.createdAt.toISOString(), + externId: n.externId, + name: n.name, + lat: n.lat, + lng: n.lng, + } +} diff --git a/pages/api/v2/profile/new.ts b/pages/api/v2/profile/new.ts index 9ccb9cfe..e3b6e5f9 100644 --- a/pages/api/v2/profile/new.ts +++ b/pages/api/v2/profile/new.ts @@ -2,25 +2,15 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { AuthData, requireAuth, withAuth } from '@/utils/api/withAuth' import { prisma } from '@/lib/prisma' import { Prisma } from '@prisma/client' -import { ProfileNewResponse } from '@/utils/types/profile' -import { AddressFragment } from '@/utils/types/location' +import { + ProfileNewParams, + ProfileNewParamsType, + ProfileNewResponse, +} from '@/utils/types/profile' import { createProfile } from '@/utils/profile' +import { toErrorString } from '@/utils/api/error' -export type ProfileNewParams = { - walletAddress: string - name: string - email: string - address: AddressFragment - avatar?: { - url: string - contractAddress?: string | null - title?: string | null - tokenId?: string | null - tokenUri?: string | null - network?: string | null - } - inviteExternId?: string -} +export default withAuth(handler) async function handler( req: NextApiRequest, @@ -34,7 +24,14 @@ async function handler( } const privyDID = requireAuth(req, res, opts) - const body = req.body as ProfileNewParams + + let body: ProfileNewParamsType + try { + body = ProfileNewParams.parse(req.body) + } catch (e) { + res.status(400).send({ error: toErrorString(e) }) + return + } let invite: Prisma.InviteGetPayload | null = null if (body.inviteExternId) { @@ -65,6 +62,7 @@ async function handler( walletAddress: body.walletAddress, name: body.name, email: body.email, + neighborhoodExternId: body.neighborhoodExternId, address: body.address, avatar: body.avatar, invite, @@ -72,5 +70,3 @@ async function handler( res.status(200).send({ externId: profile.externId }) } - -export default withAuth(handler) diff --git a/prisma/migrations/20240321185904_add_neighborhood_table/migration.sql b/prisma/migrations/20240321185904_add_neighborhood_table/migration.sql new file mode 100644 index 00000000..5f82077e --- /dev/null +++ b/prisma/migrations/20240321185904_add_neighborhood_table/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "neighborhoodId" INTEGER; + +-- CreateTable +CREATE TABLE "Neighborhood" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "externId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "lat" DOUBLE PRECISION NOT NULL, + "lng" DOUBLE PRECISION NOT NULL, + + CONSTRAINT "Neighborhood_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Neighborhood_externId_key" ON "Neighborhood"("externId"); + +-- AddForeignKey +ALTER TABLE "Profile" ADD CONSTRAINT "Profile_neighborhoodId_fkey" FOREIGN KEY ("neighborhoodId") REFERENCES "Neighborhood"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +insert into "Neighborhood" ("updatedAt", "externId", name, lat, lng) values + (now(), 'nh_Lc7wrvaEPQi5HkJa4dkz', 'Spy Pond (Arlington, MA)',42.4153925,-71.1564729), + (now(), 'nh_o2vvJ8GqssgwZkCgbsyR', 'Larkspur, CA', 37.9340915, -122.5352539), + (now(), 'nh_K1dMbVevuru4bjg6qTM2', 'North Boulder Park (Boulder, CO)',40.0149856,-105.2705456), + (now(), 'nh_qNTBU2qRCumbEDByjvkJ', 'Oakland, CA',37.8043514,-122.2711639), + (now(), 'nh_gGF8U2mUqabusg8HhjPi', 'Eden Forest Collective (Ojai, CA)',34.4480495,-119.242889), + (now(), 'nh_c8vEaJEJDnyKfbs5BADY', 'Curiosity Courtyard (Venice, CA)', 33.9850469, -118.4694832); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 35a9113f..9a48c75b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,6 +5,14 @@ generator client { // previewFeatures = ["postgresqlExtensions"] } +// npm i prisma-zod-generator +// generator zod { +// provider = "prisma-zod-generator" +// output = "../generated/zod" +// // isGenerateSelect = true +// // isGenerateInclude = true +// } + datasource db { provider = "postgresql" url = env("POSTGRES_URL") @@ -134,6 +142,9 @@ model Profile { // the invites that this profile has spawned invitees Invite[] @relation("Invitees") + neighborhood Neighborhood? @relation(fields: [neighborhoodId], references: [id]) + neighborhoodId Int? + roles Role[] locations Location[] // TODO: rename to managedLocations?? votes LocationVote[] // used to be called locationVotes @@ -244,6 +255,19 @@ enum RoleLevel { Custodian } +model Neighborhood { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + externId String @unique // for external-facing use, e.g. urls + + name String + lat Float + lng Float + + members Profile[] +} + model Location { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) diff --git a/public/images/cabin-auth-dev.png b/public/images/cabin-auth-dev.png new file mode 100644 index 00000000..a106ea5a Binary files /dev/null and b/public/images/cabin-auth-dev.png differ diff --git a/scripts/dev.ts b/scripts/dev.ts index 4dfd1455..8cfd74fe 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -2,8 +2,12 @@ import { prisma } from '../lib/prisma' import { getEthersAlchemyProvider } from '../lib/chains' import { CabinToken__factory } from '../generated/ethers' import { cabinTokenConfig } from '../lib/protocol-config' +import { randomId } from '@/utils/random' async function main() { + console.log(randomId('neighborhood')) + return + const provider = getEthersAlchemyProvider(cabinTokenConfig.networkName) const cabinTokenContract = CabinToken__factory.connect( diff --git a/utils/api/error.ts b/utils/api/error.ts new file mode 100644 index 00000000..bc1e104d --- /dev/null +++ b/utils/api/error.ts @@ -0,0 +1,15 @@ +import { ZodError } from 'zod' + +// another option is https://github.com/causaly/zod-validation-error + +export function toErrorString(e: unknown) { + if (e instanceof ZodError) { + const s: string[] = [] + for (const [k, v] of Object.entries(e.flatten().fieldErrors)) { + s.push(`${k}: ${v?.join(', ')}`) + } + return s.join('; ') + } else { + return `${e}` + } +} diff --git a/utils/profile.ts b/utils/profile.ts index a7af1bcb..d1082807 100644 --- a/utils/profile.ts +++ b/utils/profile.ts @@ -8,7 +8,7 @@ import { ActivityType, CitizenshipStatus, } from '@prisma/client' -import { AddressFragment } from '@/utils/types/location' +import { AddressFragmentType } from '@/utils/types/location' import { randomId, randomInviteCode } from '@/utils/random' import { getRoleInfoFromHat } from '@/lib/hats/hats-utils' import { unlockConfig } from '@/lib/protocol-config' @@ -26,7 +26,8 @@ type ProfileCreateParams = { walletAddress: string name: string email: string - address?: AddressFragment + neighborhoodExternId?: string + address?: AddressFragmentType avatar?: { url: string contractAddress?: string | null @@ -83,6 +84,9 @@ export async function createProfile( }, }, }, + neighborhood: params.neighborhoodExternId + ? { connect: { externId: params.neighborhoodExternId } } + : undefined, address: params.address ? { create: params.address, diff --git a/utils/random.ts b/utils/random.ts index b9617c5a..f587fb08 100644 --- a/utils/random.ts +++ b/utils/random.ts @@ -7,6 +7,7 @@ enum Prefixes { experience = 'ex', invite = 'in', cart = 'ct', + neighborhood = 'nh', } type PrefixType = keyof typeof Prefixes diff --git a/utils/routes.ts b/utils/routes.ts index ca5ed8ea..7286837b 100644 --- a/utils/routes.ts +++ b/utils/routes.ts @@ -11,6 +11,8 @@ enum Routes { PROFILE_SETUP_STATE = `/profile/setup-state`, // todo: might roll into profile edit PROFILE_SIGNAL_INTEREST = `/profile/signal-interest`, + NEIGHBORHOOD_LIST = `/neighborhood/list`, + LOCATION = `/location/[externId]`, LOCATION_NEW = `/location/new`, LOCATION_LIST = `/location/list`, diff --git a/utils/types/activity.ts b/utils/types/activity.ts index 560e5eba..ef035fbb 100644 --- a/utils/types/activity.ts +++ b/utils/types/activity.ts @@ -5,7 +5,10 @@ import { CitizenshipStatus, RoleFragment, } from '@/utils/types/profile' -import { LocationFragment, ShortAddressFragment } from '@/utils/types/location' +import { + LocationFragment, + ShortAddressFragmentType, +} from '@/utils/types/location' import { OfferFragment } from '@/utils/types/offer' import { Prisma } from '@prisma/client' import { APIError, Paginated } from '@/utils/types/shared' @@ -40,7 +43,7 @@ export type ActivityListFragment = { | 'sleepCapacity' | 'offerCount' | 'voteCount' - > & { address: ShortAddressFragment } + > & { address: ShortAddressFragmentType } offer?: Pick< OfferFragment, | 'externId' diff --git a/utils/types/location.ts b/utils/types/location.ts index 7e264b31..45a41682 100644 --- a/utils/types/location.ts +++ b/utils/types/location.ts @@ -1,9 +1,14 @@ // need these types in a separate file because prisma cant be imported in the frontend import { Prisma } from '@prisma/client' -import { AvatarFragment, ProfileBasicFragment } from '@/utils/types/profile' +import { + AvatarFragmentType, + ProfileBasicFragment, + ProfileNewParams, +} from '@/utils/types/profile' import { OfferType } from '@/utils/types/offer' import { APIError, Paginated } from '@/utils/types/shared' +import { z } from 'zod' // must match prisma's $Enums.LocationType export enum LocationType { @@ -23,29 +28,31 @@ export enum LocationSort { votesDesc = 'votesDesc', } -export type AddressFragment = { - lat: number | null - lng?: number | null - formattedAddress: string | null - streetNumber: string | null - route: string | null - routeShort: string | null - locality: string | null - admininstrativeAreaLevel1: string | null - admininstrativeAreaLevel1Short: string | null - country: string | null - countryShort: string | null - postalCode: string | null -} - -export type ShortAddressFragment = Pick< - AddressFragment, +export const AddressFragment = z.object({ + lat: z.number().nullable(), + lng: z.number().nullable(), + formattedAddress: z.string().nullable(), + streetNumber: z.string().nullable(), + route: z.string().nullable(), + routeShort: z.string().nullable(), + locality: z.string().nullable(), + admininstrativeAreaLevel1: z.string().nullable(), + admininstrativeAreaLevel1Short: z.string().nullable(), + country: z.string().nullable(), + countryShort: z.string().nullable(), + postalCode: z.string().nullable(), +}) + +export type AddressFragmentType = z.infer + +export type ShortAddressFragmentType = Pick< + AddressFragmentType, 'locality' | 'admininstrativeAreaLevel1Short' | 'country' | 'countryShort' > export type RecentVoterFragment = { externId: string - avatar: AvatarFragment + avatar: AvatarFragmentType } export type LocationFragment = { @@ -55,7 +62,7 @@ export type LocationFragment = { name: string tagline: string description: string - address: AddressFragment | null + address: AddressFragmentType | null bannerImageIpfsHash: string sleepCapacity: number internetSpeedMbps: number @@ -106,7 +113,7 @@ export type LocationEditParams = { name?: string tagline?: string description?: string - address?: AddressFragment | null + address?: AddressFragmentType | null sleepCapacity?: number internetSpeedMbps?: number caretakerEmail?: string | null diff --git a/utils/types/neighborhood.ts b/utils/types/neighborhood.ts new file mode 100644 index 00000000..26efae63 --- /dev/null +++ b/utils/types/neighborhood.ts @@ -0,0 +1,36 @@ +// need these types in a separate file because prisma cant be imported in the frontend + +import { Prisma } from '@prisma/client' +import { z } from 'zod' +import { APIError } from '@/utils/types/shared' + +export type NeighborhoodFragment = { + createdAt: string + externId: string + name: string + lat: number + lng: number +} + +export const NeighborhoodListParams = z.object({ + lat: z.string().transform((x) => parseFloat(x)), + lng: z.string().transform((x) => parseFloat(x)), + maxDistance: z + .string({ description: 'max distance in km' }) + .optional() + .transform((x) => (x ? parseInt(x) : undefined)), +}) + +export type NeighborhoodListParamsType = z.infer + +export type NeighborhoodListResponse = + | { + neighborhoods: NeighborhoodFragment[] + } + | APIError + +// must match NeighborhoodQueryInclude below +export type NeighborhoodWithRelations = Prisma.NeighborhoodGetPayload<{}> + +// must match NeighborhoodWithRelations type above +export const NeighborhoodQueryInclude = {} satisfies Prisma.NeighborhoodInclude diff --git a/utils/types/offer.ts b/utils/types/offer.ts index 622d1db5..97c14c43 100644 --- a/utils/types/offer.ts +++ b/utils/types/offer.ts @@ -1,6 +1,6 @@ // need these types in a separate file because prisma cant be imported in the frontend -import { LocationType, ShortAddressFragment } from '@/utils/types/location' +import { LocationType, ShortAddressFragmentType } from '@/utils/types/location' import { Prisma } from '@prisma/client' import { APIError, Paginated } from '@/utils/types/shared' @@ -48,7 +48,7 @@ export type OfferFragment = { type: LocationType bannerImageIpfsHash: string publishedAt: string | null - address: ShortAddressFragment | null + address: ShortAddressFragmentType | null caretaker: { externId: string } diff --git a/utils/types/profile.ts b/utils/types/profile.ts index 73ccf8c1..1dd4bf3b 100644 --- a/utils/types/profile.ts +++ b/utils/types/profile.ts @@ -1,6 +1,11 @@ // need these types in a separate file because prisma cant be imported in the frontend import { APIError, Paginated } from '@/utils/types/shared' -import { AddressFragment, ShortAddressFragment } from '@/utils/types/location' +import { z } from 'zod' +import { + AddressFragment, + AddressFragmentType, + ShortAddressFragmentType, +} from '@/utils/types/location' // must match prisma's $Enums.RoleType export enum RoleType { @@ -55,14 +60,14 @@ export type ProfileListFragment = { email: string bio: string location: string - address: ShortAddressFragment | null + address: ShortAddressFragmentType | null isAdmin: boolean mailingListOptIn: boolean | null voucherId: number | null citizenshipStatus: CitizenshipStatus | null citizenshipTokenId: number | null citizenshipMintedAt: string | null - avatar?: AvatarFragment + avatar?: AvatarFragmentType roles: RoleFragment[] badgeCount: number cabinTokenBalanceInt: number @@ -122,13 +127,13 @@ export type ProfileBasicFragment = { bio: string citizenshipStatus: CitizenshipStatus | null cabinTokenBalanceInt: number - avatar?: AvatarFragment + avatar?: AvatarFragmentType roles: RoleFragment[] } export type ProfileFragment = ProfileBasicFragment & { privyDID: string - address: ShortAddressFragment | undefined + address: ShortAddressFragmentType | undefined citizenshipTokenId: number | null citizenshipMintedAt: string | null wallet: { @@ -163,39 +168,16 @@ export type BadgeFragment = { } } -export type AvatarFragment = { - url: string - contractAddress?: string | null - network?: string | null - title?: string | null - tokenId?: string | null - tokenUri?: string | null -} - -export type CaretakerFragment = ProfileBasicFragment -/* -fragment Caretaker on Profile { - _id - email - name - avatar { - url - } - citizenshipStatus - cabinTokenBalanceInt - account { - address - } - createdAt - roles { - role - level - } - bio - badgeCount -} +export const AvatarFragment = z.object({ + url: z.string(), + contractAddress: z.string().nullish(), + title: z.string().nullish(), + tokenId: z.string().nullish(), + tokenUri: z.string().nullish(), + network: z.string().nullish(), +}) - */ +export type AvatarFragmentType = z.infer export type ProfileMeResponse = { me?: MeFragment | null @@ -211,7 +193,7 @@ export type MeFragment = { name: string email: string bio: string - address: AddressFragment | undefined + address: AddressFragmentType | undefined inviteCode: string citizenshipStatus: CitizenshipStatus citizenshipTokenId: number | null @@ -221,7 +203,7 @@ export type MeFragment = { isProfileSetupFinished: boolean isProfileSetupDismissed: boolean mailingListOptIn: boolean | null - avatar: AvatarFragment + avatar: AvatarFragmentType walletAddress: string voucher: { externId: string @@ -233,14 +215,26 @@ export type MeFragment = { locationCount: number } +export const ProfileNewParams = z.object({ + walletAddress: z.string(), + name: z.string(), + email: z.string(), + address: AddressFragment, + avatar: AvatarFragment.optional(), + inviteExternId: z.string().optional(), + neighborhoodExternId: z.string().optional(), +}) + +export type ProfileNewParamsType = z.infer + export type ProfileEditParams = { data: { name?: string email?: string bio?: string - address?: AddressFragment + address?: AddressFragmentType contactFields?: ContactFragment[] - avatar?: AvatarFragment + avatar?: AvatarFragmentType } roleTypes?: RoleType[] }