feat: add craft tags and community connections to directory and profiles

Update member directory and public profile APIs to include craftTags
and communityConnections with privacy-aware filtering. Directory now
uses predefined tags from the Tag model for filter bars and supports
craftTag/connectionTag query filters. Frontend shows craft tag pills
and cooperative topics with state labels, falling back to old
offering/lookingFor fields. Add Connections nav item.
This commit is contained in:
Jennie Robinson Faber 2026-04-05 16:40:10 +01:00
parent bd07172093
commit 896de2e7fd
5 changed files with 367 additions and 138 deletions

View file

@ -173,6 +173,7 @@ const youItems = [
const exploreItems = [
{ label: "Events", path: "/events" },
{ label: "Members", path: "/members" },
{ label: "Connections", path: "/connections" },
{ label: "Wiki", path: "/wiki" },
{ label: "About", path: "/about" },
];

View file

@ -60,40 +60,42 @@
</p>
</div>
<!-- Offering Section -->
<div
v-if="member.offering?.tags?.length || member.offering?.text"
class="profile-section"
>
<div class="section-label">Offering</div>
<div v-if="member.offering.tags?.length" class="tag-list">
<!-- What I Do (craft tags, falling back to offering) -->
<div v-if="craftTagsDisplay.length > 0 || member.offering?.text" class="profile-section">
<div class="section-label">What I Do</div>
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
<span
v-for="tag in member.offering.tags"
v-for="tag in craftTagsDisplay"
:key="tag"
class="tag-pill"
>{{ tag }}</span
>{{ tagLabel('craft', tag) }}</span
>
</div>
<p v-if="member.offering.text" class="profile-detail offering-text">
<p v-if="member.offering?.text" class="profile-detail offering-text">
{{ member.offering.text }}
</p>
</div>
<!-- Looking For Section -->
<!-- Community Connections (cooperative topics with states, falling back to lookingFor) -->
<div
v-if="member.lookingFor?.tags?.length || member.lookingFor?.text"
v-if="connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
class="profile-section"
>
<div class="section-label">Looking for</div>
<div v-if="member.lookingFor.tags?.length" class="tag-list">
<div class="section-label">Community Connections</div>
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
<span
v-for="tag in member.lookingFor.tags"
:key="tag"
class="tag-pill"
>{{ tag }}</span
v-for="topic in connectionTopicsDisplay"
:key="topic.tagSlug || topic"
class="tag-pill connection-pill"
>
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ tagLabel('cooperative', topic.tagSlug || topic) }}
</span>
</div>
<p v-if="member.lookingFor.text" class="profile-detail looking-text">
<p v-if="member.communityConnections?.details" class="profile-detail connection-details">
{{ member.communityConnections.details }}
</p>
<p v-else-if="member.lookingFor?.text" class="profile-detail looking-text">
{{ member.lookingFor.text }}
</p>
</div>
@ -150,10 +152,10 @@
</div>
</div>
<!-- Peer Support Section -->
<div v-if="member.peerSupport?.enabled" class="profile-section">
<!-- Peer Support Section (reads from communityConnections, falls back to peerSupport) -->
<div v-if="showPeerSupport" class="profile-section">
<div class="section-label">Peer Support</div>
<div v-if="member.peerSupport.skillTopics?.length" class="peer-group">
<div v-if="member.peerSupport?.skillTopics?.length" class="peer-group">
<span class="peer-label">Skills:</span>
<div class="tag-list">
<span
@ -164,7 +166,7 @@
>
</div>
</div>
<div v-if="member.peerSupport.supportTopics?.length" class="peer-group">
<div v-if="member.peerSupport?.supportTopics?.length" class="peer-group">
<span class="peer-label">Topics:</span>
<div class="tag-list">
<span
@ -175,8 +177,8 @@
>
</div>
</div>
<p v-if="member.peerSupport.availability" class="profile-detail">
{{ member.peerSupport.availability }}
<p v-if="peerAvailability" class="profile-detail">
{{ peerAvailability }}
</p>
</div>
@ -233,6 +235,15 @@ const circleLabels = {
practitioner: "Practitioner",
};
// State display text mapping
const stateLabels = {
help: "Can help",
interested: "Interested",
seeking: "Need help",
};
const stateLabel = (state) => stateLabels[state] || state || "";
const getInitials = (name) => {
if (!name) return "?";
return name
@ -246,6 +257,11 @@ const getInitials = (name) => {
// Fetch member data no await so the component renders immediately (no Suspense)
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
// Fetch tags for slug-to-label lookup
const { data: tagsData } = useFetch("/api/tags", {
default: () => ({ tags: [] }),
});
// Fetch public activity
const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
params: { limit: 5 },
@ -267,6 +283,56 @@ const formatRelativeDate = (date) => {
}
const member = computed(() => data.value?.member || null);
// Tag label lookup
const tagLabel = (pool, slug) => {
const tags = tagsData.value?.tags || [];
const found = tags.find((t) => t.slug === slug && t.pool === pool);
return found ? found.label : slug;
};
// Craft tags display: new field, falling back to offering.tags
const craftTagsDisplay = computed(() => {
if (!member.value) return [];
if (member.value.craftTags && member.value.craftTags.length > 0) {
return member.value.craftTags;
}
return member.value.offering?.tags || [];
});
// Connection topics display: new field, falling back to lookingFor.tags
const connectionTopicsDisplay = computed(() => {
if (!member.value) return [];
if (
member.value.communityConnections?.topics &&
member.value.communityConnections.topics.length > 0
) {
return member.value.communityConnections.topics;
}
if (member.value.lookingFor?.tags && member.value.lookingFor.tags.length > 0) {
return member.value.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null }));
}
return [];
});
// Peer support: check both new communityConnections and old peerSupport
const showPeerSupport = computed(() => {
if (!member.value) return false;
return (
member.value.communityConnections?.offerPeerSupport ||
member.value.peerSupport?.enabled
);
});
// Peer availability: prefer new field, fall back to old
const peerAvailability = computed(() => {
if (!member.value) return "";
return (
member.value.communityConnections?.availability ||
member.value.peerSupport?.availability ||
""
);
});
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
watch(
member,
@ -442,7 +508,8 @@ useHead({
}
.offering-text,
.looking-text {
.looking-text,
.connection-details {
margin-top: 8px;
}
@ -462,6 +529,19 @@ useHead({
white-space: nowrap;
}
.connection-pill {
display: inline-flex;
align-items: center;
gap: 4px;
}
.connection-state {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint);
}
/* ---- SOCIAL LINKS ---- */
.social-links {
display: flex;

View file

@ -41,64 +41,64 @@
>
</div>
<!-- Skills Filter -->
<!-- Craft Tags Filter -->
<div
v-if="availableSkills && availableSkills.length > 0"
v-if="craftTagOptions.length > 0"
class="skills-bar"
>
<span class="tag-label">Skills:</span>
<span class="tag-label">Craft:</span>
<button
v-for="skill in (availableSkills || []).slice(
v-for="tag in craftTagOptions.slice(
0,
showAllSkills ? undefined : 10,
showAllCraftTags ? undefined : 10,
)"
:key="skill"
:key="tag.slug"
type="button"
class="skill-tag"
:class="{ active: selectedSkills.includes(skill) }"
@click="toggleSkill(skill)"
:class="{ active: selectedCraftTags.includes(tag.slug) }"
@click="toggleCraftTag(tag.slug)"
>
{{ skill }}
{{ tag.label }}
</button>
<button
v-if="availableSkills && availableSkills.length > 10"
v-if="craftTagOptions.length > 10"
type="button"
class="more-btn"
@click="showAllSkills = !showAllSkills"
@click="showAllCraftTags = !showAllCraftTags"
>
{{
showAllSkills ? "Show less" : `+${availableSkills.length - 10} more`
showAllCraftTags ? "Show less" : `+${craftTagOptions.length - 10} more`
}}
</button>
</div>
<!-- Topics Filter -->
<!-- Connection Tags Filter -->
<div
v-if="availableTopics && availableTopics.length > 0"
v-if="connectionTagOptions.length > 0"
class="skills-bar"
>
<span class="tag-label">Topics:</span>
<button
v-for="topic in (availableTopics || []).slice(
v-for="tag in connectionTagOptions.slice(
0,
showAllTopics ? undefined : 10,
showAllConnectionTags ? undefined : 10,
)"
:key="topic"
:key="tag.slug"
type="button"
class="skill-tag"
:class="{ active: selectedTopics.includes(topic) }"
@click="toggleTopic(topic)"
:class="{ active: selectedConnectionTags.includes(tag.slug) }"
@click="toggleConnectionTag(tag.slug)"
>
{{ topic }}
{{ tag.label }}
</button>
<button
v-if="availableTopics && availableTopics.length > 10"
v-if="connectionTagOptions.length > 10"
type="button"
class="more-btn"
@click="showAllTopics = !showAllTopics"
@click="showAllConnectionTags = !showAllConnectionTags"
>
{{
showAllTopics ? "Show less" : `+${availableTopics.length - 10} more`
showAllConnectionTags ? "Show less" : `+${connectionTagOptions.length - 10} more`
}}
</button>
</div>
@ -117,16 +117,16 @@
Offering Peer Support
<button type="button" @click="clearPeerSupportFilter">&times;</button>
</span>
<span v-for="skill in selectedSkills" :key="'s-' + skill" class="af-tag">
{{ skill }}
<button type="button" @click="toggleSkill(skill)">&times;</button>
<span v-for="slug in selectedCraftTags" :key="'c-' + slug" class="af-tag">
{{ craftTagLabel(slug) }}
<button type="button" @click="toggleCraftTag(slug)">&times;</button>
</span>
<span v-for="topic in selectedTopics" :key="'t-' + topic" class="af-tag">
{{ topic }}
<button type="button" @click="toggleTopic(topic)">&times;</button>
<span v-for="slug in selectedConnectionTags" :key="'t-' + slug" class="af-tag">
{{ connectionTagLabel(slug) }}
<button type="button" @click="toggleConnectionTag(slug)">&times;</button>
</span>
<button
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
v-if="selectedCraftTags.length > 0 || selectedConnectionTags.length > 0"
type="button"
class="clear-all-btn"
@click="clearAllFilters"
@ -186,33 +186,38 @@
}}
</div>
<!-- Skills tags -->
<!-- Craft tags (fall back to offering.tags) -->
<div
v-if="member.offering?.tags && member.offering.tags.length > 0"
v-if="getMemberCraftTags(member).length > 0"
class="mc-tags"
>
<span class="tag-label">Skills:</span>
<span class="tag-label">Craft:</span>
<span
v-for="tag in member.offering.tags"
v-for="tag in getMemberCraftTags(member)"
:key="tag"
class="skill-tag"
>{{ tag }}</span
>{{ craftTagLabel(tag) }}</span
>
</div>
<!-- Looking for -->
<!-- Community connections topics (fall back to lookingFor.tags) -->
<div
v-if="member.lookingFor?.tags && member.lookingFor.tags.length > 0"
v-if="getMemberConnectionTopics(member).length > 0"
class="mc-looking"
>
Looking for: {{ member.lookingFor.tags.join(", ") }}
<span
v-for="topic in getMemberConnectionTopics(member)"
:key="topic.tagSlug || topic"
class="connection-topic"
>
<span class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ connectionTagLabel(topic.tagSlug || topic) }}
</span>
</div>
<!-- Peer support session link -->
<a
v-if="
member.peerSupport?.enabled && member.peerSupport?.slackUsername
"
v-if="showPeerSupport(member)"
href="#"
class="mc-session"
@click.prevent="openSlackDM(member)"
@ -270,16 +275,27 @@ const { render: renderMarkdown } = useMarkdown();
// State
const members = ref([]);
const totalCount = ref(0);
const availableSkills = ref([]);
const availableTopics = ref([]);
const loading = ref(true);
const searchQuery = ref("");
const selectedCircle = ref("all");
const peerSupportFilter = ref("all");
const selectedSkills = ref([]);
const selectedTopics = ref([]);
const showAllSkills = ref(false);
const showAllTopics = ref(false);
const selectedCraftTags = ref([]);
const selectedConnectionTags = ref([]);
const showAllCraftTags = ref(false);
const showAllConnectionTags = ref(false);
// Tag options from API
const craftTagOptions = ref([]);
const connectionTagOptions = ref([]);
// State display text mapping
const stateLabels = {
help: "Can help",
interested: "Interested",
seeking: "Need help",
};
const stateLabel = (state) => stateLabels[state] || state || "";
// Circle options
const circleOptions = [
@ -295,19 +311,55 @@ const circleLabels = {
practitioner: "Practitioner",
};
// Peer support filter options
const peerSupportOptions = [
{ label: "All Members", value: "all" },
{ label: "Offering Peer Support", value: "true" },
];
// Tag slug-to-label lookups
const craftTagLabel = (slug) => {
const found = craftTagOptions.value.find((t) => t.slug === slug);
return found ? found.label : slug;
};
const connectionTagLabel = (slug) => {
const found = connectionTagOptions.value.find((t) => t.slug === slug);
return found ? found.label : slug;
};
// Get craft tags for a member (new field, falling back to offering.tags)
const getMemberCraftTags = (member) => {
if (member.craftTags && member.craftTags.length > 0) {
return member.craftTags;
}
return member.offering?.tags || [];
};
// Get connection topics for a member (new field, falling back to lookingFor.tags)
const getMemberConnectionTopics = (member) => {
if (
member.communityConnections?.topics &&
member.communityConnections.topics.length > 0
) {
return member.communityConnections.topics;
}
// Fallback: wrap old lookingFor.tags as plain strings
if (member.lookingFor?.tags && member.lookingFor.tags.length > 0) {
return member.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null }));
}
return [];
};
// Show peer support link (check both old and new fields)
const showPeerSupport = (member) => {
if (member.communityConnections?.offerPeerSupport) return true;
if (member.peerSupport?.enabled && member.peerSupport?.slackUsername)
return true;
return false;
};
// Computed: has active filters
const hasActiveFilters = computed(() => {
return (
(selectedCircle.value && selectedCircle.value !== "all") ||
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
selectedSkills.value.length > 0 ||
selectedTopics.value.length > 0
selectedCraftTags.value.length > 0 ||
selectedConnectionTags.value.length > 0
);
});
@ -333,28 +385,51 @@ const loadMembers = async () => {
params.circle = selectedCircle.value;
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
params.peerSupport = peerSupportFilter.value;
if (selectedSkills.value.length > 0)
params.skills = selectedSkills.value.join(",");
if (selectedTopics.value.length > 0)
params.topics = selectedTopics.value.join(",");
if (selectedCraftTags.value.length === 1)
params.craftTag = selectedCraftTags.value[0];
if (selectedConnectionTags.value.length === 1)
params.connectionTag = selectedConnectionTags.value[0];
const data = await $fetch("/api/members/directory", { params });
members.value = data.members || [];
totalCount.value = data.totalCount || 0;
availableSkills.value = data.filters?.availableSkills || [];
availableTopics.value = data.filters?.availableTopics || [];
// Update tag options from API response (only on initial load or if empty)
if (data.filters?.craftTags && craftTagOptions.value.length === 0) {
craftTagOptions.value = data.filters.craftTags;
}
if (
data.filters?.cooperativeTags &&
connectionTagOptions.value.length === 0
) {
connectionTagOptions.value = data.filters.cooperativeTags;
}
} catch (error) {
console.error("Failed to load members:", error);
members.value = [];
totalCount.value = 0;
availableSkills.value = [];
availableTopics.value = [];
} finally {
loading.value = false;
}
};
// Fetch tag options from API on mount
const loadTagOptions = async () => {
try {
const data = await $fetch("/api/tags");
const tags = data.tags || [];
craftTagOptions.value = tags
.filter((t) => t.pool === "craft")
.map((t) => ({ slug: t.slug, label: t.label }));
connectionTagOptions.value = tags
.filter((t) => t.pool === "cooperative")
.map((t) => ({ slug: t.slug, label: t.label }));
} catch (error) {
console.error("Failed to load tags:", error);
}
};
// Toggle peer support checkbox
const togglePeerSupport = (e) => {
peerSupportFilter.value = e.target.checked ? "true" : "all";
@ -370,24 +445,24 @@ const debouncedSearch = () => {
}, 300);
};
// Toggle skill filter
const toggleSkill = (skill) => {
const index = selectedSkills.value.indexOf(skill);
// Toggle craft tag filter
const toggleCraftTag = (slug) => {
const index = selectedCraftTags.value.indexOf(slug);
if (index > -1) {
selectedSkills.value.splice(index, 1);
selectedCraftTags.value.splice(index, 1);
} else {
selectedSkills.value.push(skill);
selectedCraftTags.value = [slug]; // single-select for API query param
}
loadMembers();
};
// Toggle topic filter
const toggleTopic = (topic) => {
const index = selectedTopics.value.indexOf(topic);
// Toggle connection tag filter
const toggleConnectionTag = (slug) => {
const index = selectedConnectionTags.value.indexOf(slug);
if (index > -1) {
selectedTopics.value.splice(index, 1);
selectedConnectionTags.value.splice(index, 1);
} else {
selectedTopics.value.push(topic);
selectedConnectionTags.value = [slug]; // single-select for API query param
}
loadMembers();
};
@ -407,14 +482,17 @@ const clearAllFilters = () => {
searchQuery.value = "";
selectedCircle.value = "all";
peerSupportFilter.value = "all";
selectedSkills.value = [];
selectedTopics.value = [];
selectedCraftTags.value = [];
selectedConnectionTags.value = [];
loadMembers();
};
// Slack DM functionality
const openSlackDM = async (member) => {
const username = member.peerSupport?.slackUsername || member.name;
const username =
member.communityConnections?.slackHandle ||
member.peerSupport?.slackUsername ||
member.name;
try {
await navigator.clipboard.writeText(username);
@ -429,12 +507,13 @@ const openSlackDM = async (member) => {
};
// Load on mount and handle query params
onMounted(() => {
onMounted(async () => {
const route = useRoute();
if (route.query.peerSupport === "true") {
peerSupportFilter.value = "true";
}
await loadTagOptions();
loadMembers();
});
@ -756,10 +835,28 @@ useHead({
}
.mc-looking {
font-size: 11px;
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.connection-topic {
font-size: 10px;
color: var(--text-dim);
padding: 1px 6px;
border: 1px dashed var(--border);
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.connection-state {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint);
font-style: italic;
margin-top: 4px;
}
.mc-session {

View file

@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
status: "active",
})
.select(
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
)
.lean();
@ -70,6 +70,21 @@ export default defineEventHandler(async (event) => {
if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
// Craft tags
if (isVisible("craftTags")) {
filtered.craftTags = member.craftTags;
}
// Community connections (expose only public-safe fields)
if (isVisible("communityConnections")) {
filtered.communityConnections = {
topics: member.communityConnections?.topics,
offerPeerSupport: member.communityConnections?.offerPeerSupport,
availability: member.communityConnections?.availability,
details: member.communityConnections?.details,
};
}
// Peer support: expose only fields needed for matching/contact UX
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) {

View file

@ -1,5 +1,6 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import Tag from "../../models/tag.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
@ -27,6 +28,8 @@ export default defineEventHandler(async (event) => {
const tags = query.tags ? query.tags.split(",") : [];
const peerSupport = query.peerSupport || "";
const topics = query.topics ? query.topics.split(",") : [];
const craftTag = query.craftTag || "";
const connectionTag = query.connectionTag || "";
// Build query
const dbQuery = {
@ -39,46 +42,39 @@ export default defineEventHandler(async (event) => {
dbQuery.circle = circle;
}
// Filter by peer support availability
// Collect $and conditions for combining multiple filters
const andConditions = [];
// Filter by peer support availability (check both old and new fields)
if (peerSupport === "true") {
dbQuery["peerSupport.enabled"] = true;
andConditions.push({
$or: [
{ "peerSupport.enabled": true },
{ "communityConnections.offerPeerSupport": true },
],
});
}
// Search by name or bio
if (search) {
// Escape special regex characters to prevent ReDoS
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
dbQuery.$or = [
{ name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } },
];
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
andConditions.push({
$or: [
{ name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } },
],
});
}
// Filter by tags (search in offering.tags or lookingFor.tags)
if (tags.length > 0) {
dbQuery.$or = [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
];
// If search is also present, combine with AND
if (search) {
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
dbQuery.$and = [
{
$or: [
{ name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } },
],
},
{
$or: [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
],
},
];
delete dbQuery.$or;
}
andConditions.push({
$or: [
{ "offering.tags": { $in: tags } },
{ "lookingFor.tags": { $in: tags } },
],
});
}
// Filter by peer support topics
@ -86,10 +82,25 @@ export default defineEventHandler(async (event) => {
dbQuery["peerSupport.topics"] = { $in: topics };
}
// Filter by craft tag
if (craftTag) {
dbQuery.craftTags = craftTag;
}
// Filter by connection tag
if (connectionTag) {
dbQuery["communityConnections.topics.tagSlug"] = connectionTag;
}
// Apply combined $and conditions
if (andConditions.length > 0) {
dbQuery.$and = andConditions;
}
try {
const members = await Member.find(dbQuery)
.select(
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
)
.sort({ createdAt: -1 })
.lean();
@ -124,6 +135,20 @@ export default defineEventHandler(async (event) => {
if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
// Craft tags (with fallback to offering.tags for backward compat)
if (isVisible("craftTags")) {
filtered.craftTags = member.craftTags;
}
// Community connections (expose only public-safe fields)
if (isVisible("communityConnections")) {
filtered.communityConnections = {
topics: member.communityConnections?.topics,
offerPeerSupport: member.communityConnections?.offerPeerSupport,
availability: member.communityConnections?.availability,
};
}
// Peer support: expose only fields needed for matching/contact UX
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) {
@ -138,7 +163,7 @@ export default defineEventHandler(async (event) => {
return filtered;
});
// Get unique tags for filter options (from both offering and lookingFor)
// Get unique tags for filter options (from both offering and lookingFor) — backward compat
const allTags = members
.flatMap((m) => [
...(m.offering?.tags || []),
@ -154,12 +179,23 @@ export default defineEventHandler(async (event) => {
.filter((topic, index, self) => self.indexOf(topic) === index)
.sort();
// Fetch predefined tags from Tag model for filter bars
const [craftTags, cooperativeTags] = await Promise.all([
Tag.find({ pool: "craft", active: true }).sort({ label: 1 }).lean(),
Tag.find({ pool: "cooperative", active: true }).sort({ label: 1 }).lean(),
]);
return {
members: filteredMembers,
totalCount: filteredMembers.length,
filters: {
availableSkills: allTags,
availableTopics: allTopics,
craftTags: craftTags.map((t) => ({ slug: t.slug, label: t.label })),
cooperativeTags: cooperativeTags.map((t) => ({
slug: t.slug,
label: t.label,
})),
},
};
} catch (error) {