Build a Map-First Property Marketplace
Build a Supabase property marketplace with synchronized React cards and maps.
Introduction
30 Second Summary
Property hunting becomes frustrating when a results list shows homes that seem to vanish from the map. Visitors need every view to agree before they can compare their options with confidence.
In this project, you will build a responsive property marketplace with React and TypeScript. One shared result set will connect relational listing data from Supabase to filters, cards, and an interactive OpenStreetMap view.
What You'll Build
Your finished marketplace lets you set a rent limit, watch the cards and map markers narrow to the same homes, then select a property that highlights in both views.
By the end of this project, you'll have:
- A database-backed marketplace that loads four fictional listings with their property, owner, and agent details. Each listing appears as a polished card in your browser.
- Responsive property filters that narrow the inventory by maximum rent, minimum bedrooms, or property type. The result count updates with every change.
- A synchronized card and map experience where visible markers always match the filtered cards. Selecting either representation highlights the same property.
- Secret Mission: Add an explainable recommendation score based only on each visitor's selected budget, bedroom count, and property type.
Are there any prerequisites?
You need a Windows computer with Visual Studio Code and Node.js installed. You also need an email address for a free Supabase account plus permission to update Node.js if its version is below 22.12.0.
Before We Start
This checkpoint defines who the marketplace serves before any hands-on work begins. It also captures why listing cards and map markers must show the same filtered inventory.
Set Up the Marketplace Workspace
Your marketplace needs compatible tools before it can load relational data or keep its views synchronized. A mismatched runtime can stop the development server before the first property appears.
You will establish a reproducible workspace with Node.js, Vite, React, and TypeScript. You will also connect a Supabase project through browser-safe environment variables.
In this step, get ready to:
- Verify that Node.js meets the required version.
- Scaffold the pinned frontend workspace.
- Create a Supabase project with browser-safe credentials.
Verify your Node.js version
Vite 8.2.2 requires Node.js 22.12.0 or newer. The version check in PowerShell shows whether your existing runtime is ready.
- Check your installed Node.js version by running this command:
node --version
What does this check prove?
The node --version command asks the installed runtime to report its version. Your result determines which setup path you follow.
✔️ I see version 22.12.0 or higher
Your Node.js runtime meets Vite's requirement. That compatibility check is complete.
ⓧ I see an older version
Updating Node.js changes your system runtime. Your existing project files remain untouched.
- Close the current PowerShell window.
- Download node-v24.20.0-x64.msi from the official Node.js directory.
- Run the downloaded Windows x64 MSI.
- Complete the installer with its default options.
A new PowerShell process is needed to detect the updated runtime.
- Press the Windows key.
- Type PowerShell.
- Press Enter.
- Verify the upgraded runtime by running this command:
node --version
What does this second check prove?
The new PowerShell process reads the updated Node.js installation. The reported version should now be 24.20.0.
ⓧ Command not found
PowerShell cannot currently find a Node.js installation. Installing the official Windows build adds the runtime to your computer.
- Download node-v24.20.0-x64.msi from the official Node.js directory.
- Run the downloaded Windows x64 MSI.
- Complete the installer with its default options.
PowerShell needs a fresh process before it can find the new command.
- Close the current PowerShell window.
- Press the Windows key.
- Type PowerShell.
- Press Enter.
- Verify the installation by running this command:
node --version
What does this installation check prove?
A version result confirms that PowerShell can find Node.js. The expected installed version is 24.20.0.
Still seeing the wrong version?
Close every PowerShell window before checking again. An older window can keep the previous system path.
If the older version remains active, confirm that the 24.20.0 Windows x64 MSI completed successfully.
Ask for help with my Node.js version check.
Scaffold the pinned frontend
The scaffold supplies the project structure that Vite uses to run your React interface. Pinned dependencies keep every learner on the same tested versions.
You will keep the property-marketplace folder on your Desktop. Visual Studio Code will use that folder as the workspace.
- Move PowerShell to your Desktop by running this command:
Set-Location -Path "$env:USERPROFILE\Desktop"
What does this command do?
PowerShell changes its current location to the Desktop inside your Windows user profile. The scaffolded folder is created there.
- Create the React TypeScript project by running this command:
npm create vite@9.2.0 property-marketplace -- --template react-ts
What does this scaffold create?
The command runs create-vite 9.2.0 with the react-ts template. It creates the property-marketplace folder with a React TypeScript starter.
If npm asks for permission to download the create-vite package, confirm the request. The scaffold then writes the project files.
You should see output confirming that the project was scaffolded. The new folder now exists on your Desktop.
Scaffold command failed?
Confirm that your Node.js result meets the minimum version. Check your internet connection before running the scaffold command again.
If a property-marketplace folder already exists from a failed attempt, remove that incomplete folder before retrying.
Ask for help with my Vite scaffold error.
- Move PowerShell into the new project folder by running this command:
Set-Location property-marketplace
What does this location change do?
PowerShell now points at the property-marketplace folder. Commands run from here can read its project files.
- Open the current project folder in Visual Studio Code by running this command:
- Choose Yes, I trust the authors if the Workspace Trust prompt appears.
code .
What should you see?
The dot tells Visual Studio Code to open the current PowerShell folder. The Explorer sidebar should show the generated workspace.
- The top level includes .gitignore, .oxlintrc.json, README.md, index.html, and package.json.
- The top level also includes tsconfig.app.json, tsconfig.json, tsconfig.node.json, and vite.config.ts.
- The public folder contains favicon.svg and icons.svg.
- The src folder contains the starter application files plus its assets folder.
Visual Studio Code did not open?
The Visual Studio Code installer normally adds the code command to your system path. Restart PowerShell before trying the command again.
Ask for help with opening my project from PowerShell.
- Select package.json in the Visual Studio Code Explorer.
- Replace its contents with the pinned manifest below:
{
"name": "property-marketplace",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "2.115.0",
"leaflet": "1.9.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-leaflet": "5.0.0"
},
"devDependencies": {
"@types/leaflet": "1.9.22",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.1.0",
"typescript": "6.0.2",
"vite": "8.2.2"
}
}
What does this manifest control?
- The scripts section defines development, production build, and preview tasks.
- The dependencies section pins the browser libraries used by the completed marketplace.
- The devDependencies section pins Vite, TypeScript, type definitions, and the React plugin.
- Save package.json.
The install may take a short while because npm downloads every pinned package. Continuous output in PowerShell means the installation is progressing.
- Install the pinned dependencies by running this command:
npm install
What does this installation do?
npm reads package.json and downloads the specified dependency versions. It also creates the local node_modules directory.
The installation is complete when PowerShell returns to its prompt without a dependency error.
Dependency installation failed?
Confirm that PowerShell is inside the property-marketplace folder. The same folder should contain package.json.
A JSON parsing error usually points to a missing quote or comma in package.json.
Ask for help with my pinned npm installation.
- Select index.html in the Visual Studio Code Explorer.
- Replace its contents with the marketplace entry point below:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="A synchronized property marketplace powered by Supabase and OpenStreetMap." />
<title>Property Atlas</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
What does this entry point define?
The root element gives React a place to render the application. The title sets the browser tab to Property Atlas.
The module script loads src/main.tsx as the frontend entry point.
- Save index.html.
Before you start the server, what browser title do you expect the updated entry point to display?
- Start the Vite development server by running this command:
npm run dev
What does the development server do?
The dev script starts Vite on http://localhost:5173. The process keeps control of this PowerShell window until you stop it.
- Press the Windows key.
- Type Microsoft Edge.
- Press Enter.
- Enter http://localhost:5173 in the address bar.
You should see the Vite starter page with Property Atlas in the browser tab. PowerShell should remain free of terminal errors.
- Stop the development server by pressing Ctrl+C in PowerShell.
Starter page not loading?
Confirm that PowerShell still shows the Vite process running. Use the exact http://localhost:5173 address.
If PowerShell reports a package error, rerun the dependency installation inside property-marketplace.
Ask for help with my Vite starter page.
Connect the Supabase project
The browser needs a backend address before it can request marketplace data. A Supabase Free project provides that hosted foundation.
The browser receives a publishable key with access controlled through Row Level Security. Secret keys stay outside this frontend project.
Creating this project stays on the Supabase Free Plan. Keep the organization on that plan to avoid adding paid services.
- Switch back to Microsoft Edge.
- Open the Supabase Dashboard.
- Create a Supabase account with your email address.
- Create one project named property-marketplace.
- Store the project database password in your password manager.
- Keep the organization on the Free Plan.
- Finish creating the project.
Project provisioning can hold on a loading screen briefly. Once the project dashboard loads, your hosted backend is ready.
That is the account setup complete. Your next task is to give the local app its browser-safe connection values.
- Return to PowerShell in the property-marketplace folder.
- Create and open .env.local by running this command:
code .env.local
What does this command create?
Visual Studio Code opens .env.local in the current project. It creates the file when it does not already exist.
- Paste this environment variable template into .env.local:
VITE_SUPABASE_URL=your-supabase-url-here
VITE_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key-here
What do these variables hold?
Vite exposes variables with the VITE_ prefix to browser code. The URL identifies your Supabase project.
The publishable key identifies the browser client. Your scaffold keeps local environment files outside version control.
Finding the two values can feel fiddly because they share one panel. Use the Project URL plus the publishable key from the project connection details.
- Return to the Supabase project dashboard.
- Open the Connect panel.
- Record the Project URL as your Supabase Project URL.
- Copy the publishable key to your clipboard.
- Switch back to .env.local in Visual Studio Code.
- Replace your-supabase-url-here with your Supabase Project URL.
- Replace your-supabase-publishable-key-here with the publishable key from your clipboard.
- Save .env.local.
Why use the publishable key?
The publishable key is intended for browser clients whose database access is protected by policies. A Supabase secret key bypasses Row Level Security.
Keep every secret key out of .env.local and all browser code. This project uses only the publishable key.
✔️ Awesome, I've got everything!
Your pinned project files are saved. Keep your real Supabase values inside .env.local.
ⓧ I'd like to double check the full code
Compare these workspace files with yours. The .env.local reference uses placeholders, so keep your real Project URL and publishable key.
{
"name": "property-marketplace",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "2.115.0",
"leaflet": "1.9.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-leaflet": "5.0.0"
},
"devDependencies": {
"@types/leaflet": "1.9.22",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.1.0",
"typescript": "6.0.2",
"vite": "8.2.2"
}
}
Manifest checkpoint
This manifest pins every runtime package plus every development dependency used by the project.
VITE_SUPABASE_URL=your-supabase-url-here
VITE_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key-here
Environment checkpoint
Your file uses the same two variable names. Your actual Supabase values replace the reference placeholders.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="A synchronized property marketplace powered by Supabase and OpenStreetMap." />
<title>Property Atlas</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
HTML checkpoint
This entry point contains the Property Atlas title plus the root element used by React.
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
TypeScript references checkpoint
This file links the browser configuration to the Node configuration.
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
Browser TypeScript checkpoint
This strict configuration checks the browser code inside src with DOM plus Vite types.
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
Node TypeScript checkpoint
This strict configuration checks vite.config.ts with Node types.
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
})
Vite configuration checkpoint
This configuration activates the React plugin for the Vite project.
Before you run the final check, do you expect Vite to start with the new local environment file in place?
- Start the configured workspace by running this command:
npm run dev
What does this final run verify?
The command reads the pinned manifest and starts Vite at http://localhost:5173. A clean start confirms that the setup files can be loaded.
- Switch back to Microsoft Edge from earlier.
- Refresh http://localhost:5173.
You should see the Vite starter page with Property Atlas in the browser tab. PowerShell should show no terminal errors.
Final workspace check failed?
Confirm that package.json contains the pinned manifest. Make sure the dependency installation completed in the same folder.
If Vite mentions an environment file problem, confirm that each .env.local variable occupies its own line.
Ask for help with my marketplace workspace setup.
Your marketplace workspace is ready to run with pinned tools and a browser-safe Supabase connection. Next, you will turn relational property records into the first visible listing cards.
Build Relational Listing Cards
Hardcoded cards repeat owner details across every listing. A relational model gives each owner or property one source of truth. Foreign keys connect those records without duplicating them.
In this step, you will create three connected tables in Supabase. You will protect their fictional records with Row Level Security. Your React app will turn the joined data into four listing cards.
In this step, get ready to:
- Create and seed the relational marketplace tables.
- Grant public read access through read-only RLS policies.
- Load four joined listings into the React interface.
Create and seed the relational schema
The SQL schema separates people, properties, and listings into connected tables. This structure lets one property reuse its owner record while each listing points to its assigned agent.
Protect Any Existing Data
Running this reset removes only the profiles, properties, and listings tables. Use this script only in the new Supabase project from the previous step.
- In the VS Code Explorer sidebar, create a supabase folder inside property-marketplace.
- Create schema.sql inside the new supabase folder.
- Add the profile and property table definitions to supabase/schema.sql by pasting this first chunk:
-- Demo reset: these statements remove only this project's three demo tables.
drop table if exists public.listings cascade;
drop table if exists public.properties cascade;
drop table if exists public.profiles cascade;
create table public.profiles (
id uuid primary key,
full_name text not null,
role text not null check (role in ('owner', 'agent'))
);
create table public.properties (
id uuid primary key,
owner_id uuid not null,
title text not null,
city text not null,
bedrooms integer not null check (bedrooms >= 0),
property_type text not null check (property_type in ('Apartment', 'Townhome', 'House')),
latitude double precision not null,
longitude double precision not null,
constraint properties_owner_id_fkey
foreign key (owner_id) references public.profiles(id)
);
How Do These Tables Connect?
- The profiles table stores fictional owners and agents once.
- The properties table stores the physical home details.
- The properties_owner_id_fkey constraint requires every property owner to exist in profiles.
- Save supabase/schema.sql.
- Return to the Supabase Dashboard from the previous step.
- Select the SQL Editor from the project sidebar.
- Start a blank query in the SQL Editor.
- Copy the first chunk from supabase/schema.sql.
- Paste the copied SQL into the blank query.
- Click Run.
- Confirm the query finishes successfully without a database error.
You now have separate tables for people and properties. The foreign key makes an owner record mandatory for every property.
Tables Not Created?
Check that the profile table appears before the property table in the query. The property foreign key depends on the profile table already existing.
If this still fails, help me debug the first schema query.
- Add the listing table beneath the property table in supabase/schema.sql by pasting this chunk:
create table public.listings (
id bigint primary key generated always as identity,
property_id uuid not null,
agent_id uuid not null,
monthly_rent integer not null check (monthly_rent > 0),
available_from date not null,
status text not null check (status in ('active', 'paused')),
constraint listings_property_id_fkey
foreign key (property_id) references public.properties(id),
constraint listings_agent_id_fkey
foreign key (agent_id) references public.profiles(id)
);
What Does the Listing Table Represent?
- Each listing points to one property through listings_property_id_fkey.
- Each listing points to one agent through listings_agent_id_fkey.
- The rent check prevents zero-value or negative monthly prices.
- The status check limits each listing to active or paused.
- Save supabase/schema.sql.
- Return to the SQL Editor query from earlier.
- Replace the query contents with the listing table chunk.
- Click Run.
- Select the Table Editor from the project sidebar.
- Confirm that profiles, properties, and listings are listed.
The database now has the complete relationship chain. A listing reaches its property, owner, and agent through explicit foreign keys.
Listing Table Failed?
Confirm that both earlier tables exist before running this chunk. A missing profiles or properties table prevents the listing foreign keys from being created.
If the tables exist, help me debug the listing table relationships.
- Add the fictional people and properties beneath the table definitions in supabase/schema.sql by pasting this chunk:
insert into public.profiles (id, full_name, role)
values
('11111111-1111-4111-8111-111111111111', 'Morgan Lee', 'owner'),
('22222222-2222-4222-8222-222222222222', 'Riley Chen', 'owner'),
('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Jordan Patel', 'agent'),
('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'Casey Brooks', 'agent');
insert into public.properties (
id,
owner_id,
title,
city,
bedrooms,
property_type,
latitude,
longitude
)
values
('10000000-0000-4000-8000-000000000001', '11111111-1111-4111-8111-111111111111', 'Capitol Hill Loft', 'Seattle', 1, 'Apartment', 47.6230, -122.3190),
('10000000-0000-4000-8000-000000000002', '11111111-1111-4111-8111-111111111111', 'Ballard Townhome', 'Seattle', 2, 'Townhome', 47.6680, -122.3860),
('10000000-0000-4000-8000-000000000003', '22222222-2222-4222-8222-222222222222', 'Green Lake House', 'Seattle', 3, 'House', 47.6800, -122.3270),
('10000000-0000-4000-8000-000000000004', '22222222-2222-4222-8222-222222222222', 'Columbia City Apartment', 'Seattle', 2, 'Apartment', 47.5590, -122.2860);
What Does This Seed Data Provide?
The four profile rows represent two owners and two agents. The four property rows reuse those owner IDs through the property foreign key.
Every coordinate points to a fictional Seattle property. These coordinates later place the same records on the marketplace map.
- Save supabase/schema.sql.
- Return to the SQL Editor query from earlier.
- Replace the query contents with the people and property seed chunk.
- Click Run.
- Return to the Table Editor.
- Select the profiles table to confirm four fictional people are present.
- Select the properties table to confirm four Seattle properties are present.
Seed Rows Missing?
Run the profile insert before the property insert. The property rows reference owner IDs from the profile rows.
If an insert still fails, help me debug the profile and property seed data.
- Add the four active listings beneath the property seed data in supabase/schema.sql by pasting this chunk:
insert into public.listings (
property_id,
agent_id,
monthly_rent,
available_from,
status
)
values
('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 2200, '2026-10-01', 'active'),
('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 2900, '2026-09-15', 'active'),
('10000000-0000-4000-8000-000000000003', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 3400, '2026-11-01', 'active'),
('10000000-0000-4000-8000-000000000004', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 2600, '2026-09-20', 'active');
How Do the Listings Complete the Model?
Each row combines a property ID with an agent ID. The listing adds the rent, availability date, and marketplace status.
All four rows use the active status. The browser query can therefore request the public inventory with one status filter.
- Save supabase/schema.sql.
- Return to the SQL Editor query from earlier.
- Replace the query contents with the listing seed chunk.
- Click Run.
- Return to the Table Editor.
- Select the listings table to confirm four active rows are present.
That is the data foundation complete. Four listings now share reusable owner, agent, and property records.
Fewer Than Four Listings?
Confirm the properties and profiles were inserted before the listings. Every listing depends on valid property and agent IDs.
If the listing count is still wrong, help me trace the missing listing rows.
Lock browser access to read only
The publishable key identifies a browser request as an unauthenticated or authenticated client. RLS policies decide which rows those clients can access.
This prototype exposes fictional records for reading. It grants no browser write privileges, which keeps public visitors from changing the demo inventory.
- Add the access rules beneath the listing seed data in supabase/schema.sql by pasting this chunk:
alter table public.profiles enable row level security;
alter table public.properties enable row level security;
alter table public.listings enable row level security;
revoke all on table public.profiles, public.properties, public.listings
from anon, authenticated;
grant select on table public.profiles, public.properties, public.listings
to anon, authenticated;
create policy "Anyone can read demo profiles"
on public.profiles for select
to anon, authenticated
using (true);
create policy "Anyone can read demo properties"
on public.properties for select
to anon, authenticated
using (true);
create policy "Anyone can read demo listings"
on public.listings for select
to anon, authenticated
using (true);
How Does the Read-Only Policy Work?
- RLS is enabled independently on all three tables.
- The revoke removes the browser roles' default table privileges.
- The grant restores only select access for reading rows.
- Each policy allows public reads from its fictional demo table.
- Save supabase/schema.sql.
- Return to the SQL Editor query from earlier.
- Replace the query contents with the access-rule chunk.
- Click Run.
- Return to the Table Editor.
- Confirm that all four listing rows remain visible after RLS is enabled.
Your browser can now read the fictional marketplace records through the publishable key. It has no table-level permission to insert, update, or delete them.
Browser Reads Blocked?
Confirm that every table has both a select grant and a matching select policy. Enabling RLS without those rules blocks the public query.
If access still fails, help me inspect the read-only RLS setup.
Connect React to the relational data
The browser needs one client for database requests and one shared type for joined listing records. The publishable key remains in .env.local instead of being copied into the source files.
- In the VS Code Explorer sidebar, create a lib folder inside src.
- Create supabase.ts inside src/lib.
- Paste this client setup into src/lib/supabase.ts:
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
if (!supabaseUrl || !supabasePublishableKey) {
throw new Error('Missing Supabase environment variables')
}
export const supabase = createClient(supabaseUrl, supabasePublishableKey)
What Does the Client Setup Do?
- The environment reads reuse the Project URL and publishable key from .env.local.
- The guard stops the app with a clear message when either browser-safe value is missing.
- The exported supabase client gives the app one connection point for database queries.
- Save src/lib/supabase.ts.
- Confirm that src/lib/supabase.ts appears beneath the lib folder in the Explorer sidebar.
Environment Variables Missing?
Confirm that .env.local remains inside property-marketplace. Check that both variable names match the client file exactly.
If the guard still triggers, help me trace my Vite environment variables.
- Create types.ts inside the existing src folder.
- Paste the relational listing types into src/types.ts:
export type PropertyType = 'Apartment' | 'Townhome' | 'House'
export type Listing = {
id: number
monthly_rent: number
available_from: string
status: 'active' | 'paused'
property: {
id: string
title: string
city: string
bedrooms: number
property_type: PropertyType
latitude: number
longitude: number
owner: { full_name: string } | null
}
agent: { full_name: string } | null
}
How Does the Listing Type Mirror the Join?
The top-level fields come from listings. The nested property object includes its related owner.
The nested agent object comes from the listing's agent relationship. Nullable names let the interface display a fallback if a relationship is unassigned.
- Save src/types.ts.
- Check the editor diagnostics for both new files. You should see no red TypeScript underlines.
- Select the existing src/App.tsx file in the Explorer sidebar.
- Replace its contents with this relational card application:
import { useEffect, useState } from 'react'
import { supabase } from './lib/supabase'
import type { Listing } from './types'
function App() {
const [listings, setListings] = useState<Listing[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let ignore = false
async function loadListings() {
setLoading(true)
const { data, error: queryError } = await supabase
.from('listings')
.select(`
id,
monthly_rent,
available_from,
status,
property:properties!listings_property_id_fkey (
id,
title,
city,
bedrooms,
property_type,
latitude,
longitude,
owner:profiles!properties_owner_id_fkey (full_name)
),
agent:profiles!listings_agent_id_fkey (full_name)
`)
.eq('status', 'active')
if (ignore) return
if (queryError) {
console.error(queryError)
setError('Could not load listings. Check your Supabase setup and browser console.')
setLoading(false)
return
}
setListings((data ?? []) as Listing[])
setLoading(false)
}
void loadListings()
return () => {
ignore = true
}
}, [])
return (
<div className="app-shell">
<header className="hero">
<p className="eyebrow">Property Atlas</p>
<h1>Find a home without losing the map.</h1>
<p>Browse fictional Seattle listings backed by relational Supabase data.</p>
</header>
{loading && <p className="status">Loading listings...</p>}
{error && <p className="status error">{error}</p>}
{!loading && !error && (
<main className="marketplace-layout">
<section className="results-panel">
<div className="results-heading">
<div><p className="eyebrow">Available now</p><h2>{listings.length} matching properties</h2></div>
<span className="result-pill">Public demo inventory</span>
</div>
<div className="listing-grid">
{listings.map((listing) => (
<button className="listing-card" key={listing.id} type="button">
<div className="card-topline"><span>{listing.property.property_type}</span><strong>${listing.monthly_rent.toLocaleString()}/mo</strong></div>
<h3>{listing.property.title}</h3>
<p>{listing.property.city} · {listing.property.bedrooms} bedroom{listing.property.bedrooms === 1 ? '' : 's'}</p>
<dl>
<div><dt>Owner</dt><dd>{listing.property.owner?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Agent</dt><dd>{listing.agent?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Available</dt><dd>{listing.available_from}</dd></div>
</dl>
</button>
))}
</div>
</section>
</main>
)}
</div>
)
}
export default App
How Does the Relational Query Work?
- The loadListings function requests only active rows from listings.
- The properties!listings_property_id_fkey alias follows each listing to its property.
- The profiles!properties_owner_id_fkey alias follows each property to its owner.
- The profiles!listings_agent_id_fkey alias follows each listing to its agent.
- The loading and error states keep the page informative while the request is running or fails.
- Save src/App.tsx.
- Switch back to the Microsoft Edge tab from the previous step.
- Refresh http://localhost:5173.
You should see four listing cards with rents, dates, owner names, and agent names. The scaffold styling may still surround the new content until you replace the stylesheet.
Listings Not Loading?
If the page shows the loading message indefinitely, inspect the browser console for the failed request. If the page shows the setup error, compare the relationship aliases with the constraint names in supabase/schema.sql.
If you need another set of eyes, help me debug the joined listing query.
- Select the existing src/main.tsx file in the Explorer sidebar.
- Replace its contents with this application entry point:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
What Does the Entry Point Do?
The entry point renders App into the existing root element. Strict Mode helps surface unsafe component behavior during local development.
- Save src/main.tsx.
- Return to Microsoft Edge to confirm the four cards still render.
- Select the existing src/index.css file in the Explorer sidebar.
- Replace its contents with this base marketplace styling:
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172554;
background: #f8fafc;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top left, #ccfbf1 0, transparent 32rem), #f8fafc; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { width: min(1440px, 100%); margin: 0 auto; padding: 32px; }
.hero { max-width: 760px; margin-bottom: 24px; }
.hero h1 { margin: 6px 0 12px; font-size: clamp(2.1rem, 5vw, 4.5rem); line-height: 0.98; letter-spacing: -0.05em; }
.hero > p:last-child { color: #475569; font-size: 1.08rem; }
.eyebrow { margin: 0; color: #0f766e; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.15em; text-transform: uppercase; }
.status, .empty-state { padding: 24px; border-radius: 16px; background: #fff; }
.status.error { color: #991b1b; border: 1px solid #fecaca; }
.results-panel { min-width: 0; }
.results-heading { display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 14px; }
.results-heading h2 { margin: 4px 0 0; }
.result-pill { padding: 7px 10px; border-radius: 999px; background: #dbeafe; color: #1e3a8a; font-size: 0.78rem; font-weight: 800; }
.listing-grid { display: grid; gap: 12px; }
.listing-card { width: 100%; padding: 18px; border: 2px solid transparent; border-radius: 16px; background: #fff; color: inherit; text-align: left; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08); transition: transform 150ms ease, border-color 150ms ease; }
.listing-card:hover { transform: translateY(-2px); }
.card-topline { display: flex; justify-content: space-between; gap: 16px; color: #0f766e; font-size: 0.85rem; font-weight: 800; }
.listing-card h3 { margin: 10px 0 5px; font-size: 1.25rem; }
.listing-card > p { margin: 0; color: #64748b; }
dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 16px 0 0; }
dt { color: #64748b; font-size: 0.72rem; text-transform: uppercase; }
dd { margin: 4px 0 0; font-weight: 700; }
@media (max-width: 560px) { .app-shell { padding: 20px; } .results-heading, .card-topline { align-items: start; flex-direction: column; } dl { grid-template-columns: 1fr; } }
What Does the Base Styling Add?
The page now has a responsive shell, a marketplace header, and readable status messages. The listing grid turns each relational record into a raised card with a clear rent summary.
The final media rule stacks dense card details on narrow screens. The same records stay readable without changing the query.
- Save src/index.css.
- Switch back to Microsoft Edge.
- Refresh the local marketplace.
You should see the Property Atlas heading above four white listing cards. Each card should show a property type, rent, title, city, bedroom count, owner, agent, and availability date.
Cards Still Look Unstyled?
Confirm that src/main.tsx imports ./index.css. Check that the new stylesheet was saved after replacing the scaffold CSS.
If the old Vite design remains, help me trace the stylesheet import.
✔️ Awesome, I've got everything!
Great. Double check that supabase/schema.sql, src/lib/supabase.ts, src/types.ts, src/App.tsx, src/main.tsx, and src/index.css are saved.
ⓧ I'd like to double check the full code
Compare each file with the completed versions below. Your existing .env.local and package.json remain unchanged.
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
if (!supabaseUrl || !supabasePublishableKey) {
throw new Error('Missing Supabase environment variables')
}
export const supabase = createClient(supabaseUrl, supabasePublishableKey)
export type PropertyType = 'Apartment' | 'Townhome' | 'House'
export type Listing = {
id: number
monthly_rent: number
available_from: string
status: 'active' | 'paused'
property: {
id: string
title: string
city: string
bedrooms: number
property_type: PropertyType
latitude: number
longitude: number
owner: { full_name: string } | null
}
agent: { full_name: string } | null
}
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
import { useEffect, useState } from 'react'
import { supabase } from './lib/supabase'
import type { Listing } from './types'
function App() {
const [listings, setListings] = useState<Listing[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let ignore = false
async function loadListings() {
setLoading(true)
const { data, error: queryError } = await supabase
.from('listings')
.select(`
id,
monthly_rent,
available_from,
status,
property:properties!listings_property_id_fkey (
id,
title,
city,
bedrooms,
property_type,
latitude,
longitude,
owner:profiles!properties_owner_id_fkey (full_name)
),
agent:profiles!listings_agent_id_fkey (full_name)
`)
.eq('status', 'active')
if (ignore) return
if (queryError) {
console.error(queryError)
setError('Could not load listings. Check your Supabase setup and browser console.')
setLoading(false)
return
}
setListings((data ?? []) as Listing[])
setLoading(false)
}
void loadListings()
return () => {
ignore = true
}
}, [])
return (
<div className="app-shell">
<header className="hero">
<p className="eyebrow">Property Atlas</p>
<h1>Find a home without losing the map.</h1>
<p>Browse fictional Seattle listings backed by relational Supabase data.</p>
</header>
{loading && <p className="status">Loading listings...</p>}
{error && <p className="status error">{error}</p>}
{!loading && !error && (
<main className="marketplace-layout">
<section className="results-panel">
<div className="results-heading">
<div><p className="eyebrow">Available now</p><h2>{listings.length} matching properties</h2></div>
<span className="result-pill">Public demo inventory</span>
</div>
<div className="listing-grid">
{listings.map((listing) => (
<button className="listing-card" key={listing.id} type="button">
<div className="card-topline"><span>{listing.property.property_type}</span><strong>${listing.monthly_rent.toLocaleString()}/mo</strong></div>
<h3>{listing.property.title}</h3>
<p>{listing.property.city} · {listing.property.bedrooms} bedroom{listing.property.bedrooms === 1 ? '' : 's'}</p>
<dl>
<div><dt>Owner</dt><dd>{listing.property.owner?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Agent</dt><dd>{listing.agent?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Available</dt><dd>{listing.available_from}</dd></div>
</dl>
</button>
))}
</div>
</section>
</main>
)}
</div>
)
}
export default App
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172554;
background: #f8fafc;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top left, #ccfbf1 0, transparent 32rem), #f8fafc; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { width: min(1440px, 100%); margin: 0 auto; padding: 32px; }
.hero { max-width: 760px; margin-bottom: 24px; }
.hero h1 { margin: 6px 0 12px; font-size: clamp(2.1rem, 5vw, 4.5rem); line-height: 0.98; letter-spacing: -0.05em; }
.hero > p:last-child { color: #475569; font-size: 1.08rem; }
.eyebrow { margin: 0; color: #0f766e; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.15em; text-transform: uppercase; }
.status, .empty-state { padding: 24px; border-radius: 16px; background: #fff; }
.status.error { color: #991b1b; border: 1px solid #fecaca; }
.results-panel { min-width: 0; }
.results-heading { display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 14px; }
.results-heading h2 { margin: 4px 0 0; }
.result-pill { padding: 7px 10px; border-radius: 999px; background: #dbeafe; color: #1e3a8a; font-size: 0.78rem; font-weight: 800; }
.listing-grid { display: grid; gap: 12px; }
.listing-card { width: 100%; padding: 18px; border: 2px solid transparent; border-radius: 16px; background: #fff; color: inherit; text-align: left; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08); transition: transform 150ms ease, border-color 150ms ease; }
.listing-card:hover { transform: translateY(-2px); }
.card-topline { display: flex; justify-content: space-between; gap: 16px; color: #0f766e; font-size: 0.85rem; font-weight: 800; }
.listing-card h3 { margin: 10px 0 5px; font-size: 1.25rem; }
.listing-card > p { margin: 0; color: #64748b; }
dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 16px 0 0; }
dt { color: #64748b; font-size: 0.72rem; text-transform: uppercase; }
dd { margin: 4px 0 0; font-weight: 700; }
@media (max-width: 560px) { .app-shell { padding: 20px; } .results-heading, .card-topline { align-items: start; flex-direction: column; } dl { grid-template-columns: 1fr; } }
-- Demo reset: these statements remove only this project's three demo tables.
drop table if exists public.listings cascade;
drop table if exists public.properties cascade;
drop table if exists public.profiles cascade;
create table public.profiles (
id uuid primary key,
full_name text not null,
role text not null check (role in ('owner', 'agent'))
);
create table public.properties (
id uuid primary key,
owner_id uuid not null,
title text not null,
city text not null,
bedrooms integer not null check (bedrooms >= 0),
property_type text not null check (property_type in ('Apartment', 'Townhome', 'House')),
latitude double precision not null,
longitude double precision not null,
constraint properties_owner_id_fkey
foreign key (owner_id) references public.profiles(id)
);
create table public.listings (
id bigint primary key generated always as identity,
property_id uuid not null,
agent_id uuid not null,
monthly_rent integer not null check (monthly_rent > 0),
available_from date not null,
status text not null check (status in ('active', 'paused')),
constraint listings_property_id_fkey
foreign key (property_id) references public.properties(id),
constraint listings_agent_id_fkey
foreign key (agent_id) references public.profiles(id)
);
insert into public.profiles (id, full_name, role)
values
('11111111-1111-4111-8111-111111111111', 'Morgan Lee', 'owner'),
('22222222-2222-4222-8222-222222222222', 'Riley Chen', 'owner'),
('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Jordan Patel', 'agent'),
('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'Casey Brooks', 'agent');
insert into public.properties (
id,
owner_id,
title,
city,
bedrooms,
property_type,
latitude,
longitude
)
values
('10000000-0000-4000-8000-000000000001', '11111111-1111-4111-8111-111111111111', 'Capitol Hill Loft', 'Seattle', 1, 'Apartment', 47.6230, -122.3190),
('10000000-0000-4000-8000-000000000002', '11111111-1111-4111-8111-111111111111', 'Ballard Townhome', 'Seattle', 2, 'Townhome', 47.6680, -122.3860),
('10000000-0000-4000-8000-000000000003', '22222222-2222-4222-8222-222222222222', 'Green Lake House', 'Seattle', 3, 'House', 47.6800, -122.3270),
('10000000-0000-4000-8000-000000000004', '22222222-2222-4222-8222-222222222222', 'Columbia City Apartment', 'Seattle', 2, 'Apartment', 47.5590, -122.2860);
insert into public.listings (
property_id,
agent_id,
monthly_rent,
available_from,
status
)
values
('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 2200, '2026-10-01', 'active'),
('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 2900, '2026-09-15', 'active'),
('10000000-0000-4000-8000-000000000003', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 3400, '2026-11-01', 'active'),
('10000000-0000-4000-8000-000000000004', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 2600, '2026-09-20', 'active');
alter table public.profiles enable row level security;
alter table public.properties enable row level security;
alter table public.listings enable row level security;
revoke all on table public.profiles, public.properties, public.listings
from anon, authenticated;
grant select on table public.profiles, public.properties, public.listings
to anon, authenticated;
create policy "Anyone can read demo profiles"
on public.profiles for select
to anon, authenticated
using (true);
create policy "Anyone can read demo properties"
on public.properties for select
to anon, authenticated
using (true);
create policy "Anyone can read demo listings"
on public.listings for select
to anon, authenticated
using (true);
- Return to the PowerShell window where Vite has been running.
- Press Ctrl+C to stop the current development server.
Before you restart the app, how many cards do you expect the database query to return?
- Start the marketplace development server again by running this command:
npm run dev
What Does This Command Confirm?
The development script starts Vite with the updated React files. A clean startup confirms that the new imports compile before the browser requests the listing data.
- Wait for PowerShell to show the local development address.
- Return to http://localhost:5173 in Microsoft Edge.
- Refresh the page.
- Count the cards in the listing grid.
You should see exactly four cards loaded from Supabase. Each card includes its property details, owner name, agent name, monthly rent, and availability date.
You have replaced the hardcoded starter screen with a relational marketplace. The browser now turns one secure query into four connected property cards.
Development Server Failed?
Confirm that PowerShell is still inside the property-marketplace folder. Check the first reported file and line for a missing character or import mismatch.
If the server starts but the cards do not appear, help me debug the complete relational card flow.
Your marketplace now has a relational source of truth. Next up, you will turn the four-card inventory into a searchable result set.
Add Search Filters
Your four cards now prove that relational data can reach the marketplace from Supabase. A larger inventory would make visitors scan every card to find suitable homes.
Search filters turn visitor preferences into one derived result set. The result counter uses that collection. The card grid uses it too.
In this step, get ready to:
- Add controlled filters for rent, bedrooms, and property type.
- Derive matching listings without changing the original Supabase results.
- Verify that a maximum rent of $2,500 produces one matching card.
Add controlled filter state
A controlled input gets its displayed value from React state. Each visitor interaction updates that state through an event handler.
- In the VS Code Explorer sidebar, select src/App.tsx.
- Update the React hook import at the top of src/App.tsx to match this line:
import { useEffect, useMemo, useState } from 'react'
Why Add useMemo?
The useMemo hook calculates the visible listings from the loaded listings and current filters. React recalculates that collection when one of its dependencies changes.
- Update the type import in src/App.tsx so the property filter can use the existing PropertyType union:
import type { Listing, PropertyType } from './types'
What Does the Type Protect?
The PropertyType union limits the filter to the property categories defined by the marketplace. Adding 'All' gives visitors a way to keep every category visible.
- Add the property-type options below the existing imports:
- Add the three filter states below the existing error state inside App():
const PROPERTY_TYPES: Array<PropertyType | 'All'> = ['All', 'Apartment', 'Townhome', 'House']
const [maxRent, setMaxRent] = useState(4000)
const [minBedrooms, setMinBedrooms] = useState(0)
const [propertyType, setPropertyType] = useState<PropertyType | 'All'>('All')
What Does This State Hold?
- The maxRent state starts at 4000. That limit includes all four demo listings.
- The minBedrooms state starts at 0. Every property meets that minimum.
- The propertyType state starts at 'All'. No property category is excluded.
- Add the reset function below the listing-loading effect:
function resetFilters() {
setMaxRent(4000)
setMinBedrooms(0)
setPropertyType('All')
}
What Does Reset Restore?
The resetFilters() function returns each preference to its initial value. One button can now restore the full inventory.
- Add the filter section directly below the closing </header> tag:
<section className="filters" aria-label="Listing filters">
<label>
<span>Max rent: ${maxRent.toLocaleString()}</span>
<input type="range" min="1800" max="4000" step="100" value={maxRent} onChange={(event) => setMaxRent(Number(event.target.value))} />
</label>
<label>
<span>Minimum bedrooms</span>
<select value={minBedrooms} onChange={(event) => setMinBedrooms(Number(event.target.value))}>
<option value="0">Any</option>
<option value="1">1+</option>
<option value="2">2+</option>
<option value="3">3+</option>
</select>
</label>
<label>
<span>Property type</span>
<select value={propertyType} onChange={(event) => setPropertyType(event.target.value as PropertyType | 'All')}>
{PROPERTY_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</label>
<button className="secondary-button" type="button" onClick={resetFilters}>Reset filters</button>
</section>
How Do the Controls Stay Current?
- The range input displays the current maxRent value. Its event handler converts the browser value from text to a number.
- The bedroom selector updates minBedrooms. Its options represent minimum bedroom counts.
- The property selector builds its options from PROPERTY_TYPES. This keeps the UI aligned with the existing type.
- The reset button calls resetFilters(). Visitors can return to the complete inventory in one action.
- Save src/App.tsx.
- Switch back to Microsoft Edge to check the updated marketplace.
You should see a rent slider, two selectors, and a reset button above the four existing cards. Your controls now have live state behind them.
Filters Not Appearing?
Check that the filter section sits inside the outer app-shell element. It must appear after the header.
Confirm that every state name matches its event handler exactly. Help me debug the missing React filter controls.
Derive the matching listings
The loaded listings array remains the source data from Supabase. A memoized calculation creates filteredListings whenever the inventory or a filter value changes.
- Add the derived listing calculation below the existing loading effect:
const filteredListings = useMemo(
() =>
listings
.filter(
(listing) =>
listing.monthly_rent <= maxRent &&
listing.property.bedrooms >= minBedrooms &&
(propertyType === 'All' || listing.property.property_type === propertyType),
)
.toSorted((a, b) => a.monthly_rent - b.monthly_rent),
[listings, maxRent, minBedrooms, propertyType],
)
How Does the Filter Work?
- The first predicate removes listings above the visitor's rent limit.
- The second predicate keeps properties that meet the bedroom minimum.
- The final predicate allows every type when propertyType is 'All'.
- The toSorted() call returns a new rent-ordered array. The original Supabase result remains unchanged.
- Replace listings with filteredListings in the card-rendering map() call.
- Add the results heading directly above the existing listing-grid element:
<div className="results-heading">
<div><p className="eyebrow">Available now</p><h2>{filteredListings.length} matching properties</h2></div>
<span className="result-pill">Public demo inventory</span>
</div>
Why Count the Derived Results?
The heading reads the same filteredListings collection as the card grid. The number and visible cards therefore update together.
- Add the empty state directly below the closing listing-grid element:
{filteredListings.length === 0 && <div className="empty-state"><h3>No exact matches</h3><p>Increase the rent limit or reset a filter.</p></div>}
Why Include an Empty State?
Some filter combinations produce zero listings. The empty state explains the result and gives the visitor a clear recovery path.
- Save src/App.tsx.
- Switch back to Microsoft Edge to test the controls.
Changing any filter should now update the result count and card grid immediately. The original four-listing array stays intact in memory.
Count and Cards Disagree?
Confirm that the heading uses filteredListings.length. Check that the card loop starts from filteredListings.
Make sure listings still appears first inside the useMemo calculation. Help me debug inconsistent filtered results.
Style and verify the filters
The filter logic now works. Styling groups the controls into one responsive search panel and keeps the current result count easy to scan.
- In the VS Code Explorer sidebar, select src/index.css.
- Add the filter-panel styles below the existing hero styles:
.filters { display: grid; grid-template-columns: 1.4fr 1fr 1fr auto; gap: 14px; align-items: end; margin-bottom: 22px; padding: 18px; border: 1px solid #cbd5e1; border-radius: 18px; background: rgba(255, 255, 255, 0.92); box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08); }
.filters label { display: grid; gap: 8px; color: #334155; font-size: 0.88rem; font-weight: 700; }
.filters input, .filters select, .secondary-button { width: 100%; min-height: 42px; border: 1px solid #94a3b8; border-radius: 10px; background: #fff; color: #172554; }
.filters select, .secondary-button { padding: 0 12px; }
.secondary-button { font-weight: 800; }
What Does This Styling Do?
The grid gives each filter a defined column on wide screens. Shared control styles keep the slider, selectors, and button visually connected.
- Add the result-summary styles below the filter styles:
.results-panel { min-width: 0; }
.results-heading { display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 14px; }
.results-heading h2 { margin: 4px 0 0; }
.result-pill { padding: 7px 10px; border-radius: 999px; background: #dbeafe; color: #1e3a8a; font-size: 0.78rem; font-weight: 800; }
.empty-state { padding: 24px; border-radius: 16px; background: #fff; border: 1px dashed #94a3b8; text-align: center; }
.empty-state h3 { margin-top: 0; }
@media (max-width: 900px) { .filters { grid-template-columns: 1fr; } }
@media (max-width: 560px) { .app-shell { padding: 20px; } .results-heading, .card-topline { align-items: start; flex-direction: column; } dl { grid-template-columns: 1fr; } }
How Does the Layout Adapt?
The result heading places the count beside its inventory label on larger screens. The responsive rules stack controls and card details when the viewport narrows.
- Save src/index.css.
- Switch back to Microsoft Edge to view the styled controls.
Before changing the rent limit, predict how many cards will remain when the maximum becomes $2,500.
- Drag the Max rent slider until its label reads $2,500.
You should see 1 matching properties and one card for Capitol Hill Loft. The count and card grid now agree on the same filtered result.
- Set Minimum bedrooms to 2+.
You should see the empty state because the only listing below $2,500 has one bedroom.
- Click Reset filters.
You should see all four cards again. That completes the shared search pipeline for the card view.
Wrong Number of Matches?
Confirm that the rent predicate uses <=. Check that maxRent becomes a number inside its change handler.
Verify that the slider label reads exactly $2,500 before counting the cards. Help me debug the property filter result.
✔️ Awesome, I've got everything!
Great work. Your marketplace now turns three visitor preferences into one rent-ordered card collection.
ⓧ I'd like to double check the full code
Compare your completed src/App.tsx with this version:
import { useEffect, useMemo, useState } from 'react'
import { supabase } from './lib/supabase'
import type { Listing, PropertyType } from './types'
const PROPERTY_TYPES: Array<PropertyType | 'All'> = ['All', 'Apartment', 'Townhome', 'House']
function App() {
const [listings, setListings] = useState<Listing[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [maxRent, setMaxRent] = useState(4000)
const [minBedrooms, setMinBedrooms] = useState(0)
const [propertyType, setPropertyType] = useState<PropertyType | 'All'>('All')
useEffect(() => {
let ignore = false
async function loadListings() {
setLoading(true)
const { data, error: queryError } = await supabase
.from('listings')
.select(`
id,
monthly_rent,
available_from,
status,
property:properties!listings_property_id_fkey (
id,
title,
city,
bedrooms,
property_type,
latitude,
longitude,
owner:profiles!properties_owner_id_fkey (full_name)
),
agent:profiles!listings_agent_id_fkey (full_name)
`)
.eq('status', 'active')
if (ignore) return
if (queryError) {
console.error(queryError)
setError('Could not load listings. Check your Supabase setup and browser console.')
setLoading(false)
return
}
setListings((data ?? []) as Listing[])
setLoading(false)
}
void loadListings()
return () => {
ignore = true
}
}, [])
const filteredListings = useMemo(
() =>
listings
.filter(
(listing) =>
listing.monthly_rent <= maxRent &&
listing.property.bedrooms >= minBedrooms &&
(propertyType === 'All' || listing.property.property_type === propertyType),
)
.toSorted((a, b) => a.monthly_rent - b.monthly_rent),
[listings, maxRent, minBedrooms, propertyType],
)
function resetFilters() {
setMaxRent(4000)
setMinBedrooms(0)
setPropertyType('All')
}
return (
<div className="app-shell">
<header className="hero">
<p className="eyebrow">Property Atlas</p>
<h1>Find a home without losing the map.</h1>
<p>Browse fictional Seattle listings backed by relational Supabase data.</p>
</header>
<section className="filters" aria-label="Listing filters">
<label>
<span>Max rent: ${maxRent.toLocaleString()}</span>
<input type="range" min="1800" max="4000" step="100" value={maxRent} onChange={(event) => setMaxRent(Number(event.target.value))} />
</label>
<label>
<span>Minimum bedrooms</span>
<select value={minBedrooms} onChange={(event) => setMinBedrooms(Number(event.target.value))}>
<option value="0">Any</option>
<option value="1">1+</option>
<option value="2">2+</option>
<option value="3">3+</option>
</select>
</label>
<label>
<span>Property type</span>
<select value={propertyType} onChange={(event) => setPropertyType(event.target.value as PropertyType | 'All')}>
{PROPERTY_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</label>
<button className="secondary-button" type="button" onClick={resetFilters}>Reset filters</button>
</section>
{loading && <p className="status">Loading listings...</p>}
{error && <p className="status error">{error}</p>}
{!loading && !error && (
<main className="results-panel">
<div className="results-heading">
<div><p className="eyebrow">Available now</p><h2>{filteredListings.length} matching properties</h2></div>
<span className="result-pill">Public demo inventory</span>
</div>
<div className="listing-grid">
{filteredListings.map((listing) => (
<article className="listing-card" key={listing.id}>
<div className="card-topline"><span>{listing.property.property_type}</span><strong>${listing.monthly_rent.toLocaleString()}/mo</strong></div>
<h3>{listing.property.title}</h3>
<p>{listing.property.city} · {listing.property.bedrooms} bedroom{listing.property.bedrooms === 1 ? '' : 's'}</p>
<dl>
<div><dt>Owner</dt><dd>{listing.property.owner?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Agent</dt><dd>{listing.agent?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Available</dt><dd>{listing.available_from}</dd></div>
</dl>
</article>
))}
</div>
{filteredListings.length === 0 && <div className="empty-state"><h3>No exact matches</h3><p>Increase the rent limit or reset a filter.</p></div>}
</main>
)}
</div>
)
}
export default App
Compare your completed src/index.css with this version:
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172554;
background: #f8fafc;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top left, #ccfbf1 0, transparent 32rem), #f8fafc; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { width: min(1440px, 100%); margin: 0 auto; padding: 32px; }
.hero { max-width: 760px; margin-bottom: 24px; }
.hero h1 { margin: 6px 0 12px; font-size: clamp(2.1rem, 5vw, 4.5rem); line-height: 0.98; letter-spacing: -0.05em; }
.hero > p:last-child { color: #475569; font-size: 1.08rem; }
.eyebrow { margin: 0; color: #0f766e; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.15em; text-transform: uppercase; }
.filters { display: grid; grid-template-columns: 1.4fr 1fr 1fr auto; gap: 14px; align-items: end; margin-bottom: 22px; padding: 18px; border: 1px solid #cbd5e1; border-radius: 18px; background: rgba(255, 255, 255, 0.92); box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08); }
.filters label { display: grid; gap: 8px; color: #334155; font-size: 0.88rem; font-weight: 700; }
.filters input, .filters select, .secondary-button { width: 100%; min-height: 42px; border: 1px solid #94a3b8; border-radius: 10px; background: #fff; color: #172554; }
.filters select, .secondary-button { padding: 0 12px; }
.secondary-button { font-weight: 800; }
.status, .empty-state { padding: 24px; border-radius: 16px; background: #fff; }
.status.error { color: #991b1b; border: 1px solid #fecaca; }
.results-panel { min-width: 0; }
.results-heading { display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 14px; }
.results-heading h2 { margin: 4px 0 0; }
.result-pill { padding: 7px 10px; border-radius: 999px; background: #dbeafe; color: #1e3a8a; font-size: 0.78rem; font-weight: 800; }
.listing-grid { display: grid; gap: 12px; }
.listing-card { width: 100%; padding: 18px; border: 2px solid transparent; border-radius: 16px; background: #fff; color: inherit; text-align: left; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08); transition: transform 150ms ease, border-color 150ms ease; }
.listing-card:hover { transform: translateY(-2px); }
.card-topline { display: flex; justify-content: space-between; gap: 16px; color: #0f766e; font-size: 0.85rem; font-weight: 800; }
.listing-card h3 { margin: 10px 0 5px; font-size: 1.25rem; }
.listing-card > p { margin: 0; color: #64748b; }
dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 16px 0 0; }
dt { color: #64748b; font-size: 0.72rem; text-transform: uppercase; }
dd { margin: 4px 0 0; font-weight: 700; }
.empty-state { border: 1px dashed #94a3b8; text-align: center; }
.empty-state h3 { margin-top: 0; }
@media (max-width: 900px) { .filters { grid-template-columns: 1fr; } }
@media (max-width: 560px) { .app-shell { padding: 20px; } .results-heading, .card-topline { align-items: start; flex-direction: column; } dl { grid-template-columns: 1fr; } }
Your cards now respond to one shared preference pipeline. Next, you will place the inventory on a map and uncover what happens when two views stop using the same results.
Expose the Map Mismatch
Your Supabase data now flows through filters before reaching the listing cards. A map creates a second view that needs to follow the same data pipeline.
This step deliberately connects the cards to filteredListings while connecting the markers to listings. You will expose the contradictory inventory before fixing it.
In this step, get ready to:
- Prepare a responsive surface for an interactive map.
- Render every listing as a clickable map marker.
- Reveal the mismatch between filtered cards and unfiltered markers.
Prepare the map surface
React Leaflet renders Leaflet maps through React components. Its map needs the Leaflet stylesheet plus an explicit height before tiles can appear.
- In src/main.tsx, find the current import group:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
What does the current entry point load?
This import group loads React's root tools before your marketplace styles. The map library's styles are still missing.
- Replace that import group with this updated version:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './index.css'
import App from './App.tsx'
Why load the Leaflet stylesheet?
The leaflet/dist/leaflet.css import supplies the positioning rules used by map tiles and controls. Loading it before index.css lets your marketplace styles control the final layout.
- Save src/main.tsx.
- Return to the Microsoft Edge tab from earlier.
You should still see the filtered listing cards without a terminal compilation error. This confirms the stylesheet import resolves correctly.
Did the page stop loading?
Confirm the import is spelled exactly as shown. Also confirm the pinned leaflet dependency remains in package.json.
Ask for help with the import if the Vite terminal reports a missing module. Help me fix the Leaflet stylesheet import in my React Vite app.
The marketplace needs a two-column layout on wide screens. The right column will hold the map while the existing cards remain on the left.
- In src/index.css, add these layout rules immediately before the responsive media rules:
.marketplace-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr); gap: 22px; }
.results-panel, .map-panel { min-width: 0; }
.map-panel { position: sticky; top: 20px; align-self: start; overflow: hidden; border: 1px solid #cbd5e1; border-radius: 18px; background: #e2e8f0; box-shadow: 0 18px 45px rgba(15, 23, 42, 0.12); }
.map { width: 100%; height: 680px; }
What do these styles create?
- The .marketplace-layout grid places the results beside the map on wide screens.
- The .map-panel rule keeps the map visible as the visitor scrolls through cards.
- The .map rule gives the map a measurable height of 680px.
- Replace the existing @media (max-width: 900px) rule with this responsive version:
@media (max-width: 900px) { .filters, .marketplace-layout { grid-template-columns: 1fr; } .map-panel { position: static; } .map { height: 440px; } }
How does the layout adapt?
Screens below 900px use one column. The shorter map height keeps the mobile page manageable.
- Save src/index.css.
- Return to the marketplace in Microsoft Edge.
On a wide browser window, the cards should now occupy the left side of the page. The open space on the right is ready for the map.
Did the cards stay full width?
Confirm .marketplace-layout matches the class on the existing main element. Make the browser wider than 900px so the mobile rule does not collapse the grid.
Use this prompt if the grid still stays in one column. Help me debug the marketplace grid in src/index.css.
Render every listing on the map
A MapContainer creates the map context used by its child layers. Each listing becomes a CircleMarker positioned with the latitude and longitude already stored in Supabase.
- In src/App.tsx, replace the imports and constants at the top with this updated group:
import { useEffect, useMemo, useState } from 'react'
import { CircleMarker, MapContainer, Popup, TileLayer } from 'react-leaflet'
import { supabase } from './lib/supabase'
import type { Listing, PropertyType } from './types'
const DEMO_CENTER: [number, number] = [47.6062, -122.3321]
const PROPERTY_TYPES: Array<PropertyType | 'All'> = ['All', 'Apartment', 'Townhome', 'House']
What does this map setup provide?
- The React Leaflet imports provide the map container plus its tile and marker layers.
- The DEMO_CENTER coordinates center the initial view on Seattle.
- The existing PROPERTY_TYPES collection remains unchanged for the property-type filter.
- Find the propertyType state inside App().
- Add this selection state directly below it:
const [selectedId, setSelectedId] = useState<number | null>(null)
What does the selection state hold?
The selectedId value records which marker a visitor clicks. A later step uses that shared identity to coordinate both representations.
- Save src/App.tsx.
- Check the marketplace in Microsoft Edge.
You should still see the filters and listing cards. The browser should show no compilation overlay.
Seeing an import error?
Check that every React Leaflet component name uses the same capitalization as the code block. Confirm react-leaflet remains listed in package.json.
Use this prompt if the imports still fail. Help me resolve my React Leaflet component import error.
OpenStreetMap Standard tiles provide the visible streets beneath the markers. The tile layer keeps attribution visible while each marker opens a popup for its listing.
- In src/App.tsx, locate the closing tag for the existing results-panel section.
- Add this map section directly below that closing tag:
<section className="map-panel" aria-label="Map of matching properties">
<MapContainer center={DEMO_CENTER} zoom={12} scrollWheelZoom={true} className="map">
<TileLayer attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" />
{listings.map((listing) => {
const isSelected = listing.id === selectedId
return (
<CircleMarker
key={listing.id}
center={[listing.property.latitude, listing.property.longitude]}
radius={isSelected ? 13 : 9}
pathOptions={{ color: isSelected ? '#172554' : '#0f766e', fillColor: isSelected ? '#f59e0b' : '#14b8a6', fillOpacity: 0.9, weight: 3 }}
eventHandlers={{ click: () => setSelectedId(listing.id) }}
>
<Popup><strong>{listing.property.title}</strong><br />${listing.monthly_rent.toLocaleString()}/month</Popup>
</CircleMarker>
)
})}
</MapContainer>
</section>
How does the map work?
- The TileLayer loads the Standard map tiles with visible OpenStreetMap attribution.
- The listings.map(...) call creates one circle for every loaded active listing.
- Each marker reads its position from the related property's coordinates.
- Each marker click stores the listing ID in selectedId.
- Save src/App.tsx.
- Return to the marketplace in Microsoft Edge.
- Click one of the map circles.
The map is live. You should see four circles around Seattle plus a popup showing the clicked property's title and monthly rent.
Is the map blank?
Check that src/main.tsx imports leaflet/dist/leaflet.css. Confirm the .map rule in src/index.css has an explicit height.
If the background appears without tiles, check the browser console for a blocked network request. Help me debug my blank React Leaflet map.
✔️ Awesome, I've got everything!
Your map now displays all four fictional listings. Keep the development server running for the mismatch test.
ⓧ I'd like to double check the full code
Compare these three files with your workspace. The listings.map(...) line is the deliberate integration defect for this step.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
What should match in the entry point?
The Leaflet stylesheet appears before your local stylesheet. The existing React root remains unchanged.
import { useEffect, useMemo, useState } from 'react'
import { CircleMarker, MapContainer, Popup, TileLayer } from 'react-leaflet'
import { supabase } from './lib/supabase'
import type { Listing, PropertyType } from './types'
const DEMO_CENTER: [number, number] = [47.6062, -122.3321]
const PROPERTY_TYPES: Array<PropertyType | 'All'> = ['All', 'Apartment', 'Townhome', 'House']
function App() {
const [listings, setListings] = useState<Listing[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [maxRent, setMaxRent] = useState(4000)
const [minBedrooms, setMinBedrooms] = useState(0)
const [propertyType, setPropertyType] = useState<PropertyType | 'All'>('All')
const [selectedId, setSelectedId] = useState<number | null>(null)
useEffect(() => {
let ignore = false
async function loadListings() {
setLoading(true)
const { data, error: queryError } = await supabase
.from('listings')
.select(`
id,
monthly_rent,
available_from,
status,
property:properties!listings_property_id_fkey (
id,
title,
city,
bedrooms,
property_type,
latitude,
longitude,
owner:profiles!properties_owner_id_fkey (full_name)
),
agent:profiles!listings_agent_id_fkey (full_name)
`)
.eq('status', 'active')
if (ignore) return
if (queryError) {
console.error(queryError)
setError('Could not load listings. Check your Supabase setup and browser console.')
setLoading(false)
return
}
setListings((data ?? []) as Listing[])
setLoading(false)
}
void loadListings()
return () => {
ignore = true
}
}, [])
const filteredListings = useMemo(
() =>
listings
.filter(
(listing) =>
listing.monthly_rent <= maxRent &&
listing.property.bedrooms >= minBedrooms &&
(propertyType === 'All' || listing.property.property_type === propertyType),
)
.toSorted((a, b) => a.monthly_rent - b.monthly_rent),
[listings, maxRent, minBedrooms, propertyType],
)
function resetFilters() {
setMaxRent(4000)
setMinBedrooms(0)
setPropertyType('All')
}
return (
<div className="app-shell">
<header className="hero">
<p className="eyebrow">Property Atlas</p>
<h1>Find a home without losing the map.</h1>
<p>Browse fictional Seattle listings backed by relational Supabase data.</p>
</header>
<section className="filters" aria-label="Listing filters">
<label>
<span>Max rent: ${maxRent.toLocaleString()}</span>
<input type="range" min="1800" max="4000" step="100" value={maxRent} onChange={(event) => setMaxRent(Number(event.target.value))} />
</label>
<label>
<span>Minimum bedrooms</span>
<select value={minBedrooms} onChange={(event) => setMinBedrooms(Number(event.target.value))}>
<option value="0">Any</option>
<option value="1">1+</option>
<option value="2">2+</option>
<option value="3">3+</option>
</select>
</label>
<label>
<span>Property type</span>
<select value={propertyType} onChange={(event) => setPropertyType(event.target.value as PropertyType | 'All')}>
{PROPERTY_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</label>
<button className="secondary-button" type="button" onClick={resetFilters}>Reset filters</button>
</section>
{loading && <p className="status">Loading listings...</p>}
{error && <p className="status error">{error}</p>}
{!loading && !error && (
<main className="marketplace-layout">
<section className="results-panel">
<div className="results-heading">
<div><p className="eyebrow">Available now</p><h2>{filteredListings.length} matching properties</h2></div>
<span className="result-pill">Public demo inventory</span>
</div>
<div className="listing-grid">
{filteredListings.map((listing) => {
return (
<button className="listing-card" key={listing.id} type="button">
<div className="card-topline"><span>{listing.property.property_type}</span><strong>${listing.monthly_rent.toLocaleString()}/mo</strong></div>
<h3>{listing.property.title}</h3>
<p>{listing.property.city} · {listing.property.bedrooms} bedroom{listing.property.bedrooms === 1 ? '' : 's'}</p>
<dl>
<div><dt>Owner</dt><dd>{listing.property.owner?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Agent</dt><dd>{listing.agent?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Available</dt><dd>{listing.available_from}</dd></div>
</dl>
</button>
)
})}
</div>
{filteredListings.length === 0 && <div className="empty-state"><h3>No exact matches</h3><p>Increase the rent limit or reset a filter.</p></div>}
</section>
<section className="map-panel" aria-label="Map of matching properties">
<MapContainer center={DEMO_CENTER} zoom={12} scrollWheelZoom={true} className="map">
<TileLayer attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" />
{listings.map((listing) => {
const isSelected = listing.id === selectedId
return (
<CircleMarker
key={listing.id}
center={[listing.property.latitude, listing.property.longitude]}
radius={isSelected ? 13 : 9}
pathOptions={{ color: isSelected ? '#172554' : '#0f766e', fillColor: isSelected ? '#f59e0b' : '#14b8a6', fillOpacity: 0.9, weight: 3 }}
eventHandlers={{ click: () => setSelectedId(listing.id) }}
>
<Popup><strong>{listing.property.title}</strong><br />${listing.monthly_rent.toLocaleString()}/month</Popup>
</CircleMarker>
)
})}
</MapContainer>
</section>
</main>
)}
</div>
)
}
export default App
Where is the mismatch created?
The results count plus cards use filteredListings. The markers use the original listings collection.
The marker styling also reads selectedId so a clicked circle becomes larger and changes colour.
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172554;
background: #f8fafc;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top left, #ccfbf1 0, transparent 32rem), #f8fafc; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { width: min(1440px, 100%); margin: 0 auto; padding: 32px; }
.hero { max-width: 760px; margin-bottom: 24px; }
.hero h1 { margin: 6px 0 12px; font-size: clamp(2.1rem, 5vw, 4.5rem); line-height: 0.98; letter-spacing: -0.05em; }
.hero > p:last-child { color: #475569; font-size: 1.08rem; }
.eyebrow { margin: 0; color: #0f766e; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.15em; text-transform: uppercase; }
.filters { display: grid; grid-template-columns: 1.4fr 1fr 1fr auto; gap: 14px; align-items: end; margin-bottom: 22px; padding: 18px; border: 1px solid #cbd5e1; border-radius: 18px; background: rgba(255, 255, 255, 0.92); box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08); }
.filters label { display: grid; gap: 8px; color: #334155; font-size: 0.88rem; font-weight: 700; }
.filters input, .filters select, .secondary-button { width: 100%; min-height: 42px; border: 1px solid #94a3b8; border-radius: 10px; background: #fff; color: #172554; }
.filters select, .secondary-button { padding: 0 12px; }
.secondary-button { font-weight: 800; }
.status, .empty-state { padding: 24px; border-radius: 16px; background: #fff; }
.status.error { color: #991b1b; border: 1px solid #fecaca; }
.marketplace-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr); gap: 22px; }
.results-panel, .map-panel { min-width: 0; }
.results-heading { display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 14px; }
.results-heading h2 { margin: 4px 0 0; }
.result-pill { padding: 7px 10px; border-radius: 999px; background: #dbeafe; color: #1e3a8a; font-size: 0.78rem; font-weight: 800; }
.listing-grid { display: grid; gap: 12px; }
.listing-card { width: 100%; padding: 18px; border: 2px solid transparent; border-radius: 16px; background: #fff; color: inherit; text-align: left; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08); transition: transform 150ms ease, border-color 150ms ease; }
.listing-card:hover { transform: translateY(-2px); }
.listing-card.selected { border-color: #f59e0b; box-shadow: 0 14px 34px rgba(245, 158, 11, 0.2); }
.card-topline { display: flex; justify-content: space-between; gap: 16px; color: #0f766e; font-size: 0.85rem; font-weight: 800; }
.listing-card h3 { margin: 10px 0 5px; font-size: 1.25rem; }
.listing-card > p { margin: 0; color: #64748b; }
dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 16px 0 0; }
dt { color: #64748b; font-size: 0.72rem; text-transform: uppercase; }
dd { margin: 4px 0 0; font-weight: 700; }
.map-panel { position: sticky; top: 20px; align-self: start; overflow: hidden; border: 1px solid #cbd5e1; border-radius: 18px; background: #e2e8f0; box-shadow: 0 18px 45px rgba(15, 23, 42, 0.12); }
.map { width: 100%; height: 680px; }
.empty-state { border: 1px dashed #94a3b8; text-align: center; }
.empty-state h3 { margin-top: 0; }
@media (max-width: 900px) { .filters, .marketplace-layout { grid-template-columns: 1fr; } .map-panel { position: static; } .map { height: 440px; } }
@media (max-width: 560px) { .app-shell { padding: 20px; } .results-heading, .card-topline { align-items: start; flex-direction: column; } dl { grid-template-columns: 1fr; } }
What should match in the stylesheet?
The map has an explicit desktop height plus a shorter mobile height. The map panel becomes non-sticky when the marketplace collapses to one column.
Reveal the mismatch
Before you change the filter, do you expect the card grid and map to remove the same listings?
- Return to the marketplace in Microsoft Edge.
- Set Max rent to $2500.
- Count the visible listing cards.
- Count the visible map circles.
You should see exactly one card and four map circles. The card represents the only listing within the rent limit while the map continues showing the complete inventory.
This mismatch is intentional
The cards read from filteredListings while the markers read from listings. Two views now make contradictory claims about which properties match.
You have exposed a real integration defect before changing its data pipeline. That visible contradiction gives the next step a precise problem to solve.
The map mismatch is now visible. Next, you will give the cards and markers one shared result set plus synchronized selection styling.
Synchronize Cards and Map Selection
The previous step made the integration defect visible. The cards obey filteredListings. The map still reads listings.
This step makes derived state the shared source of truth for both views. It also uses selectedId as the synchronized selection signal.
In this step, get ready to:
- Make map markers obey the active filters.
- Synchronize selection styling across cards and markers.
- Test the shared state with a production build.
Drive markers from filtered listings
Both views need to read the same collection. The smallest fix is to make the marker loop use filteredListings.
- In src/App.tsx, find the listings array used directly above the CircleMarker block.
- Replace listings with filteredListings.
- Save src/App.tsx.
- Return to Microsoft Edge from earlier.
- Set Max rent to $2500.
You should see one card. You should also see one map circle.
That closes the visible gap. Your map now respects the visitor's active filters.
Still seeing four markers?
Check that the marker loop uses filteredListings. The card loop already uses that collection.
Save src/App.tsx. Confirm the development server is still running.
help me find why my filtered cards and map markers still have different counts
Share selection across both views
A filtered-out listing should lose its visible selection. The visibleSelectedId value keeps the highlight limited to the current result set.
- In src/App.tsx, add the derived selection immediately below the filteredListings declaration by copying this code:
const visibleSelectedId = filteredListings.some((listing) => listing.id === selectedId)
? selectedId
: null
What does this selection check do?
- The some() check looks for the selected listing in the current filtered collection.
- A selection that survives the filters keeps its ID.
- A filtered-out selection becomes null.
- Replace the existing listing-grid block with this synchronized card block:
<div className="listing-grid">
{filteredListings.map((listing) => {
const isSelected = listing.id === visibleSelectedId
return (
<button className={`listing-card${isSelected ? ' selected' : ''}`} key={listing.id} type="button" onClick={() => setSelectedId(listing.id)}>
<div className="card-topline"><span>{listing.property.property_type}</span><strong>${listing.monthly_rent.toLocaleString()}/mo</strong></div>
<h3>{listing.property.title}</h3>
<p>{listing.property.city} · {listing.property.bedrooms} bedroom{listing.property.bedrooms === 1 ? '' : 's'}</p>
<dl>
<div><dt>Owner</dt><dd>{listing.property.owner?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Agent</dt><dd>{listing.agent?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Available</dt><dd>{listing.available_from}</dd></div>
</dl>
</button>
)
})}
</div>
How do cards use the shared selection?
- Each card compares its listing ID with visibleSelectedId.
- A matching card receives the existing selected class.
- A card click stores its listing ID through setSelectedId.
- Save src/App.tsx.
- Return to Microsoft Edge.
- Click Reset filters.
- Select the Ballard Townhome card.
You should see an orange border around the selected card.
Card not highlighting?
Confirm the button's className appends selected when isSelected is true.
Check that the click handler passes listing.id to setSelectedId.
help me debug why my selected listing card does not highlight
The map can now consume the same visible ID. Its marker radius and colours make the shared selection easy to spot.
- Replace the existing map-panel block with this synchronized map block:
<section className="map-panel" aria-label="Map of matching properties">
<MapContainer center={DEMO_CENTER} zoom={12} scrollWheelZoom={true} className="map">
<TileLayer attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" />
{filteredListings.map((listing) => {
const isSelected = listing.id === visibleSelectedId
return (
<CircleMarker
key={listing.id}
center={[listing.property.latitude, listing.property.longitude]}
radius={isSelected ? 13 : 9}
pathOptions={{ color: isSelected ? '#172554' : '#0f766e', fillColor: isSelected ? '#f59e0b' : '#14b8a6', fillOpacity: 0.9, weight: 3 }}
eventHandlers={{ click: () => setSelectedId(listing.id) }}
>
<Popup><strong>{listing.property.title}</strong><br />${listing.monthly_rent.toLocaleString()}/month</Popup>
</CircleMarker>
)
})}
</MapContainer>
</section>
How does the map stay synchronized?
- The map iterates over filteredListings.
- Each marker uses the same isSelected condition as its card.
- A selected marker grows from radius 9 to radius 13.
- The selected marker changes to an amber fill.
- A marker click stores the same listing ID used by the cards.
- Save src/App.tsx.
- Return to Microsoft Edge.
- Set Max rent to $2500.
- Select the visible listing card.
You should see the card gain an orange border. Its matching marker should grow with an amber centre.
- Select the visible marker.
You should see the same card remain highlighted. Selection now travels in both directions.
Marker and card highlights disagree?
Confirm both callbacks compare listing.id with visibleSelectedId.
Check that both click handlers call setSelectedId(listing.id).
help me synchronize my selected card and Leaflet marker
✔️ Awesome, I've got everything!
Great. Save src/App.tsx before testing the complete synchronization flow.
ⓧ I'd like to double check the full code
Compare your complete src/App.tsx file with this reference:
import { useEffect, useMemo, useState } from 'react'
import { CircleMarker, MapContainer, Popup, TileLayer } from 'react-leaflet'
import { supabase } from './lib/supabase'
import type { Listing, PropertyType } from './types'
const DEMO_CENTER: [number, number] = [47.6062, -122.3321]
const PROPERTY_TYPES: Array<PropertyType | 'All'> = ['All', 'Apartment', 'Townhome', 'House']
function App() {
const [listings, setListings] = useState<Listing[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [maxRent, setMaxRent] = useState(4000)
const [minBedrooms, setMinBedrooms] = useState(0)
const [propertyType, setPropertyType] = useState<PropertyType | 'All'>('All')
const [selectedId, setSelectedId] = useState<number | null>(null)
useEffect(() => {
let ignore = false
async function loadListings() {
setLoading(true)
const { data, error: queryError } = await supabase
.from('listings')
.select(`
id,
monthly_rent,
available_from,
status,
property:properties!listings_property_id_fkey (
id,
title,
city,
bedrooms,
property_type,
latitude,
longitude,
owner:profiles!properties_owner_id_fkey (full_name)
),
agent:profiles!listings_agent_id_fkey (full_name)
`)
.eq('status', 'active')
if (ignore) return
if (queryError) {
console.error(queryError)
setError('Could not load listings. Check your Supabase setup and browser console.')
setLoading(false)
return
}
setListings((data ?? []) as Listing[])
setLoading(false)
}
void loadListings()
return () => {
ignore = true
}
}, [])
const filteredListings = useMemo(
() =>
listings
.filter(
(listing) =>
listing.monthly_rent <= maxRent &&
listing.property.bedrooms >= minBedrooms &&
(propertyType === 'All' || listing.property.property_type === propertyType),
)
.toSorted((a, b) => a.monthly_rent - b.monthly_rent),
[listings, maxRent, minBedrooms, propertyType],
)
const visibleSelectedId = filteredListings.some((listing) => listing.id === selectedId)
? selectedId
: null
function resetFilters() {
setMaxRent(4000)
setMinBedrooms(0)
setPropertyType('All')
setSelectedId(null)
}
return (
<div className="app-shell">
<header className="hero">
<p className="eyebrow">Property Atlas</p>
<h1>Find a home without losing the map.</h1>
<p>Browse fictional Seattle listings backed by relational Supabase data.</p>
</header>
<section className="filters" aria-label="Listing filters">
<label>
<span>Max rent: ${maxRent.toLocaleString()}</span>
<input type="range" min="1800" max="4000" step="100" value={maxRent} onChange={(event) => setMaxRent(Number(event.target.value))} />
</label>
<label>
<span>Minimum bedrooms</span>
<select value={minBedrooms} onChange={(event) => setMinBedrooms(Number(event.target.value))}>
<option value="0">Any</option>
<option value="1">1+</option>
<option value="2">2+</option>
<option value="3">3+</option>
</select>
</label>
<label>
<span>Property type</span>
<select value={propertyType} onChange={(event) => setPropertyType(event.target.value as PropertyType | 'All')}>
{PROPERTY_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</label>
<button className="secondary-button" type="button" onClick={resetFilters}>Reset filters</button>
</section>
{loading && <p className="status">Loading listings...</p>}
{error && <p className="status error">{error}</p>}
{!loading && !error && (
<main className="marketplace-layout">
<section className="results-panel">
<div className="results-heading">
<div><p className="eyebrow">Available now</p><h2>{filteredListings.length} matching properties</h2></div>
<span className="result-pill">Public demo inventory</span>
</div>
<div className="listing-grid">
{filteredListings.map((listing) => {
const isSelected = listing.id === visibleSelectedId
return (
<button className={`listing-card${isSelected ? ' selected' : ''}`} key={listing.id} type="button" onClick={() => setSelectedId(listing.id)}>
<div className="card-topline"><span>{listing.property.property_type}</span><strong>${listing.monthly_rent.toLocaleString()}/mo</strong></div>
<h3>{listing.property.title}</h3>
<p>{listing.property.city} · {listing.property.bedrooms} bedroom{listing.property.bedrooms === 1 ? '' : 's'}</p>
<dl>
<div><dt>Owner</dt><dd>{listing.property.owner?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Agent</dt><dd>{listing.agent?.full_name ?? 'Unassigned'}</dd></div>
<div><dt>Available</dt><dd>{listing.available_from}</dd></div>
</dl>
</button>
)
})}
</div>
{filteredListings.length === 0 && <div className="empty-state"><h3>No exact matches</h3><p>Increase the rent limit or reset a filter.</p></div>}
</section>
<section className="map-panel" aria-label="Map of matching properties">
<MapContainer center={DEMO_CENTER} zoom={12} scrollWheelZoom={true} className="map">
<TileLayer attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" />
{filteredListings.map((listing) => {
const isSelected = listing.id === visibleSelectedId
return (
<CircleMarker
key={listing.id}
center={[listing.property.latitude, listing.property.longitude]}
radius={isSelected ? 13 : 9}
pathOptions={{ color: isSelected ? '#172554' : '#0f766e', fillColor: isSelected ? '#f59e0b' : '#14b8a6', fillOpacity: 0.9, weight: 3 }}
eventHandlers={{ click: () => setSelectedId(listing.id) }}
>
<Popup><strong>{listing.property.title}</strong><br />${listing.monthly_rent.toLocaleString()}/month</Popup>
</CircleMarker>
)
})}
</MapContainer>
</section>
</main>
)}
</div>
)
}
export default App
Test every synchronization path
Filter changes can remove a selected listing from view. Resetting the filters should also clear the stored selection.
- Click Reset filters.
You should see four cards. You should also see four markers.
- Set Minimum bedrooms to 2+.
- Select House in the Property type field.
You should see one card. You should also see one marker.
- Click Reset filters.
You should see all four results again. No card or marker should remain selected.
Before you build, do you expect TypeScript and bundling to accept the shared filtering and selection pipeline?
- Switch back to the VS Code terminal panel from earlier.
- Run the production build with this command:
npm run build
You should see the production build complete successfully. The terminal should finish without TypeScript or bundling errors.
You fixed the integration defect. Cards and markers now behave like two views of one marketplace.
Production build failing?
Check the first file path reported by the build. Compare that section with the full src/App.tsx reference above.
Confirm every isSelected declaration sits inside its own map() callback.
help me debug my property marketplace production build
Secret mission
Add Explainable Recommendations
Turn the synchronized marketplace into a transparent recommendation experience. You will score visible properties using only the visitor's selected budget, bedroom count, and property type. Every score will include plain-language reasons that a visitor can inspect.
Clean Up Your Resources
Clean Up Your Resources
Your local property-marketplace workspace and Supabase Free project remain after this session. The current setup stays at $0/month while your organization remains on the Free Plan and usage stays within its included limits.
Cost warning
Your current project has no ongoing charge within the Supabase Free Plan limits. Free projects can pause after one week of inactivity.
- Keep your Supabase organization on the Free Plan.
- Avoid adding paid services to this project.
Resources you used:
- A Vite development process in PowerShell, if it is still running.
- A local property-marketplace workspace containing the application files.
- A generated node_modules directory inside the local workspace.
- One Supabase Free project containing the fictional relational database and read-only RLS policies.
No Map Resource to Delete
The OpenStreetMap Standard tile service creates no account or stored map resource for this project. Its availability is best-effort.
- Use the Standard tiles only for normal interactive viewing.
- Avoid scraping the tile service.
- Avoid prefetching tiles.
- Avoid offline use.
Keep everything running
Keep the Supabase backend and local workspace if you want to continue building the marketplace. The Vite server can stop without deleting either resource.
- Stop the Vite development server by pressing Ctrl+C in the PowerShell window from earlier.
- Leave the Supabase project active for future marketplace work.
- Keep the property-marketplace workspace on your computer.
Your database and code remain available. Supabase may pause the Free project after one week of inactivity.
Pause - I'll come back to this later
Pause the hosted backend while keeping its data for later. Your local workspace stays on your computer.
- Stop the Vite development server by pressing Ctrl+C in the PowerShell window from earlier.
- Return to the signed-in Supabase Dashboard session from earlier.
- Select the project you used for Property Atlas.
- Select Settings.
- Select General.
- Find Project availability.
- Select Pause Project.
You will see the project enter a paused state. That safely parks the backend while your database remains available for a later resume.
Delete - I don't want to use this again
Remove both persistent resources when you want to start fresh. This path deletes the hosted database and local workspace.
Deletion Is Permanent
Permanent deletion can feel risky. Deleting the Supabase project removes its database content for good.
Deleting the local workspace removes your source files and local environment configuration.
- Stop the Vite development server by pressing Ctrl+C in the PowerShell window from earlier.
- Return to the signed-in Supabase Dashboard session from earlier.
- Select the project you used for Property Atlas.
- Select Settings.
- Select General.
The permanent project controls are now in view. The next confirmation removes the hosted backend.
- Select Delete project.
- Select Delete Project.
- Enter the exact project name shown in the confirmation window.
- Select Delete to confirm.
You should see the project leave your Dashboard. That clears the hosted half of the marketplace.
- Press the Windows key to open search.
- Type File Explorer.
- Press Enter.
- Use File Explorer to locate the property-marketplace folder from this project.
- Right-click the property-marketplace folder.
- Select Delete.
You should no longer see the workspace in its previous location. You have now cleared the local half of the marketplace too.
Nice Work!
Nice Work!
You did it! Your React plus TypeScript property marketplace now turns relational Supabase records into cards that stay aligned with OpenStreetMap markers.
You've learned how to:
- Build a relational marketplace dataset with explicit foreign keys. Protect its fictional records through read-only Row Level Security policies.
- Turn visitor preferences into one derived result set. Use that collection to synchronize the card grid with the map. Preserve the same selection across both views.
- Load active inventory through a browser-safe Supabase client. Prove the final app is ready to bundle through a successful Vite production build.
- Secret Mission: Score visible properties with an explainable recommendation model. Show the budget reason. Show the bedroom reason. Show the property-type reason. Rank the shared results by score.
Ready to quiz yourself?