Customizations
This guide walks you through adding custom fields to the listing type. We'll use three examples:
- address - A plain text field
- opening_hours - A JSON field containing opening hours data
- is_wheelchair_accessible - A boolean field (with filtering capability)
Adding custom fields to listings requires changes in several places:
- Database Migration - Add new columns to the listings table
- Queries & Types - Update database queries and TypeScript types
- Form Schema - Update the listing editor form validation
- Listing Editor - Add form fields for editing
- Public Display - Show the fields on the public listing page
- Filtering (for boolean fields) - Add filter options
Create a new migration file in app/__core/listings/src/db/migrations/
, like
-- ---
-- MODULE NAME: Listings Custom Fields
-- MODULE DATE: 20241215
-- MODULE SCOPE: Tables
-- ---
BEGIN;
-- ---
-- Add Custom Fields to Listings Table
-- ---
-- Add address field (plain text)
ALTER TABLE public.listings
ADD COLUMN address TEXT;
-- Add opening_hours field (JSON)
ALTER TABLE public.listings
ADD COLUMN opening_hours JSONB;
-- Add is_wheelchair_accessible field (boolean)
ALTER TABLE public.listings
ADD COLUMN is_wheelchair_accessible BOOLEAN DEFAULT FALSE;
-- ---
-- END OF FILE
-- ---
COMMIT;
Go to https://supabase.com/dashboard/project/_/sql
, choose your project and copy and paste the migration file you created. Alternatively, you can also add the fields directly to the listings table through the Table Editor
within supabase.
In your terminal run pnpm typegen
to get the latest types in your local project.
Go to app/__core/listings/src/db/queries.ts
Add the new fields to the FULL_LISTING_PARAMS
constant:
export const FULL_LISTING_PARAMS = `
/*
Other params
*/
// Add new custom params
address,
opening_hours,
is_wheelchair_accessible,
`
Go to app/__core/listings/src/types/types.ts
Add the new fields to the ListingFormSchema
:
// Define the opening hours structure
export const OpeningHoursSchema = z
.object({
monday: z
.object({
open: z.string().optional(),
close: z.string().optional(),
closed: z.boolean().default(false)
})
.optional(),
tuesday: z
.object({
open: z.string().optional(),
close: z.string().optional(),
closed: z.boolean().default(false)
})
.optional(),
wednesday: z
.object({
open: z.string().optional(),
close: z.string().optional(),
closed: z.boolean().default(false)
})
.optional(),
thursday: z
.object({
open: z.string().optional(),
close: z.string().optional(),
closed: z.boolean().default(false)
})
.optional(),
friday: z
.object({
open: z.string().optional(),
close: z.string().optional(),
closed: z.boolean().default(false)
})
.optional(),
saturday: z
.object({
open: z.string().optional(),
close: z.string().optional(),
closed: z.boolean().default(false)
})
.optional(),
sunday: z
.object({
open: z.string().optional(),
close: z.string().optional(),
closed: z.boolean().default(false)
})
.optional()
})
.optional()
export type OpeningHoursType = z.infer<typeof OpeningHoursSchema>
export const ListingFormSchema = z.object({
/*
Other fields
*/
// Add new custom fields
address: z.string().optional(),
opening_hours: OpeningHoursSchema,
is_wheelchair_accessible: z.boolean().optional()
})
Go to app/__core/listings/src/components/listing-editor.tsx
Add the new fields to the form's defaultValues
:
const form = useForm<ListingFormSchemaType>({
resolver: zodResolver(ListingFormSchema),
defaultValues: {
/*
Other fields
*/
// Add new custom fields
address: listing?.address ?? "",
opening_hours: listing?.opening_hours ?? undefined,
is_wheelchair_accessible: listing?.is_wheelchair_accessible ?? false
}
})
Add the form fields in the appropriate section (e.g., after the "Listing Details" section):
{
/* Add this new section after section-listing-details */
}
;<div className="space-y-4">
<div className="mb-4">
<h3 className="text-xl font-bold">Business Information</h3>
<p className="text-sm font-medium">
Additional information about the business location and hours.
</p>
</div>
<Separator />
<div className="space-y-8">
{/* Address Field */}
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<FormLabel>Address</FormLabel>
<FormDescription>
The physical address of the business
</FormDescription>
</div>
<FormControl>
<Input
placeholder="123 Main St, City, State 12345"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Opening Hours Field */}
<FormField
control={form.control}
name="opening_hours"
render={({ field }) => (
<FormItem className="grid grid-cols-1 gap-4 sm:grid-cols-1">
<div>
<FormLabel>Opening Hours</FormLabel>
<FormDescription>
Business operating hours for each day of the week
</FormDescription>
</div>
<FormControl>
<OpeningHoursEditor value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Wheelchair Accessible Field */}
<FormField
control={form.control}
name="is_wheelchair_accessible"
render={({ field }) => (
<FormItem className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<FormLabel>Wheelchair Accessible</FormLabel>
<FormDescription>
Is this location wheelchair accessible?
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
Create app/__core/listings/src/components/listing-editor/opening-hours-editor.tsx
:
"use client"
// Import External Packages
import { useState } from "react"
// Import Local Imports
import { OpeningHoursType } from "../../types/types"
// Import Core Dependencies
// Import Shared Dependencies
import { Input } from "@shared/ui/input"
import { Label } from "@shared/ui/label"
import { Switch } from "@shared/ui/switch"
import { Card, CardContent, CardHeader, CardTitle } from "@shared/ui/card"
// Import Extension Dependencies
const DAYS = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
] as const
export function OpeningHoursEditor({
value,
onChange
}: {
value?: OpeningHoursType
onChange: (value: OpeningHoursType) => void
}) {
const [hours, setHours] = useState<OpeningHoursType>(value ?? {})
const updateDay = (day: (typeof DAYS)[number], dayData: any) => {
const newHours = {
...hours,
[day]: dayData
}
setHours(newHours)
onChange(newHours)
}
return (
<Card>
<CardHeader>
<CardTitle>Opening Hours</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{DAYS.map((day) => (
<div
key={day}
className="flex items-center justify-between space-x-4"
>
<Label className="w-20 capitalize">{day}</Label>
<div className="flex items-center space-x-2">
<Switch
checked={!hours[day]?.closed}
onCheckedChange={(checked) => {
updateDay(day, {
...hours[day],
closed: !checked,
open: checked ? hours[day]?.open || "09:00" : undefined,
close: checked ? hours[day]?.close || "17:00" : undefined
})
}}
/>
{!hours[day]?.closed && (
<>
<Input
type="time"
value={hours[day]?.open || "09:00"}
onChange={(e) =>
updateDay(day, {
...hours[day],
open: e.target.value
})
}
className="w-32"
/>
<span>to</span>
<Input
type="time"
value={hours[day]?.close || "17:00"}
onChange={(e) =>
updateDay(day, {
...hours[day],
close: e.target.value
})
}
className="w-32"
/>
</>
)}
{hours[day]?.closed && (
<span className="text-muted-foreground">Closed</span>
)}
</div>
</div>
))}
</CardContent>
</Card>
)
}
Go to app/__core/listings/src/pages/public-listing-page.tsx
Add the custom fields display in the listing information card:
<CardContent className="space-y-4">
<ListingPageGroupedTagTable listing={listing} />
{/* Add custom fields here */}
{listing.address && (
<div className="flex flex-wrap justify-between">
<div className="text-base font-semibold">Address</div>
<div className="text-base text-right">{listing.address}</div>
</div>
)}
{listing.opening_hours && (
<div className="space-y-2">
<div className="text-base font-semibold">Opening Hours</div>
<OpeningHoursDisplay hours={listing.opening_hours} />
</div>
)}
{listing.is_wheelchair_accessible && (
<div className="flex flex-wrap justify-between">
<div className="text-base font-semibold">Accessibility</div>
<div className="text-base">♿ Wheelchair Accessible</div>
</div>
)}
{/* Existing category and other fields... */}
</CardContent>
Create app/__core/listings/src/components/opening-hours-display.tsx
:
// Import External Packages
// Import Local Imports
import { OpeningHoursType } from "../types/types"
// Import Core Dependencies
// Import Shared Dependencies
// Import Extension Dependencies
const DAYS = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
] as const
export function OpeningHoursDisplay({ hours }: { hours: OpeningHoursType }) {
if (!hours) return null
const formatTime = (time: string) => {
const [hour, minute] = time.split(":")
const hourNum = parseInt(hour)
const ampm = hourNum >= 12 ? "PM" : "AM"
const displayHour = hourNum % 12 || 12
return `${displayHour}:${minute} ${ampm}`
}
return (
<div className="space-y-1 text-sm">
{DAYS.map((day) => {
const dayData = hours[day]
if (!dayData) return null
return (
<div key={day} className="flex justify-between">
<span className="capitalize font-medium">{day}</span>
<span>
{dayData.closed
? "Closed"
: `${formatTime(dayData.open || "09:00")} - ${formatTime(
dayData.close || "17:00"
)}`}
</span>
</div>
)
})}
</div>
)
}
Go to app/__core/listings/src/components/listings-filter-sidebar.tsx
Add the accessibility filter in the sidebar:
{
/* Add this new section after the search section */
}
;<div className="relative flex w-full flex-col items-start overflow-visible border-y py-4 group-last:border-b-0">
<div className="flex w-full items-end justify-between">
<p className="py-2 text-sm font-semibold">Accessibility</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="wheelchair-accessible"
checked={searchParams.get("wheelchair_accessible") === "true"}
onCheckedChange={(checked) => {
const currentUrl = new URL(window.location.href)
const currentSearchParams = new URLSearchParams(currentUrl.search)
if (checked) {
currentSearchParams.set("wheelchair_accessible", "true")
} else {
currentSearchParams.delete("wheelchair_accessible")
}
const finalUrl = pathname + "?" + currentSearchParams.toString()
Router.push(finalUrl, { scroll: false })
}}
/>
<Label htmlFor="wheelchair-accessible">Wheelchair Accessible</Label>
</div>
</div>
Go to app/__core/listings/src/components/listing-overview-section.tsx
Add the wheelchair accessible filter parameter:
const wheelchairAccessibleFilter = filterAndSortParams?.wheelchair_accessible === 'true'
// Pass it to ListingGridDataWrapper
<ListingGridDataWrapper
/*
other fields
*/
// add new field
wheelchairAccessibleFilter={wheelchairAccessibleFilter}
/>
Update the ListingGridDataWrapper
function signature and logic:
async function ListingGridDataWrapper({
/*
other fields
*/
// add new field
wheelchairAccessibleFilter
}: {
/*
other fields
*/
// add new field
wheelchairAccessibleFilter?: boolean
}) {
// ... existing code ...
const regularSearchData = await getPublishedListings({
inputParams: {
/*
other fields
*/
// add new field
wheelchairAccessibleFilter
}
})
// ... rest of the function
}
Go to app/__core/listings/src/data/getPublishedListings.ts
Add the wheelchair accessible filter to the data fetching function:
interface InputParams {
/*
other fields
*/
// add new field
wheelchairAccessibleFilter?: boolean | null
}
export const getPublishedListings = cache(
async ({
inputParams,
contextParams
}: {
inputParams: InputParams
contextParams: DataFetchContext
}) => {
const {
/*
other fields
*/
// add new field
wheelchairAccessibleFilter
} = inputParams
// ... existing code ...
return withDataFetchErrorHandling("getPublishedListings", async () => {
const supabase = createSupabaseBrowserClient()
let query = supabase
.from(TABLE_NAME_LISTINGS)
.select(FULL_LISTING_PARAMS, { count: "exact" })
.match({
is_user_published: true,
is_admin_published: true
})
// ... existing filters ...
if (wheelchairAccessibleFilter) {
query = query.eq("is_wheelchair_accessible", true)
}
// ... rest of the function
})
}
)
Add translations for the new fields in app/__shared/i18n/src/locales/en.ts
(and other language files):
// Add these to the listings section
"listings": {
"listing-editor": {
"section-business-info": {
"title": "Business Information",
"description": "Additional information about the business location and hours",
"field": {
"address": {
"title": "Address",
"description": "The physical address of the business",
"placeholder": "123 Main St, City, State 12345"
},
"opening-hours": {
"title": "Opening Hours",
"description": "Business operating hours for each day of the week"
},
"wheelchair-accessible": {
"title": "Wheelchair Accessible",
"description": "Is this location wheelchair accessible?"
}
}
}
},
"public-listing-page": {
"address": "Address",
"opening-hours": "Opening Hours",
"accessibility": "Accessibility",
"wheelchair-accessible": "Wheelchair Accessible"
},
"listings-filter-sidebar": {
"accessibility": "Accessibility",
"wheelchair-accessible": "Wheelchair Accessible"
}
}
Go to app/__core/listings/src/components/listing-overview-section.tsx
Make sure the main ListingOverview
function passes the new filter parameter:
export async function ListingOverview({}: // ... existing parameters
{
// ... existing parameters
}) {
// ... existing code ...
const wheelchairAccessibleFilter =
filterAndSortParams?.wheelchair_accessible === "true"
return (
<SubSectionOuterContainer
id={stringToSlug(title)}
className={cn("w-full", className)}
>
<SubSectionInnerContainer className="mx-auto w-full max-w-5xl">
{categoryNavigation && <SubSectionTitle>{title}</SubSectionTitle>}
<SubSectionContentContainer className="mt-6">
<Suspense
fallback={
<ListingOverviewGridLoading limit={6} maxCols={maxCols} />
}
>
<ListingGridDataWrapper
limit={maxNumListings}
tagArray={tagArray}
sortBy={sortBy}
maxCols={maxCols}
categoryFilter={categoryFilter}
parentPathFilter={parentPathFilter}
searchQuery={searchFilter}
wheelchairAccessibleFilter={wheelchairAccessibleFilter}
showPagination={showPagination}
itemsPerPage={itemsPerPage || maxCols * 3}
showSearch={showSearch}
allowDynamicData={allowDynamicData}
range={range}
rootPathListings={rootPathListings}
/>
</Suspense>
{/* ... rest of component */}
</SubSectionContentContainer>
</SubSectionInnerContainer>
</SubSectionOuterContainer>
)
}
- Run the migration to add the database columns
- Test the listing editor - ensure new fields appear and save correctly
- Test the public listing page - verify new fields display properly
- Test filtering - ensure the wheelchair accessible filter works
- Test form validation - ensure the Zod schema validates correctly
- JSON Fields: The
opening_hours
field uses JSONB for efficient querying. You can add indexes on specific JSON paths if needed. - Filtering: Boolean fields are easy to filter. For text fields like
address
, you might want to add full-text search or geographic filtering. - Performance: Consider adding database indexes for frequently filtered fields.
- Validation: The Zod schema ensures data integrity. Adjust validation rules as needed.
- Caching: The data fetching functions use Next.js caching. Make sure to revalidate caches when data changes.
This guide provides a complete foundation for adding any type of custom field to your listings. Adapt the patterns for your specific field types and requirements.