We are launching v2: only $99 during beta phase.

DirectoryStack Logo Light Mode

You are currently viewing the v2 documentation.
You can view the v1 documentation here.

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:

  1. Database Migration - Add new columns to the listings table
  2. Queries & Types - Update database queries and TypeScript types
  3. Form Schema - Update the listing editor form validation
  4. Listing Editor - Add form fields for editing
  5. Public Display - Show the fields on the public listing page
  6. 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>
	)
}
  1. Run the migration to add the database columns
  2. Test the listing editor - ensure new fields appear and save correctly
  3. Test the public listing page - verify new fields display properly
  4. Test filtering - ensure the wheelchair accessible filter works
  5. 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.

Interested in kickstarting your directory business?

Get the latest news, guides and changelog updates straight to your inbox.

By signing up, you agree to our Privacy Policy