Created
November 18, 2024 21:24
-
-
Save maietta/11dd7cf2c6ca7acb086852fb92aaf9ba to your computer and use it in GitHub Desktop.
Nearly entire reactive Search Form for Real Estate websites
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { PageServerLoad } from './$types'; | |
import pb from '$lib/pocketbase'; | |
import { propertyClassMap } from './guards'; // Import the source of truth | |
export const load: PageServerLoad = async ({ url, depends, params }) => { | |
depends('app:search'); // Revalidate on every request to this tag. | |
const slugs = params.slugs?.replace(/^\/|\/$/g, '').split('/') || []; | |
const [classSlug, typeSlug] = slugs; | |
if (classSlug) url.searchParams.set('Class', classSlug); | |
if (typeSlug) url.searchParams.set('Type', typeSlug); | |
try { | |
// Generate filters and fetch data | |
const searchParams = Object.fromEntries(url.searchParams.entries()); | |
const filters = generateFilters(searchParams); | |
const resultList = await fetchData(filters, searchParams); | |
return { | |
records: resultList.items, // PocketBase API always returns an array. | |
filters, | |
pagination: { | |
page: resultList.page, | |
perPage: resultList.perPage, | |
totalItems: resultList.totalItems, | |
totalPages: resultList.totalPages, | |
}, | |
}; | |
} catch (error: any) { | |
console.error('Error processing load request:', error); | |
return { | |
status: error.status || 500, | |
body: { error: error.message || 'Internal Server Error' }, | |
}; | |
} | |
}; | |
// Utility function to generate filters for the PocketBase query | |
const generateFilters = (params: Record<string, string | undefined>): string => { | |
const filters: string[] = [`status = 'ACTIVE'`]; // Base filter | |
// Handle 'Class' | |
if (params.Class) { | |
const classEntry = propertyClassMap[params.Class]; | |
if (!classEntry) throw new Error(`Unknown class slug: ${params.Class}`); | |
filters.push(`class = "${classEntry.class}"`); | |
} | |
// Handle 'Type' | |
if (params.Type && params.Class) { | |
const typeEntry = propertyClassMap[params.Class]?.types?.find( | |
(type) => type.slug === params.Type | |
); | |
if (!typeEntry) throw new Error(`Unknown type slug: ${params.Type}`); | |
filters.push(`data.L_Type_ ~ "${typeEntry.database}"`); | |
} | |
// Handle 'Area' | |
if (params.Area) { | |
filters.push(`data.L_Area ~ "${params.Area}"`); | |
} | |
return filters.join(' && '); | |
}; | |
// Fetch data from the PocketBase collection | |
const fetchData = async (filters: string, pagination: Record<string, string | undefined>) => { | |
const defaultPage = 1; | |
const defaultPerPage = 20; | |
const page = Number(pagination.page) || defaultPage; | |
const perPage = Number(pagination.perPage) || defaultPerPage; | |
// Validate pagination values | |
if (page <= 0 || perPage <= 0) { | |
throw { status: 400, message: 'Invalid pagination values.' }; | |
} | |
return await pb.collection('listings').getList(page, perPage, { | |
filter: filters, | |
fields: 'mlsid,data.L_AskingPrice,data.L_Area,data.L_Description', | |
sort: '+data.L_AskingPrice', | |
}); | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script lang="ts"> | |
import { goto } from '$app/navigation'; | |
import { page } from '$app/stores'; | |
import { onMount } from 'svelte'; | |
import { queryParameters, ssp } from 'sveltekit-search-params'; | |
import Pagination from './pagination.svelte'; | |
import SearchForm from './form.svelte'; | |
import { propertyClassMap } from './guards'; | |
let { records, ...props } = $props(); | |
let scope = $page.params.scope; | |
let pagination = $state({ page: 1, pageSize: 15 }); | |
let system = $state({ Class: '', Type: '' }); | |
let form = queryParameters( | |
{ | |
Area: { | |
encode: (value) => (value && value.trim() ? value : undefined), | |
decode: (value) => (value && value.trim() ? value : undefined) | |
}, | |
Bedrooms: { | |
encode: (value) => (value ? String(value) : undefined), // Convert to string for the URL | |
decode: (value) => (value ? parseInt(value, 10) : undefined) // Parse back as a number | |
}, | |
Bathrooms: { | |
encode: (value) => (value ? String(value) : undefined), // Convert to string for the URL | |
decode: (value) => (value ? parseInt(value, 10) : undefined) // Parse back as a number | |
}, | |
AskingPriceMin: ssp.number(0), | |
AskingPriceMax: ssp.number(9999999), | |
page: ssp.number(1) | |
// pageSize: ssp.number(15) | |
}, | |
{ | |
pushHistory: false, // Enables updating the browser history | |
showDefaults: false, // Don't include default values in the query string | |
debounceHistory: 500, | |
sort: false | |
} | |
); | |
// Initialize values on mount | |
onMount(() => { | |
const [classSegment, typeSegment] = $page.params.slugs.split('/').filter(Boolean); | |
system.Class = classSegment || ''; | |
system.Type = typeSegment || ''; | |
const urlSearchParams = new URLSearchParams($page.url.searchParams); | |
for (const [key, value] of urlSearchParams.entries()) { | |
form[key] = value; | |
} | |
}); | |
let previousUrl = ''; | |
$effect(() => { | |
const url = rebuildURL(); | |
if (url !== previousUrl) { | |
previousUrl = url; | |
goto(url, { replaceState: true }); | |
} | |
}); | |
function rebuildURL() { | |
let url = '/' + scope; | |
if (system.Class) { | |
url += `/${encodeURIComponent(system.Class)}`; | |
if (system.Type) url += `/${encodeURIComponent(system.Type)}`; | |
} | |
const searchParams = new URLSearchParams($page.url.searchParams); | |
searchParams.set('page', '1'); | |
const validTypes = propertyClassMap[system.Class]?.types?.map((t) => t.slug) || []; | |
if (!validTypes.includes(system.Type)) { | |
system.Type = ''; | |
} | |
if (system.Class === 'land' && form.Acres) searchParams.set('acres', form.Acres); | |
if (system.Class !== 'residential') { | |
if (form.BedroomsMin) searchParams.set('bedrooms', form.BedroomsMin); | |
if (form.BathroomsMin) searchParams.set('bathrooms', form.BathroomsMin); | |
} | |
if (system.Class !== 'commercial' && form.PricePerSqFt) | |
searchParams.set('pricePerSqFt', form.PricePerSqFt); | |
// If any of the form fields are not in the property class, reset them. | |
// records.data.pagination.page = 1; // Hack to reset page to 1 in pagination component more quickly. | |
return `${url}?${searchParams.toString()}`; | |
} | |
</script> | |
<SearchForm {form} {system} /> | |
{#if props.data.records.length > 0} | |
<Pagination pagination={props.data.pagination} /> | |
{#each props.data.records as record} | |
<p>{JSON.stringify(record, null, 2)}</p> | |
{/each} | |
<Pagination pagination={props.data.pagination} /> | |
{:else} | |
<p>No results found. Broaden your search criteria.</p> | |
{/if} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script lang="ts"> | |
import { propertyClassMap } from './guards'; | |
import CurrencyInput from '@canutin/svelte-currency-input'; | |
let { form = $bindable({}), system = $bindable({}) } = $props(); | |
</script> | |
<form id="search" class="shadow-inset flex flex-col gap-4 rounded border bg-white p-4"> | |
<input type="reset" class="btn btn-primary border-2 bg-red-500 text-white" onclick={(e) => (e.target as HTMLFormElement).reset()} /> | |
<label for="Class"><span class="font-bold">Property Type: </span></label> | |
<select name="Class" id="Class" bind:value={system.Class} class="border border-red-300"> | |
<option value="">— choose a property type —</option> | |
{#each Object.keys(propertyClassMap) as propertyClass} | |
<option value={propertyClass}>{propertyClass}</option> | |
{/each} | |
</select> | |
{#if system.Class === 'residential'} | |
<label for="Type" | |
><span class="font-bold">Residence Type: </span> | |
<select name="Type" id="Type" bind:value={system.Type} class="w-full"> | |
<option value="">— any type of residential —</option><!-- Leave this here --> | |
{#each propertyClassMap[system.Class]?.types ?? [] as type} | |
<option value={type.slug}>{type.slug}</option> | |
{/each} | |
</select></label | |
> | |
<!-- Additional fields for Residential --> | |
<label for="bedrooms"><span class="font-bold">Bedrooms:</span></label> | |
<input | |
type="number" | |
bind:value={form.Bedrooms} | |
name="Bedrooms" | |
placeholder="Number of bedrooms" | |
/> | |
<label for="bathrooms"><span class="font-bold">Bathrooms:</span></label> | |
<input | |
type="number" | |
bind:value={form.Bathrooms} | |
name="Bathrooms" | |
placeholder="Number of bathrooms" | |
/> | |
<div class="w-full"> | |
<div class="flex flex-wrap gap-0"> | |
<div class="w-1/2 pr-1"> | |
<label for="BedroomsMin" class="font-medium">Bedrooms:</label> | |
<select name="BedroomsMin" class="w-full" bind:value={form.BedroomsMin}> | |
<option value="">Any</option> | |
<option value="1">1 or more</option> | |
<option value="2">2 or more</option> | |
<option value="3">3 or more</option> | |
<option value="4">4 or more</option> | |
<option value="5+">5+ or more</option> | |
</select> | |
</div> | |
<div class="w-1/2 pl-1"> | |
<label for="BathroomsMin" class="font-medium">Bathrooms:</label> | |
<select name="BathroomsMin" class="w-full" bind:value={form.BathroomsMin}> | |
<option value="">Any</option> | |
<option value="1">1 or more</option> | |
<option value="2">2 or more</option> | |
<option value="3">3 or more</option> | |
<option value="4">4 or more</option> | |
<option value="5+">5+ or more</option> | |
</select> | |
</div> | |
</div> | |
</div> | |
{/if} | |
{#if system.Class === 'commercial'} | |
<hr class="my-4" /> | |
<label for="Type" class="font-medium">Type of Commercial:</label> | |
<select bind:value={system.Type} name="Type" class="w-full"> | |
<option value="">— choose commercial type —</option> | |
<option value="building-land">Building and Land</option> | |
<option value="building-and-land-w-business">Building and Land w/Business</option> | |
<option value="business-only">Business Only</option> | |
<option value="land-only">Land Only (new field)</option> | |
</select> | |
<!-- Additional field for Commercial --> | |
<label for="PricePerSqFt"><span class="font-bold">Price per Square Foot:</span></label> | |
<input type="number" bind:value={form.PricePerSqFt} placeholder="Price per SqFt" /> | |
{/if} | |
{#if system.Class === 'land'} | |
<!-- Additional field for Land --> | |
<label for="acres"><span class="font-bold">Acres:</span></label> | |
<input type="number" bind:value={form.Acres} placeholder="Acres" /> | |
{/if} | |
<label for="Area" class="font-medium"><span class="font-medium">Location:</span></label> | |
<select name="Area" class="w-full" bind:value={form.Area}> | |
<option value="">Anywhere in Del Norte County</option> | |
<option value="Big Flat">Big Flat</option> | |
<option value="Crescent City">Crescent City</option> | |
<option value="Elk Valley">Elk Valley</option> | |
<option value="Fort Dick">Fort Dick</option> | |
<option value="Gasquet">Gasquet</option> | |
<option value="Hiouchi">Hiouchi</option> | |
<option value="Klamath">Klamath</option> | |
<option value="Klamath Glenn">Klamath Glenn</option> | |
<option value="Lake Earl">Lake Earl</option> | |
<option value="Orick">Orick</option> | |
<option value="Smith River">Smith River</option> | |
<option value="Other">Other</option> | |
</select> | |
<div class="border"> | |
<legend class="text-base font-medium text-green-900">Asking Price (dollar amount): </legend> | |
<div class="flex w-full"> | |
<div class="w-1/2 pr-1"> | |
<label for="AskingPriceMin" class="font-medium text-green-900">Min:</label> | |
<div class="currency-input"> | |
<CurrencyInput | |
name="AskingPriceMin" | |
bind:value={form.AskingPriceMin} | |
isNegativeAllowed={false} | |
fractionDigits={0} | |
placeholder={0} | |
locale="en-US" | |
currency="USD" | |
/> | |
</div> | |
</div> | |
<div class="w-1/2 pl-1"> | |
<label for="AskingPriceMax" class="font-medium text-green-900">Max:</label> | |
<div class="currency-input"> | |
<CurrencyInput | |
name="AskingPriceMax" | |
bind:value={form.AskingPriceMax} | |
isNegativeAllowed={false} | |
fractionDigits={0} | |
placeholder={9999999} | |
locale="en-US" | |
currency="USD" | |
/> | |
</div> | |
</div> | |
</div> | |
</div> | |
</form> | |
<style lang="postcss"> | |
div.currency-input :global(input.currencyInput__formatted) { | |
@apply w-full border border-gray-500; | |
} | |
</style> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// const propertyClassMap: Record< | |
// string, | |
// { types?: { value: string; slug: string; database?: string }[]; class: string; } | |
// > = { | |
// residential: { | |
// class: 'RE_1', | |
// types: [ | |
// { value: 'CND', slug: 'condominium', database: 'Condominium' }, | |
// { value: 'SF', slug: 'single-family', database: 'SINGLE FAMILY' }, | |
// { value: 'MOL', slug: 'manufactured-on-land', database: 'MANUFACT. ON LAND' }, | |
// { value: 'SFG', slug: 'single-family-w-guest', database: 'SGLE FAM W/GUEST' }, | |
// { value: 'MO', slug: 'mobile-only', database: 'MOBILE-ONLY' } | |
// ], | |
// /* Fields for Residential: | |
// Price Range (Min/Max) | |
// View AR Area | |
// Trees Trees | |
// Mount Mountain View | |
// OF Ocean Front | |
// OV Ocean View | |
// RF River Front | |
// RV River View | |
// PN Panoramic | |
// LF Lake Front | |
// LV Lake View | |
// Levels | |
// 1 1 Story | |
// 2 2 Story | |
// Split Split | |
// SGQ Separate Guest Quarters | |
// OT Other | |
// Garage Type | |
// AG Attached Garage | |
// DG Detached Garage | |
// AC Attached Carport | |
// DC Detached Carport | |
// None None | |
// SQFT: | |
// 0-1000 0 - 1000 | |
// 1001-1500 1001 - 1500 | |
// 1501-2000 1501 - 2000 | |
// 2001-3000 2001 - 3000 | |
// 3000-3999 3000 - 3999 | |
// 4000+ 4000+ | |
// */ | |
// }, | |
// commercial: { | |
// class: 'CI_3', | |
// types: [ | |
// { value: 'BLWB', slug: 'building-and-land-w-business', database: 'BUILDING AND LAND W/BUSNS' }, | |
// { value: 'BL', slug: 'building-and-land', database: 'BUILDING AND LAND' }, | |
// { value: 'BO', slug: 'business-only', database: 'BUILDING ONLY' }, | |
// { value: 'LO', slug: 'land-only', database: 'LAND ONLY' } | |
// ], | |
// }, | |
// land: { | |
// class: 'LD_2', | |
// types: [ | |
// { value: 'AG', slug: 'agricultural', database: 'AGRICULTURAL' }, | |
// { value: 'CI', slug: 'commercial-industrial', database: 'COMMERCIAL/INDUST' }, | |
// { value: 'OT', slug: 'other', database: 'OTHER' }, | |
// { value: 'RE', slug: 'residential', database: 'RESIDENTIAL' } | |
// ] | |
// }, | |
// 'multi-family': { | |
// class: 'MF_4', | |
// types: [ | |
// { value: 'CND', slug: 'condominium', database: 'Condominium' }, | |
// ] | |
// } | |
// }; | |
const propertyClassMap: Record< | |
string, | |
{ | |
types?: { value: string; slug: string; database?: string }[]; | |
class: string; | |
fields?: { label: string; name: string; type: string; options?: { value: string; label: string }[]; placeholder?: string }[]; | |
} | |
> = { | |
residential: { | |
class: 'RE_1', | |
types: [ | |
{ value: 'CND', slug: 'condominium', database: 'Condominium' }, | |
{ value: 'SF', slug: 'single-family', database: 'SINGLE FAMILY' }, | |
{ value: 'MOL', slug: 'manufactured-on-land', database: 'MANUFACT. ON LAND' }, | |
{ value: 'SFG', slug: 'single-family-w-guest', database: 'SGLE FAM W/GUEST' }, | |
{ value: 'MO', slug: 'mobile-only', database: 'MOBILE-ONLY' } | |
], | |
fields: [ | |
{ label: 'Price Range (Min)', name: 'PriceMin', type: 'number', placeholder: 'Min Price' }, | |
{ label: 'Price Range (Max)', name: 'PriceMax', type: 'number', placeholder: 'Max Price' }, | |
{ | |
label: 'View', name: 'View', type: 'select', options: [ | |
{ value: 'AR', label: 'Area' }, | |
{ value: 'Trees', label: 'Trees' }, | |
{ value: 'Mount', label: 'Mountain View' }, | |
{ value: 'OF', label: 'Ocean Front' }, | |
{ value: 'OV', label: 'Ocean View' }, | |
{ value: 'RF', label: 'River Front' }, | |
{ value: 'RV', label: 'River View' }, | |
{ value: 'PN', label: 'Panoramic' }, | |
{ value: 'LF', label: 'Lake Front' }, | |
{ value: 'LV', label: 'Lake View' } | |
] | |
}, | |
{ | |
label: 'Levels', name: 'Levels', type: 'select', options: [ | |
{ value: '1', label: '1 Story' }, | |
{ value: '2', label: '2 Story' }, | |
{ value: 'Split', label: 'Split' }, | |
{ value: 'SGQ', label: 'Separate Guest Quarters' }, | |
{ value: 'OT', label: 'Other' } | |
] | |
}, | |
{ | |
label: 'Garage Type', name: 'GarageType', type: 'select', options: [ | |
{ value: 'AG', label: 'Attached Garage' }, | |
{ value: 'DG', label: 'Detached Garage' }, | |
{ value: 'AC', label: 'Attached Carport' }, | |
{ value: 'DC', label: 'Detached Carport' }, | |
{ value: 'None', label: 'None' } | |
] | |
}, | |
{ | |
label: 'SQFT', name: 'SQFT', type: 'select', options: [ | |
{ value: '0-1000', label: '0 - 1000' }, | |
{ value: '1001-1500', label: '1001 - 1500' }, | |
{ value: '1501-2000', label: '1501 - 2000' }, | |
{ value: '2001-3000', label: '2001 - 3000' }, | |
{ value: '3000-3999', label: '3000 - 3999' }, | |
{ value: '4000+', label: '4000+' } | |
] | |
} | |
] | |
}, | |
commercial: { | |
class: 'CI_3', | |
types: [ | |
{ value: 'BLWB', slug: 'building-and-land-w-business', database: 'BUILDING AND LAND W/BUSNS' }, | |
{ value: 'BL', slug: 'building-and-land', database: 'BUILDING AND LAND' }, | |
{ value: 'BO', slug: 'business-only', database: 'BUILDING ONLY' }, | |
{ value: 'LO', slug: 'land-only', database: 'LAND ONLY' } | |
], | |
fields: [ | |
{ label: 'Price Per Square Foot', name: 'PricePerSqFt', type: 'number', placeholder: 'Price per SqFt' } | |
] | |
}, | |
land: { | |
class: 'LD_2', | |
types: [ | |
{ value: 'AG', slug: 'agricultural', database: 'AGRICULTURAL' }, | |
{ value: 'CI', slug: 'commercial-industrial', database: 'COMMERCIAL/INDUST' }, | |
{ value: 'OT', slug: 'other', database: 'OTHER' }, | |
{ value: 'RE', slug: 'residential', database: 'RESIDENTIAL' } | |
] | |
}, | |
'multi-family': { | |
class: 'MF_4', | |
types: [ | |
{ value: 'CND', slug: 'condominium', database: 'Condominium' } | |
] | |
} | |
}; | |
// Export the propertyClassMap | |
export { propertyClassMap }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment