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

@ -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;