feat: restructure profile page for community connections

Replace Skills Exchange section with CraftTagSelector in About You.
Replace Peer Support section with Community Connections using
CooperativeTagSelector. Update form data, load/save logic, and
notifications to use new field names with backward-compatible
fallbacks to old peerSupport data.
This commit is contained in:
Jennie Robinson Faber 2026-04-05 16:28:34 +01:00
parent 3551f19772
commit 2aa29ba64b

View file

@ -140,45 +140,16 @@
</div>
<PrivacyToggle v-model="formData.bioPrivacy" />
</div>
</div>
<!-- Skills Exchange -->
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">Skills Exchange</div>
<!-- What I Do (craft tags) -->
<div class="field">
<label>What I Can Contribute</label>
<TagInput
v-model="formData.offering.tags"
placeholder="add skill..."
<label>What I Do</label>
<CraftTagSelector
v-model="formData.craftTags"
:tags="craftTags"
@suggest="tagSuggestPool = 'craft'; showTagSuggestModal = true"
/>
<PrivacyToggle v-model="formData.offeringPrivacy" />
</div>
<div class="field">
<label>Details</label>
<textarea
v-model="formData.offering.text"
rows="3"
placeholder="e.g., I have 10+ years in Unity and love helping new devs."
></textarea>
</div>
<div class="field">
<label>What I'm Looking For</label>
<TagInput
v-model="formData.lookingFor.tags"
placeholder="add topic..."
/>
<PrivacyToggle v-model="formData.lookingForPrivacy" />
</div>
<div class="field">
<label>Details</label>
<textarea
v-model="formData.lookingFor.text"
rows="3"
placeholder="e.g., Seeking a business-minded co-founder for a worker co-op studio."
></textarea>
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
</div>
</div>
@ -203,10 +174,33 @@
<!-- ======== RIGHT COLUMN ======== -->
<div class="profile-col-right">
<div class="profile-col-inset">
<div class="section-label">Peer Support</div>
<div class="section-label">Community Connections</div>
<div class="field">
<label>Topics</label>
<CooperativeTagSelector
v-model="formData.communityConnectionsTopics"
:tags="cooperativeTags"
@suggest="tagSuggestPool = 'cooperative'; showTagSuggestModal = true"
/>
<PrivacyToggle v-model="formData.communityConnectionsPrivacy" />
</div>
<div class="field">
<label>Details</label>
<textarea
v-model="formData.communityConnectionsDetails"
rows="3"
placeholder="What are you hoping to connect about?"
maxlength="300"
></textarea>
<div class="char-count">
{{ formData.communityConnectionsDetails?.length || 0 }} / 300
</div>
</div>
<div class="toggle-field">
<USwitch v-model="formData.peerSupportEnabled" />
<USwitch v-model="formData.communityConnectionsOfferPeerSupport" />
<div class="toggle-label">
Offer Peer Support
<span class="toggle-sub"
@ -215,47 +209,11 @@
</div>
</div>
<div v-if="formData.peerSupportEnabled" class="peer-panel">
<div class="field">
<label>Skill-Based Topics</label>
<TagInput
v-model="formData.peerSupportSkillTopics"
placeholder="add topic..."
/>
<div v-if="suggestedSkillTopics.length" class="suggested">
Suggested from your offerings:
<a
v-for="tag in suggestedSkillTopics"
:key="tag"
@click="addSuggestedSkillTopic(tag)"
>{{ tag }}</a
>
</div>
</div>
<div class="field">
<label>Conversational Topics</label>
<div class="checkbox-grid">
<label
v-for="topic in availableSupportTopics"
:key="topic"
class="checkbox-item"
:class="{
checked:
formData.peerSupportSupportTopics.includes(topic),
}"
@click.prevent="toggleSupportTopic(topic)"
>
<span class="cb">&#10003;</span>
{{ topic }}
</label>
</div>
</div>
<div v-if="formData.communityConnectionsOfferPeerSupport" class="connections-panel">
<div class="field">
<label>Availability</label>
<textarea
v-model="formData.peerSupportAvailability"
v-model="formData.communityConnectionsAvailability"
rows="3"
placeholder="e.g. Weekday afternoons ET"
></textarea>
@ -264,7 +222,7 @@
<div class="field">
<label>Slack Handle</label>
<input
v-model="formData.peerSupportSlackUsername"
v-model="formData.communityConnectionsSlackHandle"
type="text"
placeholder="@yourslackname"
/>
@ -273,13 +231,13 @@
<div class="field">
<label>Personal Message</label>
<textarea
v-model="formData.peerSupportMessage"
v-model="formData.communityConnectionsPersonalMessage"
rows="3"
maxlength="200"
placeholder="Brief note shown to people requesting time with you"
></textarea>
<div class="char-count">
{{ formData.peerSupportMessage?.length || 0 }} / 200
{{ formData.communityConnectionsPersonalMessage?.length || 0 }} / 200
</div>
</div>
</div>
@ -311,9 +269,9 @@
</div>
<div class="toggle-field">
<USwitch v-model="formData.notifyPeerRequests" />
<USwitch v-model="formData.notifyConnectionRequests" />
<div class="toggle-label">
Peer support requests
Connection requests
<span class="toggle-sub"
>When someone wants to connect</span
>
@ -345,6 +303,9 @@
</div>
</form>
</div>
<!-- Tag Suggest Modal -->
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
</div>
</template>
@ -374,6 +335,20 @@ const availableGhosts = [
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
];
// Fetch tags and split into pools
const { data: tagsData } = await useFetch("/api/tags");
const craftTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === "craft"),
);
const cooperativeTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === "cooperative"),
);
// Tag suggest modal state
const showTagSuggestModal = ref(false);
const tagSuggestPool = ref("");
// Form state
const formData = reactive({
name: "",
@ -383,16 +358,18 @@ const formData = reactive({
studio: "",
bio: "",
location: "",
offering: { text: "", tags: [] },
lookingFor: { text: "", tags: [] },
showInDirectory: true,
// Peer support
peerSupportEnabled: false,
peerSupportSkillTopics: [],
peerSupportSupportTopics: [],
peerSupportAvailability: "",
peerSupportMessage: "",
peerSupportSlackUsername: "",
// Craft tags
craftTags: [],
craftTagsPrivacy: "members",
// Community connections
communityConnectionsTopics: [],
communityConnectionsPrivacy: "members",
communityConnectionsDetails: "",
communityConnectionsOfferPeerSupport: false,
communityConnectionsAvailability: "",
communityConnectionsSlackHandle: "",
communityConnectionsPersonalMessage: "",
// Privacy
pronounsPrivacy: "members",
timeZonePrivacy: "members",
@ -400,12 +377,11 @@ const formData = reactive({
studioPrivacy: "members",
bioPrivacy: "members",
locationPrivacy: "members",
offeringPrivacy: "members",
lookingForPrivacy: "members",
// Notifications
notifyEvents: true,
notifyUpdates: true,
notifyPeerRequests: true,
notifyConnectionRequests: true,
});
const loading = ref(false);
@ -414,47 +390,11 @@ const saveSuccess = ref(false);
const saveError = ref(null);
const initialData = ref(null);
// Available conversational support topics
const availableSupportTopics = [
"Co-founder relationships",
"Burnout prevention",
"Impostor syndrome",
"Work-life boundaries",
"Conflict resolution",
"General chat & support",
];
// Computed
const hasChanges = computed(() => {
return JSON.stringify(formData) !== JSON.stringify(initialData.value);
});
const suggestedSkillTopics = computed(() => {
if (!formData.offering.tags?.length) return [];
return formData.offering.tags.filter(
(t) => !formData.peerSupportSkillTopics?.includes(t),
);
});
// Toggle a support topic in/out of the selection
const toggleSupportTopic = (topic) => {
const idx = formData.peerSupportSupportTopics.indexOf(topic);
if (idx >= 0) {
formData.peerSupportSupportTopics.splice(idx, 1);
} else {
formData.peerSupportSupportTopics.push(topic);
}
};
const addSuggestedSkillTopic = (tag) => {
if (!Array.isArray(formData.peerSupportSkillTopics)) {
formData.peerSupportSkillTopics = [];
}
if (!formData.peerSupportSkillTopics.includes(tag)) {
formData.peerSupportSkillTopics.push(tag);
}
};
// Load member data into form
const loadProfile = () => {
if (memberData.value) {
@ -466,66 +406,21 @@ const loadProfile = () => {
formData.bio = memberData.value.bio || "";
formData.location = memberData.value.location || "";
// Load offering (handle both old string and new object format)
if (typeof memberData.value.offering === "string") {
formData.offering.text = memberData.value.offering;
formData.offering.tags = [];
} else if (memberData.value.offering) {
formData.offering.text = memberData.value.offering?.text || "";
formData.offering.tags = Array.isArray(memberData.value.offering?.tags)
? [...memberData.value.offering.tags]
: [];
} else {
formData.offering.text = "";
formData.offering.tags = [];
}
// Load lookingFor (handle both old string and new object format)
if (typeof memberData.value.lookingFor === "string") {
formData.lookingFor.text = memberData.value.lookingFor;
formData.lookingFor.tags = [];
} else if (memberData.value.lookingFor) {
formData.lookingFor.text = memberData.value.lookingFor?.text || "";
formData.lookingFor.tags = Array.isArray(
memberData.value.lookingFor?.tags,
)
? [...memberData.value.lookingFor.tags]
: [];
} else {
formData.lookingFor.text = "";
formData.lookingFor.tags = [];
}
formData.showInDirectory = memberData.value.showInDirectory ?? true;
// Load peer support data
if (memberData.value.peerSupport) {
formData.peerSupportEnabled =
memberData.value.peerSupport.enabled || false;
formData.peerSupportSkillTopics = Array.isArray(
memberData.value.peerSupport.skillTopics,
)
? [...memberData.value.peerSupport.skillTopics]
: [];
formData.peerSupportSupportTopics = Array.isArray(
memberData.value.peerSupport.supportTopics,
)
? [...memberData.value.peerSupport.supportTopics]
: [];
formData.peerSupportAvailability =
memberData.value.peerSupport.availability || "";
formData.peerSupportMessage =
memberData.value.peerSupport.personalMessage || "";
formData.peerSupportSlackUsername =
memberData.value.peerSupport.slackUsername || "";
} else {
formData.peerSupportEnabled = false;
formData.peerSupportSkillTopics = [];
formData.peerSupportSupportTopics = [];
formData.peerSupportAvailability = "";
formData.peerSupportMessage = "";
formData.peerSupportSlackUsername = "";
}
// Load craft tags
formData.craftTags = Array.isArray(memberData.value.craftTags)
? [...memberData.value.craftTags]
: [];
// Load community connections (with fallback to old peerSupport fields)
const cc = memberData.value.communityConnections || {};
formData.communityConnectionsTopics = Array.isArray(cc.topics) ? [...cc.topics] : [];
formData.communityConnectionsOfferPeerSupport = cc.offerPeerSupport ?? memberData.value.peerSupport?.enabled ?? false;
formData.communityConnectionsAvailability = cc.availability || memberData.value.peerSupport?.availability || "";
formData.communityConnectionsSlackHandle = cc.slackHandle || memberData.value.peerSupport?.slackUsername || "";
formData.communityConnectionsPersonalMessage = cc.personalMessage || memberData.value.peerSupport?.personalMessage || "";
formData.communityConnectionsDetails = cc.details || "";
// Load privacy settings (with defaults)
const privacy = memberData.value.privacy || {};
@ -535,14 +430,15 @@ const loadProfile = () => {
formData.studioPrivacy = privacy.studio || "members";
formData.bioPrivacy = privacy.bio || "members";
formData.locationPrivacy = privacy.location || "members";
formData.offeringPrivacy = privacy.offering || "members";
formData.lookingForPrivacy = privacy.lookingFor || "members";
formData.craftTagsPrivacy = privacy.craftTags || "members";
formData.communityConnectionsPrivacy = privacy.communityConnections || "members";
// Load notification prefs
const notifs = memberData.value.notifications || {};
formData.notifyEvents = notifs.events ?? true;
formData.notifyUpdates = notifs.updates ?? true;
formData.notifyPeerRequests = notifs.peerRequests ?? true;
formData.notifyConnectionRequests = notifs.connectionRequests ?? true;
// Store initial state for change detection
initialData.value = JSON.parse(JSON.stringify(formData));
@ -556,29 +452,31 @@ const handleSubmit = async () => {
saveError.value = null;
try {
// Save profile data
// Save profile data (includes craft tags + privacy + notifications)
await $fetch("/api/members/profile", {
method: "PATCH",
body: {
...formData,
craftTags: formData.craftTags,
notifications: {
events: formData.notifyEvents,
updates: formData.notifyUpdates,
peerRequests: formData.notifyPeerRequests,
connectionRequests: formData.notifyConnectionRequests,
},
},
});
// Save peer support data separately
await $fetch("/api/members/me/peer-support", {
// Save community connections data
await $fetch("/api/members/me/community-connections", {
method: "PATCH",
body: {
enabled: formData.peerSupportEnabled,
skillTopics: formData.peerSupportSkillTopics,
supportTopics: formData.peerSupportSupportTopics,
availability: formData.peerSupportAvailability,
personalMessage: formData.peerSupportMessage,
slackUsername: formData.peerSupportSlackUsername,
topics: formData.communityConnectionsTopics,
offerPeerSupport: formData.communityConnectionsOfferPeerSupport,
availability: formData.communityConnectionsAvailability,
slackHandle: formData.communityConnectionsSlackHandle,
personalMessage: formData.communityConnectionsPersonalMessage,
details: formData.communityConnectionsDetails,
},
});
@ -829,48 +727,8 @@ useHead({
margin-top: 1px;
}
/* ---- CHECKBOX GRID ---- */
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px 12px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
padding: 2px 0;
user-select: none;
}
.checkbox-item .cb {
width: 13px;
height: 13px;
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: transparent;
flex-shrink: 0;
}
.checkbox-item.checked .cb {
border-color: var(--candle);
border-style: solid;
color: var(--candle);
}
.checkbox-item:hover {
color: var(--text);
}
/* ---- PEER SUPPORT PANEL ---- */
.peer-panel {
/* ---- CONNECTIONS PANEL ---- */
.connections-panel {
border: 1px dashed var(--border);
padding: 12px 14px;
margin-top: 4px;
@ -878,19 +736,6 @@ useHead({
background: var(--surface);
}
.peer-panel .suggested {
font-size: 10px;
color: var(--text-faint);
margin-top: 4px;
}
.peer-panel .suggested a {
color: var(--candle);
text-decoration: underline;
cursor: pointer;
margin-left: 4px;
}
/* ---- DISABLED BUTTON ---- */
.btn:disabled {
opacity: 0.4;
@ -947,10 +792,6 @@ useHead({
grid-template-columns: 1fr;
}
.checkbox-grid {
grid-template-columns: 1fr;
}
.profile-col-left .profile-col-inset,
.profile-col-right .profile-col-inset {
padding-left: 16px;