Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/lib/components/EmailUpdates/EmailUpdatesForm.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script>
import Grid from '$lib/components/Grid.svelte';
import { page } from '$app/stores';
import { EMAIL_CATEGORIES, createEmailUpdatesForm } from '$lib/email-updates.js';

export let defaultCategory;
Expand All @@ -10,6 +11,12 @@
const { email, car, selectedCategories, status, errorMessage, submit } = createEmailUpdatesForm();

let everything = true;
let seededCar;

$: if (askForCar && $page.url.searchParams.get('car') !== (seededCar ?? null)) {
seededCar = $page.url.searchParams.get('car');
if (seededCar) car.set(seededCar);
}

$: primaryCategory = EMAIL_CATEGORIES.find(({ key }) => key === defaultCategory);
$: $selectedCategories = everything ? EMAIL_CATEGORIES.map(({ key }) => key) : [defaultCategory];
Expand Down
16 changes: 5 additions & 11 deletions src/lib/components/HarnessSelector/HarnessSelector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { allHarnesses, vehicleHarnesses, genericHarnesses } from '$lib/utils/harnesses';
import { selectedCar } from '../../../store';
import { NO_HARNESS_OPTION } from '$lib/constants/vehicles.js';
import { matchesSearch } from '$lib/utils/carSearch.js';

import NoteCard from '$lib/components/NoteCard.svelte';
import DropdownItem from './HarnessDropdownItem.svelte';
Expand Down Expand Up @@ -78,20 +79,13 @@
}
}

// Normalize diacritics for matching (e.g., "Škoda" -> "Skoda")
function normalizeDiacritics(str) {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}

/* Filtered Dropdown */
let inputValue = "";
let inputRef;

$: searchTerms = normalizeDiacritics(inputValue.toLowerCase()).split(/\s+/).filter(Boolean);
$: filteredItems = $harnesses.filter(item => {
const car = normalizeDiacritics(`${item.car} ${item.yearList ?? ''}`.toLowerCase()); // add all years in range
return searchTerms.every(term => car.includes(term));
});
$: filteredItems = $harnesses.filter(item =>
matchesSearch(`${item.car} ${item.yearList ?? ''}`, inputValue) // add all years in range
);

