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[]
}