Hello from MCP server
<template>
<BaseLayout title="Directory">
<div class="directory-container">
<h1 class="page-title">What are we doing today?</h1>
<SearchBar
v-if="hasAnyInput"
title=""
button-text="Search"
:search-terms="searchTerms"
:selected-tags="selectedTags"
:available-tags="availableTags"
:suggestions="suggestions"
:show-clear="hasAnyInput"
:show-tags="hasCategoryOrTagFilter"
:category="selectedCategory"
:top-level="selectedTopLevel"
:quick-access-items="hasTopLevelOnly ? quickAccessProblems : []"
navigate-path="/directory"
@navigate="handleNavigate"
@search-input="handleSearchInput"
@quick-access-click="selectProblem"
/>
<div v-if="loading" class="loading-message">
Loading directory...
</div>
<template v-else>
<!-- Breadcrumb navigation -->
<div v-if="hasAnyInput" class="breadcrumb-row">
<div class="breadcrumbs">
<!-- Text search breadcrumb -->
<template v-if="hasTextSearch">
<span class="breadcrumb-item search-crumb">
Results for "{{ searchTerms.join(' ') }}"
</span>
</template>
<!-- Category/tag breadcrumbs -->
<template v-else>
<span
v-if="selectedTopLevelName"
class="breadcrumb-item clickable"
@click="clickTopLevelBreadcrumb"
>
{{ selectedTopLevelName }}
</span>
<span
v-if="selectedCategoryName"
class="breadcrumb-item clickable"
@click="clickCategoryBreadcrumb"
>
{{ selectedCategoryName }}
</span>
<span
v-for="(tag, index) in selectedTags"
:key="tag.id"
class="breadcrumb-item clickable"
@click="clickTagBreadcrumb(index)"
>
{{ tag.name }}
</span>
</template>
</div>
</div>
<!-- Text search: show searchResults -->
<template v-if="hasTextSearch">
<div v-if="directoryData.searchResults.length === 0" class="no-results-message">
<p>No jobs match your search.</p>
</div>
<div v-else class="search-results">
<SearchResult
v-for="problem in directoryData.searchResults"
:key="problem.id"
:title="problem.name || ''"
:description="problem.description || ''"
@click="selectProblem(problem)"
/>
</div>
</template>
<!-- Category/tag filtering: show categoryView -->
<template v-else-if="hasCategoryOrTagFilter">
<div v-if="categoryViewProblems.length === 0" class="no-results-message">
<p>No jobs found in this category.</p>
</div>
<div v-else class="search-results">
<SearchResult
v-for="problem in categoryViewProblems"
:key="problem.id"
:title="problem.name || ''"
:description="problem.description || ''"
@click="selectProblemFromCategory(problem)"
/>
</div>
</template>
<!-- Top-level selected but Plumbing: show database categories -->
<template v-else-if="hasTopLevelOnly && selectedTopLevel === '_plumbing'">
<div v-if="sortedCategories.length === 0" class="no-results-message">
<p>No categories found.</p>
</div>
<div v-else class="categories-list">
<div
v-for="category in sortedCategories"
:key="category.id"
class="category-item"
@click="selectCategory(category)"
>
<span class="category-name">{{ category.name }}</span>
</div>
</div>
</template>
<!-- Top-level selected but not Plumbing: coming soon -->
<template v-else-if="hasTopLevelOnly">
<div class="no-results-message">
<p>{{ selectedTopLevelName }} coming soon.</p>
</div>
</template>
<!-- No input: show top-level categories as 2x2 grid -->
<template v-else>
<div class="top-level-grid">
<div
v-for="category in TOP_LEVEL_CATEGORIES"
:key="category.id"
:class="['top-level-card', { 'top-level-card-disabled': !category.enabled }]"
@click="category.enabled && selectTopLevel(category)"
>
<span class="top-level-name">{{ category.name }}</span>
<button
class="top-level-button"
:disabled="!category.enabled"
@click.stop="category.enabled && selectTopLevel(category)"
>
Access Menu Pricing
</button>
</div>
</div>
</template>
</template>
</div>
</BaseLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import BaseLayout from "@/components/BaseLayout.vue";
import SearchBar from "@/components/SearchBar.vue";
import SearchResult from "@/components/SearchResult.vue";
import { loadDirectory, type DirectoryData, type ProblemWithTagNames } from "@/framework/directory";
import type { ProblemCategoriesRecord, ProblemsRecord } from "@/pocketbase-types";
import { useTnfrStore } from "@/stores/tnfr";
const router = useRouter();
const route = useRoute();
const tnfrStore = useTnfrStore();
const loading = ref(true);
const directoryData = ref<DirectoryData>({
problems: [],
tags: [],
categories: [],
searchResults: [],
categoryView: [],
availableTags: [],
});
// HACK: Hard-coded top-level categories (will be in database later)
const TOP_LEVEL_CATEGORIES = [
{ id: '_plumbing', name: 'Plumbing', enabled: true },
{ id: '_electrical', name: 'Electrical', enabled: false },
{ id: '_hvac_service', name: 'HVAC Service', enabled: false },
{ id: '_hvac_equipment', name: 'HVAC Equipment', enabled: false },
];
// Fallback quick access problem refIds (used if speedDial has < 5 items)
const FALLBACK_QUICK_ACCESS_REFIDS = [
'PE3_problem',
'PC1_problem',
'PA1_problem',
'PH7_problem',
'PG14_problem',
];
// Filter problems for quick access chips - use speedDial or fallback to hardcoded
const quickAccessProblems = computed(() => {
const refIds = tnfrStore.speedDial.length >= 5
? tnfrStore.speedDial.slice(0, 5)
: FALLBACK_QUICK_ACCESS_REFIDS;
return refIds
.map(refId => directoryData.value.problems.find(p => p.refId === refId))
.filter((p): p is ProblemsRecord => p !== undefined)
.map(p => ({ id: p.id, name: p.name || 'Unnamed', refId: p.refId }));
});
const selectedTopLevel = computed(() => {
return route.query.top?.toString() || null;
});
const selectedTopLevelName = computed(() => {
if (!selectedTopLevel.value) return null;
const cat = TOP_LEVEL_CATEGORIES.find(c => c.id === selectedTopLevel.value);
return cat?.name || null;
});
// Parse URL query params
const searchTerms = computed(() => {
const q = route.query.q?.toString() || '';
return q ? q.split(' ').filter(t => t.trim()) : [];
});
const selectedCategory = computed(() => {
return route.query.category?.toString() || null;
});
const selectedCategoryName = computed(() => {
if (!selectedCategory.value) return null;
const category = directoryData.value.categories.find(c => c.id === selectedCategory.value);
return category?.name || null;
});
const selectedTags = computed(() => {
const tagsParam = route.query.tags?.toString() || '';
if (!tagsParam) return [];
const tagNames = tagsParam.split(',').filter(n => n.trim());
return tagNames
.map(name => {
const tag = directoryData.value.tags.find(t => t.name === name);
return tag ? { id: tag.id, name: tag.name || '' } : null;
})
.filter(t => t !== null) as { id: string; name: string }[];
});
// State checks
const hasTextSearch = computed(() => searchTerms.value.length > 0);
const hasCategoryOrTagFilter = computed(() => selectedCategory.value !== null || selectedTags.value.length > 0);
const hasTopLevelOnly = computed(() => selectedTopLevel.value !== null && !hasCategoryOrTagFilter.value);
const hasAnyInput = computed(() => hasTextSearch.value || hasCategoryOrTagFilter.value || selectedTopLevel.value !== null);
// Flatten categoryView into a single list of problems, sorted by name
const categoryViewProblems = computed(() => {
const problems: ProblemWithTagNames[] = [];
for (const category of directoryData.value.categoryView) {
problems.push(...category.problems);
}
return problems.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
});
// Categories sorted by name
const sortedCategories = computed(() => {
return [...directoryData.value.categories].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
});
// Available tags from framework.directory (sorted by information gain, top 5)
const availableTags = computed(() => {
return directoryData.value.availableTags.slice(0, 5);
});
// Suggestions based on search input
const currentSearchInput = ref('');
const suggestions = computed(() => {
if (currentSearchInput.value.length < 2) return [];
const query = currentSearchInput.value.toLowerCase();
const results: { id: string; name: string; type: 'tag' | 'job' }[] = [];
const selectedIds = selectedTags.value.map(t => t.id);
// Matching tags
for (const tag of directoryData.value.tags) {
if (tag.name?.toLowerCase().includes(query) && !selectedIds.includes(tag.id)) {
results.push({ id: tag.id, name: tag.name || '', type: 'tag' });
}
}
// Matching jobs
for (const problem of directoryData.value.problems) {
if (problem.name?.toLowerCase().includes(query)) {
results.push({ id: problem.id, name: problem.name || '', type: 'job' });
}
}
return results.slice(0, 10);
});
const selectTopLevel = (topLevel: { id: string; name: string }) => {
router.push(`/directory?top=${topLevel.id}`);
};
const selectCategory = (category: ProblemCategoriesRecord) => {
// Preserve top-level selection when selecting a sub-category
if (selectedTopLevel.value) {
router.push(`/directory?top=${selectedTopLevel.value}&category=${category.id}`);
} else {
router.push(`/directory?category=${category.id}`);
}
};
const selectProblem = (problem: ProblemsRecord) => {
router.push(`/job/${problem.id}`);
};
const selectProblemFromCategory = (problem: ProblemWithTagNames) => {
router.push(`/job/${problem.id}`);
};
const handleNavigate = (url: string) => {
router.push(url);
};
const handleSearchInput = (query: string) => {
currentSearchInput.value = query;
};
// Breadcrumb navigation
const clickTopLevelBreadcrumb = () => {
// Remove category and tags, keep only top-level
router.push(`/directory?top=${selectedTopLevel.value}`);
};
const clickCategoryBreadcrumb = () => {
// Remove all tags, keep top-level and category
if (selectedTopLevel.value) {
router.push(`/directory?top=${selectedTopLevel.value}&category=${selectedCategory.value}`);
} else {
router.push(`/directory?category=${selectedCategory.value}`);
}
};
const clickTagBreadcrumb = (index: number) => {
// Keep top-level, category, and tags up to and including the clicked index
const tagsToKeep = selectedTags.value.slice(0, index + 1);
const tagNames = tagsToKeep.map(t => t.name).join(',');
let url = '/directory?';
if (selectedTopLevel.value) {
url += `top=${selectedTopLevel.value}&`;
}
if (selectedCategory.value) {
url += `category=${selectedCategory.value}&`;
}
url += `tags=${tagNames}`;
router.push(url);
};
const clearAll = () => {
router.push('/directory');
};
// Reload directory data when URL changes
const reloadDirectory = async () => {
loading.value = true;
try {
const text = searchTerms.value.join(' ') || null;
const tagIds = selectedTags.value.map(t => t.id);
directoryData.value = await loadDirectory({
text,
category: selectedCategory.value,
tags: tagIds,
});
} finally {
loading.value = false;
}
};
watch(() => route.query, reloadDirectory, { deep: true });
onMounted(async () => {
await tnfrStore.loadSpeedDial();
await reloadDirectory();
});
</script>
<style scoped>
.directory-container {
padding: 20px;
padding-bottom: 500px;
max-width: 1200px;
margin: 0 auto;
}
.page-title {
margin: 0 0 24px 0;
color: var(--ion-text-color);
font-size: 48px;
font-weight: 700;
text-align: center;
font-variant: small-caps;
}
.loading-message,
.no-results-message {
text-align: center;
padding: 40px 20px;
color: var(--ion-color-medium);
font-size: 16px;
}
.categories-list {
display: flex;
flex-direction: column;
gap: 0;
max-width: 1000px;
margin: 0 auto;
}
.search-results {
display: flex;
flex-direction: column;
gap: 0;
max-width: 1000px;
margin: 0 auto;
}
.search-results > *:nth-child(odd) {
background-color: var(--ion-color-light);
}
.category-item {
cursor: pointer;
padding: 16px;
transition: background-color 0.1s ease;
}
.category-item:nth-child(odd) {
background-color: var(--ion-color-light);
}
.category-item:hover {
background-color: rgba(var(--ion-color-primary-rgb), 0.15);
}
.category-name {
font-size: 18px;
font-weight: 500;
color: var(--ion-text-color);
line-height: 1.3;
}
/* Top-level categories 2x2 grid */
.top-level-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
max-width: 1000px;
margin: 0 auto;
}
.top-level-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 32px 24px;
background: var(--ion-color-light);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
min-height: 160px;
}
.top-level-card:hover:not(.top-level-card-disabled) {
background: rgba(var(--ion-color-primary-rgb), 0.1);
border-color: var(--ion-color-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.top-level-card-disabled {
background: var(--ion-color-light-shade);
cursor: default;
opacity: 0.6;
}
.top-level-card-disabled .top-level-name {
color: var(--ion-color-medium);
}
.top-level-card-disabled .top-level-button {
background: var(--ion-color-medium);
cursor: default;
}
.top-level-name {
font-size: 22px;
font-weight: 600;
color: var(--ion-text-color);
text-align: center;
}
/* Quick access chips */
.quick-access-section {
max-width: 1000px;
margin: 0 auto 24px;
}
.quick-access-chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.quick-access-chip {
display: inline-block;
padding: 8px 16px;
background: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
border-radius: 20px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.quick-access-chip:hover {
opacity: 0.85;
transform: translateY(-1px);
}
.top-level-button {
padding: 10px 20px;
background: var(--ion-color-success);
color: var(--ion-color-success-contrast);
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
}
.top-level-button:hover {
opacity: 0.85;
}
.breadcrumb-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
max-width: 1000px;
margin: 0 auto 16px;
padding: 12px 16px;
background: var(--ion-color-light);
border-radius: 8px;
}
.breadcrumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.breadcrumb-item {
display: inline-flex;
align-items: center;
position: relative;
padding: 6px 12px;
margin-left: 16px;
background: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
border-radius: 16px;
font-size: 14px;
font-weight: 500;
}
.breadcrumb-item:first-child {
margin-left: 0;
}
.breadcrumb-item + .breadcrumb-item::before {
content: "›";
position: absolute;
left: -14px;
color: var(--ion-color-medium);
font-size: 16px;
font-weight: bold;
}
.breadcrumb-item.clickable {
cursor: pointer;
transition: opacity 0.2s ease;
}
.breadcrumb-item.clickable:hover {
opacity: 0.8;
}
.breadcrumb-item.search-crumb {
background: var(--ion-color-medium);
color: var(--ion-color-medium-contrast);
}
.clear-button {
padding: 6px 16px;
background: var(--ion-color-danger);
color: var(--ion-color-danger-contrast);
border: none;
border-radius: 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s ease;
flex-shrink: 0;
}
.clear-button:hover {
opacity: 0.8;
}
/* Mobile adjustments */
@media (max-width: 767px) {
.directory-container {
padding: 16px;
}
.page-title {
font-size: 36px;
}
.category-name {
font-size: 18px;
}
.top-level-grid {
gap: 12px;
max-width: 100%;
}
.top-level-card {
padding: 24px 16px;
min-height: 140px;
}
.top-level-name {
font-size: 18px;
}
.top-level-button {
padding: 8px 16px;
font-size: 13px;
}
}
</style>