const handleClear = () => {
// clear search input or close menu
Expand Down Expand Up @@ -162,7 +156,7 @@
<span class="chevron">{@html ChevronIcon}</span>
</div>
<div class="dropdown-content" class:show={menuOpen}>
{#if searchTerms.length > 0}
{#if inputValue.trim() !== ''}
{#if filteredItems.length > 0}
{#each filteredItems as item}
<DropdownItem value={item} on:click={() => handleOptionClick(item)} on:keydown={(e) => handleOptionKeyDown(e, item)} />
Expand Down
13 changes: 13 additions & 0 deletions src/lib/utils/carSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Normalize diacritics for matching (e.g., "Škoda" -> "Skoda")
function normalizeDiacritics(str) {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}

// Every word has to match, in any order
export function matchesSearch(text, query) {
const haystack = normalizeDiacritics(text.toLowerCase());
return normalizeDiacritics(query.toLowerCase())
.split(/\s+/)
.filter(Boolean)
.every(term => haystack.includes(term));
}
149 changes: 142 additions & 7 deletions src/routes/vehicles/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,28 @@

import YoutubeIcon from '$lib/icons/social/youtube.svg?raw';
import InfoIcon from '$lib/icons/ui/info.svg?raw';
import CloseIcon from '$lib/icons/ui/close.svg?raw';
import CarIcon from '$lib/icons/features/car.svg?raw';

import CommaFourImage from '$lib/images/products/comma-four/four_screen_on.png';

import { FOUR_PRICE, FOUR_STRIKETHROUGH_PRICE, FOUR_SALE } from '$lib/constants/prices.js';
import { vehicleCountText } from '$lib/constants/vehicles.js';

import { matchesSearch } from '$lib/utils/carSearch.js';

const brand_images = import.meta.glob('$lib/images/vehicles/brand-icons/*.png', { eager: true });

let filter = '';
const total = Object.values(vehicles).flat().length;

// Unmatched cars are hidden with CSS
$: matches = new Set(
Object.values(vehicles)
.flat()
.filter(car => matchesSearch(`${car.name} ${car.year_list.replaceAll(',', '')}`, filter))
.map(car => car.name)
);
</script>

<div class="vehicles-cover-image"></div>
Expand Down Expand Up @@ -106,20 +120,44 @@

<section class="light" id="compatibility-chart">
<div class="container" style="width:85%; max-width: 60rem">
<p class="last-updated">Last updated: {compatibilityMeta.last_updated}</p>
<div class="filter-card">
<div class="filter-head">
<label class="filter-label" for="vehicle-filter">Find your car</label>
<span class="filter-meta">
<strong class="count" class:searching={filter.trim()}>{matches.size}/{total} &middot;</strong>
updated {compatibilityMeta.last_updated}
</span>
</div>
<div class="filter-field">
<input
id="vehicle-filter"
class="vehicle-filter"
type="search"
placeholder="e.g. 2023 camry"
autocomplete="off"
bind:value={filter}
>
{#if filter}
<button class="clear" type="button" aria-label="Clear filter" on:click={() => filter = ''}>
{@html CloseIcon}
</button>
{/if}
</div>
</div>

{#each Object.entries(vehicles) as [make, cars]}
{#if cars.length !== 0}
{@const brand_img_path = `/src/lib/images/vehicles/brand-icons/Logo-${make}.png`}
<div id={make.toLowerCase()} class="car-make-header">
{@const matchCount = cars.filter(car => matches.has(car.name)).length}
<div id={make.toLowerCase()} class="car-make-header" class:filtered-out={matchCount === 0}>
{#if brand_images[brand_img_path]}
<img src={brand_images[brand_img_path].default} alt="{make} car brand" />
{/if}
<h3>{make} <span class="muted">({cars.length})</span></h3>
<h3>{make} <span class="muted">({matchCount})</span></h3>
</div>

{#each cars as car_info}
<div class="car-row">
<div class="car-row" class:filtered-out={!matches.has(car_info.name)}>
<Accordion backgroundColor="var(--color-card-background)">
<div slot="label">
<div class="car-details-wrapper">
Expand Down Expand Up @@ -196,6 +234,13 @@
{/each}
{/if}
{/each}

{#if matches.size === 0}
<div class="no-matches">
<strong>openpilot doesn't support {filter} yet</strong>
<a href="/vehicles?car={encodeURIComponent(filter)}#email-updates">Get an email when it does &rarr;</a>
</div>
{/if}
</div>
</section>

Expand Down Expand Up @@ -308,10 +353,95 @@
}
}

.last-updated {
text-align: center;
.filter-card {
margin: 3rem 0;
padding: 3rem;
background-color: var(--color-card-background);
border: 1px solid rgba(0, 0, 0, .4);
}

@media screen and (max-width: 768px) {
.filter-card {
padding: 2rem 1rem;
}
}

.filter-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.25rem 1rem;
margin-bottom: 0.5rem;
}

.filter-label {
font-size: 1.125rem;
font-weight: 700;
}

.filter-meta {
color: rgba(0, 0, 0, 0.55);
font-size: 1rem;
font-style: italic;
margin-bottom: 1rem;
}

.filter-meta .count {
color: #000;
font-size: 1.125rem;
font-style: normal;
font-weight: 700;
opacity: 0;
transition: opacity 0.1s;
}

.filter-meta .count.searching {
opacity: 1;
}

.filter-field {
position: relative;
}

.vehicle-filter {
box-sizing: border-box;
width: 100%;
padding: 1rem 3rem 1rem 1rem;
color: #000;
font: inherit;
background: #fff;
border: 1px solid #000;

&:focus-visible {
outline: 2px solid #000;
outline-offset: -3px;
}

&::-webkit-search-cancel-button {
display: none;
}
}

.clear {
position: absolute;
top: 0;
right: 13px;
height: 100%;
padding: 0;
cursor: pointer;
background-color: transparent;
border: none;
}

.no-matches {
display: grid;
gap: 0.5rem;
justify-items: center;
margin-top: 2rem;
padding: 2rem;
text-align: center;
background-color: var(--color-card-background);
border: 1px solid rgba(0, 0, 0, 0.4);
}

/* TODO: extract shared card class */
Expand Down Expand Up @@ -516,4 +646,9 @@
#faq {
margin-bottom: 3rem;
}

/* Last, so it beats the `display` the rows and make headers set for themselves */
.filtered-out {
display: none;
}
</style>
Loading