Skip to content

Instantly share code, notes, and snippets.

@maietta
Created November 18, 2024 21:24
Show Gist options
  • Save maietta/11dd7cf2c6ca7acb086852fb92aaf9ba to your computer and use it in GitHub Desktop.
Save maietta/11dd7cf2c6ca7acb086852fb92aaf9ba to your computer and use it in GitHub Desktop.
Nearly entire reactive Search Form for Real Estate websites
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',
});
};
<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}
<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>
// 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