refactor: update membership agreement and conflict resolution framework templates to utilize centralized cooperative information, enhance decision framework handling, and improve overall layout for better user experience

This commit is contained in:
Jennie Robinson Faber 2025-09-10 08:32:57 +01:00
parent f1889b3a70
commit 7b4fb6c2fd
5 changed files with 510 additions and 703 deletions

View file

@ -424,7 +424,8 @@ const formatObjectAsMarkdown = (obj: any): string => {
// Membership Agreement formatting - Complete document with all static and dynamic content // Membership Agreement formatting - Complete document with all static and dynamic content
const formatMembershipAgreementAsText = (data: any): string => { const formatMembershipAgreementAsText = (data: any): string => {
const formData = data.formData || {}; const formData = data.formData || {};
let content = `MEMBERSHIP AGREEMENT\n====================\n\n`; const cooperativeName = data.cooperativeName || formData.cooperativeName || "the cooperative";
let content = "";
// Section 1: Who We Are // Section 1: Who We Are
content += `1. WHO WE ARE\n-------------\n\n`; content += `1. WHO WE ARE\n-------------\n\n`;
@ -459,18 +460,25 @@ const formatMembershipAgreementAsText = (data: any): string => {
content += `New members join through a consent process, which means existing members must agree that adding this person won't harm the cooperative.\n\n`; content += `New members join through a consent process, which means existing members must agree that adding this person won't harm the cooperative.\n\n`;
content += `1. Trial period of ${formData.trialPeriodMonths || 3} months working together\n`; content += `1. Trial period of ${formData.trialPeriodMonths || 3} months working together\n`;
content += `2. Values alignment conversation\n`; content += `2. Values alignment conversation\n`;
content += `3. Consent decision by current members\n`; content += `3. Optional - Equal buy-in contribution of $${formData.buyInAmount || "[amount]"} (can be paid over time or waived based on need)\n\n`;
content += `4. Optional - Equal buy-in contribution of $${formData.buyInAmount || "[amount]"} (can be paid over time or waived based on need)\n\n`;
content += `Leaving the Cooperative:\n`; content += `Leaving the Cooperative:\n`;
content += `Members can leave anytime with ${formData.noticeDays || 30} days notice. The cooperative will:\n\n`; content += `Members can leave anytime with ${formData.noticeDays || 30} days notice. ${cooperativeName} will:\n\n`;
content += `• Pay out their share of any surplus within ${formData.surplusPayoutDays || 30} days\n`; content += `• Pay out their share of any surplus within ${formData.surplusPayoutDays || 30} days\n`;
content += `• Return their buy-in contribution within ${formData.buyInReturnDays || 90} days\n`; content += `• Return their buy-in contribution within ${formData.buyInReturnDays || 90} days\n`;
content += `• Maintain respectful ongoing relationships when possible\n\n`; content += `• Maintain respectful ongoing relationships when possible\n\n`;
// Section 3: How We Make Decisions // Section 3: How We Make Decisions
content += `3. HOW WE MAKE DECISIONS\n------------------------\n\n`; content += `3. HOW WE MAKE DECISIONS\n------------------------\n\n`;
content += `Consent-Based Decisions:\nWe use consent, not consensus. This means we move forward when no one has a principled objection that would harm the cooperative. An objection must explain how the proposal would contradict our values or threaten our sustainability.\n\n`;
content += `Primary Decision Framework: ${data.decisionFrameworkName || "Consent-Based - No one objects strongly enough to block"}\n\n`;
const frameworkDetails = data.decisionFrameworkDetails || {};
if (frameworkDetails.practicalDescription) {
content += `${frameworkDetails.practicalDescription}\n\n`;
} else {
content += `We use consent, not consensus. This means we move forward when no one has a principled objection that would harm our organization. An objection must explain how the proposal would contradict our values or threaten our sustainability.\n\n`;
}
content += `Day-to-Day Decisions:\nDecisions under $${formData.dayToDayLimit || 100} can be made by any member. Just tell others what you did at the next meeting.\n\n`; content += `Day-to-Day Decisions:\nDecisions under $${formData.dayToDayLimit || 100} can be made by any member. Just tell others what you did at the next meeting.\n\n`;
@ -481,7 +489,7 @@ const formatMembershipAgreementAsText = (data: any): string => {
content += `• Changing this agreement\n`; content += `• Changing this agreement\n`;
content += `• Taking on debt over $${formData.majorDebtThreshold || 5000}\n`; content += `• Taking on debt over $${formData.majorDebtThreshold || 5000}\n`;
content += `• Fundamental changes to our purpose or structure\n`; content += `• Fundamental changes to our purpose or structure\n`;
content += `• Dissolution of the cooperative\n\n`; content += `• Dissolution of ${cooperativeName}\n\n`;
content += `Meeting Structure:\n`; content += `Meeting Structure:\n`;
content += `• Regular meetings happen ${formData.meetingFrequency || "weekly"}\n`; content += `• Regular meetings happen ${formData.meetingFrequency || "weekly"}\n`;
@ -491,12 +499,12 @@ const formatMembershipAgreementAsText = (data: any): string => {
// Section 4: Money and Labour // Section 4: Money and Labour
content += `4. MONEY AND LABOUR\n-------------------\n\n`; content += `4. MONEY AND LABOUR\n-------------------\n\n`;
content += `Equal Ownership:\nEach member owns an equal share of the cooperative, regardless of hours worked or tenure.\n\n`; content += `Equal Ownership:\nEach member owns an equal share of ${cooperativeName}, regardless of hours worked or tenure.\n\n`;
content += `Paying Ourselves:\n`; content += `Paying Ourselves:\n`;
const payPolicy = formData.payPolicy || "equal-pay"; const payPolicy = formData.payPolicy || "equal-pay";
const payPolicyName = payPolicy.replace('-', ' ').replace(/\b\w/g, (l: string) => l.toUpperCase()); const payPolicyName = payPolicy.replace('-', ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
content += `Pay Policy: ${payPolicyName}\n\n`; content += `Pay Policy: ${data.payPolicyName || payPolicyName}\n\n`;
if (payPolicy === 'equal-pay') { if (payPolicy === 'equal-pay') {
content += `All members receive equal compensation regardless of role or hours worked.\n`; content += `All members receive equal compensation regardless of role or hours worked.\n`;
@ -516,7 +524,7 @@ const formatMembershipAgreementAsText = (data: any): string => {
} }
content += `\nPayment Schedule:\n`; content += `\nPayment Schedule:\n`;
content += `• Paid on the ${formData.paymentDay || 15} of each month\n`; content += `• Paid on the ${data.paymentDayLabel || formData.paymentDay || 15} of each month\n`;
content += `• Surplus (profit) distributed equally every ${formData.surplusFrequency || "quarter"}\n\n`; content += `• Surplus (profit) distributed equally every ${formData.surplusFrequency || "quarter"}\n\n`;
content += `Work Expectations:\n`; content += `Work Expectations:\n`;
@ -556,11 +564,13 @@ const formatMembershipAgreementAsText = (data: any): string => {
// Section 7: Changing This Agreement // Section 7: Changing This Agreement
content += `7. CHANGING THIS AGREEMENT\n--------------------------\n\n`; content += `7. CHANGING THIS AGREEMENT\n--------------------------\n\n`;
content += `This is a living document. We review it every ${formData.reviewFrequency || "year"} and update it through our consent process. Small clarifications can happen anytime; structural changes need full member consent.\n\n`; const frameworkLabel = data.decisionFrameworkLabel || "consent-based decision";
const structuralReq = data.structuralChangeRequirement || "full member consent";
content += `This is a living document. We review it every ${formData.reviewFrequency || "year"} and update it through our ${frameworkLabel} process. Small clarifications can happen anytime; structural changes need ${structuralReq}.\n\n`;
// Section 8: If We Need to Close // Section 8: If We Need to Close
content += `8. IF WE NEED TO CLOSE\n----------------------\n\n`; content += `8. IF WE NEED TO CLOSE\n----------------------\n\n`;
content += `If the cooperative dissolves:\n`; content += `If ${cooperativeName} dissolves:\n`;
content += `1. Pay all debts and obligations\n`; content += `1. Pay all debts and obligations\n`;
content += `2. Return member contributions\n`; content += `2. Return member contributions\n`;
content += `3. Distribute remaining assets equally among members\n`; content += `3. Distribute remaining assets equally among members\n`;
@ -569,19 +579,26 @@ const formatMembershipAgreementAsText = (data: any): string => {
} }
content += `\n`; content += `\n`;
// Section 9: Legal Bits // Section 9: Legal Registration
content += `9. LEGAL BITS\n-------------\n\n`; content += `9. LEGAL REGISTRATION\n---------------------\n\n`;
content += `Legal Structure: ${formData.legalStructure || "[Cooperative corporation, LLC, partnership, etc.]"}\n`;
content += `Registered in: ${formData.registeredLocation || "[State/Province]"}\n`; if (formData.isLegallyRegistered) {
content += `Fiscal Year-End: ${formData.fiscalYearEndMonth || "December"} ${formData.fiscalYearEndDay || 31}\n\n`; content += `Legal Structure: ${formData.legalStructure || "[Cooperative corporation, LLC, partnership, etc.]"}\n`;
content += `This agreement works alongside but doesn't replace our legal incorporation documents. Where they conflict, we follow the law but work to align our legal structure with our values.\n\n`; content += `Registered in: ${formData.registeredLocation || "[State/Province]"}\n`;
content += `Fiscal Year-End: ${formData.fiscalYearEndMonth || "December"} ${formData.fiscalYearEndDay || 31}\n\n`;
content += `This agreement works alongside but doesn't replace our legal incorporation documents. Where they conflict, we follow the law but work to align our legal structure with our values.\n\n`;
} else {
const thisCooperative = cooperativeName === "the cooperative" ? "This cooperative" : cooperativeName;
content += `${thisCooperative} operates as an informal collective. If we decide to register legally in the future, we'll update this section with our legal structure details.\n\n`;
}
return content; return content;
}; };
const formatMembershipAgreementAsMarkdown = (data: any): string => { const formatMembershipAgreementAsMarkdown = (data: any): string => {
const formData = data.formData || {}; const formData = data.formData || {};
let content = `# Membership Agreement\n\n`; const cooperativeName = data.cooperativeName || formData.cooperativeName || "the cooperative";
let content = "";
// Section 1: Who We Are // Section 1: Who We Are
content += `## 1. Who We Are\n\n`; content += `## 1. Who We Are\n\n`;
@ -616,19 +633,26 @@ const formatMembershipAgreementAsMarkdown = (data: any): string => {
content += `New members join through a consent process, which means existing members must agree that adding this person won't harm the cooperative.\n\n`; content += `New members join through a consent process, which means existing members must agree that adding this person won't harm the cooperative.\n\n`;
content += `1. Trial period of **${formData.trialPeriodMonths || 3} months** working together\n`; content += `1. Trial period of **${formData.trialPeriodMonths || 3} months** working together\n`;
content += `2. Values alignment conversation\n`; content += `2. Values alignment conversation\n`;
content += `3. Consent decision by current members\n`; content += `3. Optional - Equal buy-in contribution of **$${formData.buyInAmount || "[amount]"}** (can be paid over time or waived based on need)\n\n`;
content += `4. Optional - Equal buy-in contribution of **$${formData.buyInAmount || "[amount]"}** (can be paid over time or waived based on need)\n\n`;
content += `### Leaving the Cooperative\n\n`; content += `### Leaving the Cooperative\n\n`;
content += `Members can leave anytime with **${formData.noticeDays || 30} days** notice. The cooperative will:\n\n`; content += `Members can leave anytime with **${formData.noticeDays || 30} days** notice. ${cooperativeName} will:\n\n`;
content += `- Pay out their share of any surplus within **${formData.surplusPayoutDays || 30} days**\n`; content += `- Pay out their share of any surplus within **${formData.surplusPayoutDays || 30} days**\n`;
content += `- Return their buy-in contribution within **${formData.buyInReturnDays || 90} days**\n`; content += `- Return their buy-in contribution within **${formData.buyInReturnDays || 90} days**\n`;
content += `- Maintain respectful ongoing relationships when possible\n\n`; content += `- Maintain respectful ongoing relationships when possible\n\n`;
// Section 3: How We Make Decisions // Section 3: How We Make Decisions
content += `## 3. How We Make Decisions\n\n`; content += `## 3. How We Make Decisions\n\n`;
content += `### Consent-Based Decisions\n\n`;
content += `We use consent, not consensus. This means we move forward when no one has a principled objection that would harm the cooperative. An objection must explain how the proposal would contradict our values or threaten our sustainability.\n\n`; content += `### Primary Decision Framework\n\n`;
content += `**${data.decisionFrameworkName || "Consent-Based - No one objects strongly enough to block"}**\n\n`;
const frameworkDetails = data.decisionFrameworkDetails || {};
if (frameworkDetails.practicalDescription) {
content += `${frameworkDetails.practicalDescription}\n\n`;
} else {
content += `We use consent, not consensus. This means we move forward when no one has a principled objection that would harm our organization. An objection must explain how the proposal would contradict our values or threaten our sustainability.\n\n`;
}
content += `### Day-to-Day Decisions\n\n`; content += `### Day-to-Day Decisions\n\n`;
content += `Decisions under **$${formData.dayToDayLimit || 100}** can be made by any member. Just tell others what you did at the next meeting.\n\n`; content += `Decisions under **$${formData.dayToDayLimit || 100}** can be made by any member. Just tell others what you did at the next meeting.\n\n`;
@ -642,7 +666,7 @@ const formatMembershipAgreementAsMarkdown = (data: any): string => {
content += `- Changing this agreement\n`; content += `- Changing this agreement\n`;
content += `- Taking on debt over **$${formData.majorDebtThreshold || 5000}**\n`; content += `- Taking on debt over **$${formData.majorDebtThreshold || 5000}**\n`;
content += `- Fundamental changes to our purpose or structure\n`; content += `- Fundamental changes to our purpose or structure\n`;
content += `- Dissolution of the cooperative\n\n`; content += `- Dissolution of ${cooperativeName}\n\n`;
content += `### Meeting Structure\n\n`; content += `### Meeting Structure\n\n`;
content += `- Regular meetings happen **${formData.meetingFrequency || "weekly"}**\n`; content += `- Regular meetings happen **${formData.meetingFrequency || "weekly"}**\n`;
@ -653,12 +677,12 @@ const formatMembershipAgreementAsMarkdown = (data: any): string => {
// Section 4: Money and Labour // Section 4: Money and Labour
content += `## 4. Money and Labour\n\n`; content += `## 4. Money and Labour\n\n`;
content += `### Equal Ownership\n\n`; content += `### Equal Ownership\n\n`;
content += `Each member owns an equal share of the cooperative, regardless of hours worked or tenure.\n\n`; content += `Each member owns an equal share of ${cooperativeName}, regardless of hours worked or tenure.\n\n`;
content += `### Paying Ourselves\n\n`; content += `### Paying Ourselves\n\n`;
const payPolicy = formData.payPolicy || "equal-pay"; const payPolicy = formData.payPolicy || "equal-pay";
const payPolicyName = payPolicy.replace('-', ' ').replace(/\b\w/g, (l: string) => l.toUpperCase()); const payPolicyName = payPolicy.replace('-', ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
content += `**Pay Policy:** ${payPolicyName}\n\n`; content += `**Pay Policy:** ${data.payPolicyName || payPolicyName}\n\n`;
if (payPolicy === 'equal-pay') { if (payPolicy === 'equal-pay') {
content += `All members receive equal compensation regardless of role or hours worked.\n\n`; content += `All members receive equal compensation regardless of role or hours worked.\n\n`;
@ -678,7 +702,7 @@ const formatMembershipAgreementAsMarkdown = (data: any): string => {
} }
content += `\n**Payment Schedule:**\n\n`; content += `\n**Payment Schedule:**\n\n`;
content += `- Paid on the **${formData.paymentDay || 15}** of each month\n`; content += `- Paid on the **${data.paymentDayLabel || formData.paymentDay || 15}** of each month\n`;
content += `- Surplus (profit) distributed equally every **${formData.surplusFrequency || "quarter"}**\n\n`; content += `- Surplus (profit) distributed equally every **${formData.surplusFrequency || "quarter"}**\n\n`;
content += `### Work Expectations\n\n`; content += `### Work Expectations\n\n`;
@ -718,11 +742,13 @@ const formatMembershipAgreementAsMarkdown = (data: any): string => {
// Section 7: Changing This Agreement // Section 7: Changing This Agreement
content += `## 7. Changing This Agreement\n\n`; content += `## 7. Changing This Agreement\n\n`;
content += `This is a living document. We review it every **${formData.reviewFrequency || "year"}** and update it through our consent process. Small clarifications can happen anytime; structural changes need full member consent.\n\n`; const frameworkLabel = data.decisionFrameworkLabel || "consent-based decision";
const structuralReq = data.structuralChangeRequirement || "full member consent";
content += `This is a living document. We review it every **${formData.reviewFrequency || "year"}** and update it through our ${frameworkLabel} process. Small clarifications can happen anytime; structural changes need ${structuralReq}.\n\n`;
// Section 8: If We Need to Close // Section 8: If We Need to Close
content += `## 8. If We Need to Close\n\n`; content += `## 8. If We Need to Close\n\n`;
content += `If the cooperative dissolves:\n\n`; content += `If ${cooperativeName} dissolves:\n\n`;
content += `1. Pay all debts and obligations\n`; content += `1. Pay all debts and obligations\n`;
content += `2. Return member contributions\n`; content += `2. Return member contributions\n`;
content += `3. Distribute remaining assets equally among members\n`; content += `3. Distribute remaining assets equally among members\n`;
@ -731,12 +757,18 @@ const formatMembershipAgreementAsMarkdown = (data: any): string => {
} }
content += `\n`; content += `\n`;
// Section 9: Legal Bits // Section 9: Legal Registration
content += `## 9. Legal Bits\n\n`; content += `## 9. Legal Registration\n\n`;
content += `- **Legal Structure:** ${formData.legalStructure || "[Cooperative corporation, LLC, partnership, etc.]"}\n`;
content += `- **Registered in:** ${formData.registeredLocation || "[State/Province]"}\n`; if (formData.isLegallyRegistered) {
content += `- **Fiscal Year-End:** ${formData.fiscalYearEndMonth || "December"} ${formData.fiscalYearEndDay || 31}\n\n`; content += `- **Legal Structure:** ${formData.legalStructure || "[Cooperative corporation, LLC, partnership, etc.]"}\n`;
content += `This agreement works alongside but doesn't replace our legal incorporation documents. Where they conflict, we follow the law but work to align our legal structure with our values.\n\n`; content += `- **Registered in:** ${formData.registeredLocation || "[State/Province]"}\n`;
content += `- **Fiscal Year-End:** ${formData.fiscalYearEndMonth || "December"} ${formData.fiscalYearEndDay || 31}\n\n`;
content += `This agreement works alongside but doesn't replace our legal incorporation documents. Where they conflict, we follow the law but work to align our legal structure with our values.\n\n`;
} else {
const thisCooperative = cooperativeName === "the cooperative" ? "This cooperative" : cooperativeName;
content += `${thisCooperative} operates as an informal collective. If we decide to register legally in the future, we'll update this section with our legal structure details.\n\n`;
}
return content; return content;
}; };

View file

@ -0,0 +1,81 @@
import { ref, watch, readonly } from 'vue'
export interface CoopInfo {
cooperativeName: string
dateEstablished: string
purpose: string
coreValues: string
legalStructure: string
registeredLocation: string
isLegallyRegistered: boolean
}
const STORAGE_KEY = 'coop-info'
// Global reactive state
const coopInfo = ref<CoopInfo>({
cooperativeName: '',
dateEstablished: '',
purpose: '',
coreValues: '',
legalStructure: '',
registeredLocation: '',
isLegallyRegistered: false
})
// Flag to prevent loading during initial save
let isInitialized = false
export const useCoopInfo = () => {
// Load data from localStorage on first use
if (!isInitialized && process.client) {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const parsedData = JSON.parse(saved)
coopInfo.value = { ...coopInfo.value, ...parsedData }
} catch (error) {
console.error('Error loading coop info:', error)
}
}
isInitialized = true
// Set up watcher to save changes
watch(
coopInfo,
(newData) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newData))
},
{ deep: true }
)
}
// Helper function to update specific fields
const updateCoopInfo = (updates: Partial<CoopInfo>) => {
coopInfo.value = { ...coopInfo.value, ...updates }
}
// Helper function to get display name (with fallback)
const getDisplayName = () => {
return coopInfo.value.cooperativeName || 'Worker Cooperative'
}
// Helper function to get organization name for different contexts
const getOrgName = () => {
return coopInfo.value.cooperativeName || 'Organization'
}
// Helper function to check if basic info is complete
const isBasicInfoComplete = () => {
return !!(coopInfo.value.cooperativeName && coopInfo.value.cooperativeName.trim())
}
return {
coopInfo: readonly(coopInfo),
updateCoopInfo,
getDisplayName,
getOrgName,
isBasicInfoComplete
}
}

View file

@ -1,596 +0,0 @@
<template>
<div class="min-h-screen bg-neutral-50 pb-24">
<div class="max-w-4xl mx-auto p-6">
<!-- Header -->
<div class="mb-8">
<div class="flex items-start justify-between">
<div>
<h1 class="text-3xl font-black text-black mb-2">
Turn skills into fair, sellable offers
</h1>
<p class="text-neutral-600">
Tell us what you're good at and who you help. We'll suggest offers that match your co-op's shared capacity.
</p>
</div>
<div class="flex items-center gap-3">
<button
@click="skipCoach"
class="px-4 py-2 text-sm bg-neutral-50 border-2 border-neutral-300 rounded-lg text-neutral-700 hover:bg-neutral-100 hover:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
:aria-label="'Skip coach and go to streams tab'"
>
Skip coach Streams
</button>
</div>
</div>
</div>
<!-- Section A: Name your strengths -->
<section class="mb-8" aria-labelledby="strengths-heading">
<div class="flex items-center gap-2 mb-4">
<h2 id="strengths-heading" class="text-xl font-bold text-black">
A) Name your strengths
</h2>
<div class="relative group">
<button
class="w-4 h-4 text-neutral-400 hover:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-full"
aria-label="Why limit to 3 skills per member?"
>
<svg fill="currentColor" viewBox="0 0 20 20" class="w-4 h-4">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
</button>
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-black text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
Focus keeps offers shippable
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black"></div>
</div>
</div>
</div>
<p class="text-neutral-600 mb-6">
Pick what you can reliably do as a team. We'll keep it simple.
</p>
<div class="space-y-6">
<div
v-for="member in members"
:key="member.id"
class="p-6 bg-white border-2 border-neutral-200 rounded-xl shadow-sm"
>
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-bold text-black">{{ member.name }}</h3>
<p v-if="member.role" class="text-sm text-neutral-600">{{ member.role }}</p>
</div>
<div class="text-sm text-neutral-500">
{{ getSelectedSkillsCount(member.id) }}/3 skills selected
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="skill in availableSkills"
:key="skill.id"
@click="toggleSkill(member.id, skill.id)"
:disabled="!canSelectSkill(member.id, skill.id)"
:class="[
'px-3 py-1.5 text-sm rounded-full border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
isSkillSelected(member.id, skill.id)
? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700'
: canSelectSkill(member.id, skill.id)
? 'bg-white text-neutral-700 border-neutral-300 hover:border-blue-400 hover:text-blue-600'
: 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
]"
:aria-pressed="isSkillSelected(member.id, skill.id)"
:aria-label="`${isSkillSelected(member.id, skill.id) ? 'Remove' : 'Add'} ${skill.label} skill for ${member.name}`"
>
{{ skill.label }}
</button>
</div>
</div>
</div>
</section>
<!-- Section B: Who do you help? -->
<section class="mb-8" aria-labelledby="problems-heading">
<div class="flex items-center gap-2 mb-4">
<h2 id="problems-heading" class="text-xl font-bold text-black">
B) Who do you help?
</h2>
<div class="relative group">
<button
class="w-4 h-4 text-neutral-400 hover:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-full"
aria-label="Why limit to 2 problem types?"
>
<svg fill="currentColor" viewBox="0 0 20 20" class="w-4 h-4">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
</button>
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-black text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
Focus keeps offers shippable
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black"></div>
</div>
</div>
</div>
<p class="text-neutral-600 mb-6">
Choose the problems you can solve this month. We'll suggest time-boxed offers.
</p>
<div class="flex flex-wrap gap-3">
<div
v-for="problem in availableProblems"
:key="problem.id"
class="relative"
>
<button
@click="toggleProblem(problem.id)"
:disabled="!canSelectProblem(problem.id)"
:class="[
'px-4 py-2 text-sm rounded-lg border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
isProblemSelected(problem.id)
? 'bg-green-600 text-white border-green-600 hover:bg-green-700'
: canSelectProblem(problem.id)
? 'bg-white text-neutral-700 border-neutral-300 hover:border-green-400 hover:text-green-600'
: 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
]"
:aria-pressed="isProblemSelected(problem.id)"
:aria-label="`${isProblemSelected(problem.id) ? 'Remove' : 'Add'} ${problem.label} problem type`"
>
{{ problem.label }}
</button>
<!-- Examples popover trigger -->
<button
@click="toggleExamples(problem.id)"
@keydown.escape="hideExamples"
class="ml-1 text-xs text-neutral-500 hover:text-neutral-700 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
:aria-label="`See examples for ${problem.label}`"
:aria-expanded="showExamples === problem.id"
>
see examples
</button>
<!-- Examples popover -->
<div
v-if="showExamples === problem.id"
class="absolute z-10 mt-2 p-3 bg-white border-2 border-neutral-200 rounded-lg shadow-lg min-w-64 max-w-sm"
role="tooltip"
:aria-label="`Examples for ${problem.label}`"
>
<div class="text-sm">
<p class="font-medium text-black mb-2">Examples:</p>
<ul class="space-y-1 text-neutral-700">
<li v-for="example in problem.examples" :key="example" class="flex items-start">
<span class="text-neutral-400 mr-2"></span>
<span>{{ example }}</span>
</li>
</ul>
</div>
<button
@click="hideExamples"
class="mt-2 text-xs text-blue-600 hover:text-blue-800 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
aria-label="Close examples"
>
Close
</button>
</div>
</div>
</div>
<div class="mt-4 text-sm text-neutral-500">
{{ selectedProblems.length }}/2 problem types selected
</div>
</section>
<!-- Section C: Suggested offers -->
<section class="mb-8" aria-labelledby="offers-heading">
<h2 id="offers-heading" class="text-xl font-bold text-black mb-4">
C) Suggested offers
</h2>
<!-- Loading state -->
<div
v-if="loading"
class="text-center py-12 bg-white border-2 border-dashed border-blue-200 rounded-xl"
>
<div class="max-w-md mx-auto">
<div class="w-16 h-16 mx-auto mb-4 bg-blue-50 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<h3 class="font-medium text-blue-900 mb-2">Generating offers...</h3>
<p class="text-blue-700">
Creating personalized revenue suggestions based on your selections.
</p>
</div>
</div>
<!-- Empty state -->
<div
v-else-if="suggestedOffers.length === 0"
class="text-center py-12 bg-white border-2 border-dashed border-neutral-300 rounded-xl"
>
<div class="max-w-md mx-auto">
<div class="w-16 h-16 mx-auto mb-4 bg-neutral-100 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 class="font-medium text-neutral-900 mb-2">No offers yet</h3>
<p class="text-neutral-600 mb-4">
Pick a few skills and a problemwe'll suggest something you can sell this month.
</p>
<p class="text-sm text-neutral-500">
We need at least one shared skill and one problem type to suggest offers.
</p>
</div>
</div>
<!-- Offer cards -->
<div v-else class="grid gap-6 md:grid-cols-2">
<div
v-for="offer in suggestedOffers"
:key="offer.id"
class="p-6 bg-white border-2 border-neutral-200 rounded-xl shadow-sm hover:shadow-md transition-shadow"
role="article"
:aria-label="`Offer: ${offer.name}`"
>
<h3 class="font-bold text-black mb-3">{{ offer.name }}</h3>
<!-- Offer chips -->
<div class="flex flex-wrap gap-2 mb-4">
<span class="inline-flex items-center px-2 py-1 text-xs bg-green-50 text-green-700 border border-green-200 rounded-full">
Covers ~{{ calculateMonthlyCoverage(offer) }}% of monthly needs at baseline
</span>
<span class="inline-flex items-center px-2 py-1 text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded-full">
Typical payout: {{ getPayoutDaysRange(offer) }}
</span>
<span class="inline-flex items-center px-2 py-1 text-xs bg-purple-50 text-purple-700 border border-purple-200 rounded-full">
Why this
</span>
</div>
<!-- Scope -->
<div class="mb-4">
<p class="text-sm font-medium text-neutral-700 mb-2">Scope:</p>
<ul class="space-y-1">
<li
v-for="item in offer.scope"
:key="item"
class="text-sm text-neutral-600 flex items-start"
>
<span class="text-neutral-400 mr-2"></span>
<span>{{ item }}</span>
</li>
</ul>
</div>
<!-- Price range -->
<div class="mb-4 p-3 bg-neutral-50 rounded-lg">
<div class="flex justify-between items-center mb-1">
<span class="text-sm font-medium text-neutral-700">Baseline:</span>
<span class="font-bold text-black">${{ offer.price.baseline.toLocaleString() }}</span>
</div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-neutral-700">Stretch:</span>
<span class="font-bold text-green-600">${{ offer.price.stretch.toLocaleString() }}</span>
</div>
<p class="text-xs text-neutral-500">{{ offer.price.calcNote }}</p>
</div>
<!-- Payout delay -->
<div class="mb-4 flex items-center justify-between text-sm">
<span class="text-neutral-600">Payment timing:</span>
<span class="font-medium text-black">{{ offer.payoutDelayDays }} days</span>
</div>
<!-- Why this works -->
<div class="mb-4">
<p class="text-sm font-medium text-neutral-700 mb-2">Why this works for your co-op:</p>
<ul class="space-y-1">
<li
v-for="reason in offer.whyThis"
:key="reason"
class="text-sm text-neutral-600 flex items-start"
>
<span class="text-green-500 mr-2"></span>
<span>{{ updateLanguageToCoopTerms(reason) }}</span>
</li>
</ul>
</div>
<!-- Risk notes (if any) -->
<div v-if="offer.riskNotes.length > 0" class="border-t border-neutral-200 pt-3">
<p class="text-sm font-medium text-amber-700 mb-2">Consider:</p>
<ul class="space-y-1">
<li
v-for="risk in offer.riskNotes"
:key="risk"
class="text-sm text-amber-600 flex items-start"
>
<span class="text-amber-500 mr-2"></span>
<span>{{ risk }}</span>
</li>
</ul>
</div>
</div>
</div>
</section>
</div>
<!-- Sticky Footer -->
<div class="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-neutral-200 shadow-lg">
<div class="max-w-4xl mx-auto p-4">
<div class="flex items-center justify-between">
<button
@click="goBack"
class="px-4 py-2 text-neutral-700 hover:text-black focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-lg transition-colors"
aria-label="Go back to previous page"
>
Back
</button>
<div class="flex items-center gap-3">
<button
@click="regenerateOffers"
:disabled="!canRegenerate"
:class="[
'px-4 py-2 rounded-lg border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
canRegenerate
? 'border-neutral-300 text-neutral-700 hover:border-blue-400 hover:text-blue-600'
: 'border-neutral-200 text-neutral-400 cursor-not-allowed'
]"
:aria-label="canRegenerate ? 'Regenerate offers with current selections' : 'Cannot regenerate - select skills and problems first'"
>
🔄 Regenerate
</button>
<button
@click="useOffers"
:disabled="suggestedOffers.length === 0"
:class="[
'px-6 py-2 rounded-lg font-medium transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
suggestedOffers.length > 0
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-neutral-200 text-neutral-400 cursor-not-allowed'
]"
:aria-label="suggestedOffers.length > 0 ? 'Add these offers to cover co-op needs' : 'No offers to use - generate offers first'"
>
Add to plan
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
import { useDebounceFn } from "@vueuse/core";
// REMOVED: All sample data imports to prevent demo data
// Store integration
const planStore = usePlanStore();
// Initialize with empty data
const members = ref<Member[]>([]);
const availableSkills = ref<SkillTag[]>([]);
const availableProblems = ref<ProblemTag[]>([]);
// Set members in store on component mount
onMounted(() => {
planStore.setMembers(members.value);
});
// Reactive state
const selectedSkills = ref<Record<string, string[]>>({});
const selectedProblems = ref<string[]>([]);
const showExamples = ref<string | null>(null);
const offers = ref<Offer[] | null>(null);
const loading = ref(false);
// Use offer suggestor composable
const { suggestOffers } = useOfferSuggestor();
// Catalogs for the suggestor
const catalogs = computed(() => ({
skills: availableSkills.value,
problems: availableProblems.value
}));
// Computed for suggested offers (for backward compatibility)
const suggestedOffers = computed(() => offers.value || []);
// Helper functions for offer chips
function calculateMonthlyCoverage(offer: Offer): number {
// Estimate monthly burn (simplified calculation)
const totalMemberHours = members.value.reduce((sum, m) => sum + m.availableHrs, 0);
const avgHourlyRate = members.value.reduce((sum, m) => sum + m.hourly, 0) / members.value.length;
const estimatedMonthlyBurn = totalMemberHours * avgHourlyRate * 1.25; // Add on-costs
return Math.round((offer.price.baseline / estimatedMonthlyBurn) * 100);
}
function getPayoutDaysRange(offer: Offer): string {
const days = offer.payoutDelayDays;
if (days <= 15) return "015 days";
if (days <= 30) return "1530 days";
if (days <= 45) return "3045 days";
return `${days} days`;
}
function updateLanguageToCoopTerms(text: string): string {
return text
.replace(/maximize|maximiz/gi, 'cover needs with')
.replace(/optimize|optimiz/gi, 'improve')
.replace(/competitive advantage/gi, 'shared capacity')
.replace(/market position/gi, 'community standing')
.replace(/profit/gi, 'surplus')
.replace(/revenue growth/gi, 'sustainable income')
.replace(/scale/gi, 'grow together')
.replace(/efficiency gains/gi, 'reduce risk')
.replace(/leverages/gi, 'uses')
.replace(/expertise/gi, 'shared skills')
.replace(/builds reputation/gi, 'builds trust in community')
.replace(/high-impact/gi, 'meaningful')
.replace(/productivity/gi, 'shared capacity');
}
// Debounced offer generation
const debouncedGenerateOffers = useDebounceFn(async () => {
const hasSkills = Object.values(selectedSkills.value).some(skills => skills.length > 0);
const hasProblems = selectedProblems.value.length > 0;
if (!hasSkills || !hasProblems) {
offers.value = null;
return;
}
loading.value = true;
try {
const input = {
members: members.value,
selectedSkillsByMember: selectedSkills.value,
selectedProblems: selectedProblems.value
};
const suggestedOffers = suggestOffers(input, catalogs.value);
offers.value = suggestedOffers;
} catch (error) {
console.error('Failed to generate offers:', error);
offers.value = null;
} finally {
loading.value = false;
}
}, 300);
// Skill management
function toggleSkill(memberId: string, skillId: string) {
if (!selectedSkills.value[memberId]) {
selectedSkills.value[memberId] = [];
}
const memberSkills = selectedSkills.value[memberId];
const index = memberSkills.indexOf(skillId);
if (index >= 0) {
memberSkills.splice(index, 1);
} else {
memberSkills.push(skillId);
}
debouncedGenerateOffers();
}
function isSkillSelected(memberId: string, skillId: string): boolean {
return selectedSkills.value[memberId]?.includes(skillId) || false;
}
function canSelectSkill(memberId: string, skillId: string): boolean {
if (isSkillSelected(memberId, skillId)) return true;
return getSelectedSkillsCount(memberId) < 3;
}
function getSelectedSkillsCount(memberId: string): number {
return selectedSkills.value[memberId]?.length || 0;
}
// Problem management
function toggleProblem(problemId: string) {
const index = selectedProblems.value.indexOf(problemId);
if (index >= 0) {
selectedProblems.value.splice(index, 1);
} else {
selectedProblems.value.push(problemId);
}
debouncedGenerateOffers();
}
function isProblemSelected(problemId: string): boolean {
return selectedProblems.value.includes(problemId);
}
function canSelectProblem(problemId: string): boolean {
if (isProblemSelected(problemId)) return true;
return selectedProblems.value.length < 2;
}
// Examples popover
function toggleExamples(problemId: string) {
showExamples.value = showExamples.value === problemId ? null : problemId;
}
function hideExamples() {
showExamples.value = null;
}
// Footer actions
const canRegenerate = computed(() => {
const hasSkills = Object.values(selectedSkills.value).some(skills => skills.length > 0);
const hasProblems = selectedProblems.value.length > 0;
return hasSkills && hasProblems;
});
function goBack() {
// Navigate back - would typically use router
window.history.back();
}
function regenerateOffers() {
if (canRegenerate.value) {
// Re-call suggestOffers with same inputs
debouncedGenerateOffers();
}
}
function useOffers() {
if (offers.value && offers.value.length > 0) {
// Add offers to plan store as streams
planStore.addStreamsFromOffers(offers.value);
// Navigate back to wizard with success message
const router = useRouter();
// Show success notification
console.log(`Added ${offers.value.length} offers as revenue streams to your plan.`);
// Navigate to wizard revenue step - adjust path as needed for your routing
router.push('/wizards'); // This would need to be the correct wizard path
// Note: The Streams tab activation would be handled by the wizard component
// when it detects new streams in the store
}
}
function skipCoach() {
// Navigate directly to wizard streams without adding offers
const router = useRouter();
router.push('/wizards'); // Navigate to wizard - streams tab would be activated there
}
// Close examples on click outside
onMounted(() => {
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest('[role="tooltip"]') && !target.closest('button[aria-expanded]')) {
showExamples.value = null;
}
};
document.addEventListener('click', handleClickOutside);
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
});
</script>

View file

@ -16,7 +16,7 @@
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 <h1
class="text-3xl md:text-5xl font-bold uppercase m-0 py-4 border-t-2 border-b-2 text-black dark:text-white border-black dark:border-white" class="text-3xl md:text-5xl font-bold uppercase m-0 py-4 border-t-2 border-b-2 text-black dark:text-white border-black dark:border-white"
:data-org-name="formData.orgName || 'Organization'"> :data-org-name="getOrgName()">
CONFLICT RESOLUTION CONFLICT RESOLUTION
</h1> </h1>
</div> </div>
@ -895,6 +895,9 @@
<script setup> <script setup>
import { ref, watch, computed, onMounted } from "vue"; import { ref, watch, computed, onMounted } from "vue";
// Import centralized coop info
const { coopInfo, updateCoopInfo, getOrgName } = useCoopInfo();
definePageMeta({ definePageMeta({
layout: false, layout: false,
}); });
@ -1429,6 +1432,27 @@ function debounce(func, wait) {
}; };
} }
// Sync with centralized coop info
watch(
() => coopInfo.value,
(newCoopInfo) => {
if (newCoopInfo.cooperativeName) {
formData.value.orgName = newCoopInfo.cooperativeName;
}
},
{ deep: true, immediate: true }
);
// Update centralized store when org name changes
watch(
() => formData.value.orgName,
(newOrgName) => {
if (newOrgName && newOrgName !== coopInfo.value.cooperativeName) {
updateCoopInfo({ cooperativeName: newOrgName });
}
}
);
// Watch for changes and auto-save // Watch for changes and auto-save
watch( watch(
[ [
@ -1450,7 +1474,7 @@ watch(
// Export data for the ExportOptions component // Export data for the ExportOptions component
const exportData = computed(() => ({ const exportData = computed(() => ({
formData: formData.value, formData: formData.value,
orgName: formData.value.orgName || "Organization", orgName: getOrgName(),
orgType: formData.value.orgType, orgType: formData.value.orgType,
memberCount: formData.value.memberCount, memberCount: formData.value.memberCount,
sectionsEnabled: sectionsEnabled.value, sectionsEnabled: sectionsEnabled.value,

View file

@ -15,9 +15,7 @@
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 <h1
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100" class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100"
:data-coop-name=" :data-coop-name="getDisplayName()">
formData.cooperativeName || 'Worker Cooperative'
">
MEMBERSHIP AGREEMENT MEMBERSHIP AGREEMENT
</h1> </h1>
</div> </div>
@ -172,7 +170,7 @@
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
Any person who: Any person who:
</p> </p>
<UFormField label="Member Requirements" class="form-group-large"> <UFormField class="form-group-large">
<UTextarea <UTextarea
v-model="formData.memberRequirements" v-model="formData.memberRequirements"
:rows="4" :rows="4"
@ -193,7 +191,7 @@
<p class="content-paragraph"> <p class="content-paragraph">
New members join through a consent process, which means New members join through a consent process, which means
existing members must agree that adding this person won't harm existing members must agree that adding this person won't harm
the cooperative. {{ getDisplayName().toLowerCase() }}.
</p> </p>
<ol class="content-list numbered my-2 pl-6 list-decimal"> <ol class="content-list numbered my-2 pl-6 list-decimal">
@ -203,18 +201,17 @@
v-model="formData.trialPeriodMonths" v-model="formData.trialPeriodMonths"
type="number" type="number"
placeholder="3" placeholder="3"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
months working together months working together
</li> </li>
<li>Values alignment conversation</li> <li>Values alignment conversation</li>
<li>Consent decision by current members</li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Optional - Equal buy-in contribution of $<UInput Optional - Equal buy-in contribution of $<UInput
v-model="formData.buyInAmount" v-model="formData.buyInAmount"
type="number" type="number"
placeholder="1000" placeholder="1000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
(can be paid over time or waived based on need) (can be paid over time or waived based on need)
</li> </li>
@ -234,7 +231,7 @@
v-model="formData.noticeDays" v-model="formData.noticeDays"
type="number" type="number"
placeholder="30" placeholder="30"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
days notice. The cooperative will: days notice. The cooperative will:
</p> </p>
@ -246,7 +243,7 @@
v-model="formData.surplusPayoutDays" v-model="formData.surplusPayoutDays"
type="number" type="number"
placeholder="30" placeholder="30"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
days days
</li> </li>
@ -256,7 +253,7 @@
v-model="formData.buyInReturnDays" v-model="formData.buyInReturnDays"
type="number" type="number"
placeholder="90" placeholder="90"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
days days
</li> </li>
@ -276,16 +273,29 @@
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<!-- Decision Framework Selection -->
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"> class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
Consent-Based Decisions Primary Decision Framework
</h3> </h3>
<UFormField class="form-group-large mb-4">
<USelect
v-model="formData.decisionFramework"
:items="decisionFrameworkOptions"
placeholder="Select decision framework"
size="xl"
class="w-full"
@change="autoSave" />
</UFormField>
</div>
<div>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
We use consent, not consensus. This means we move forward when {{
no one has a principled objection that would harm the getFrameworkDetails(formData.decisionFramework)
cooperative. An objection must explain how the proposal would .practicalDescription
contradict our values or threaten our sustainability. }}
</p> </p>
</div> </div>
@ -301,7 +311,7 @@
v-model="formData.dayToDayLimit" v-model="formData.dayToDayLimit"
type="number" type="number"
placeholder="100" placeholder="100"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
can be made by any member. Just tell others what you did at can be made by any member. Just tell others what you did at
the next meeting. the next meeting.
@ -319,13 +329,13 @@
v-model="formData.regularDecisionMin" v-model="formData.regularDecisionMin"
type="number" type="number"
placeholder="100" placeholder="100"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
and $<UInput and $<UInput
v-model="formData.regularDecisionMax" v-model="formData.regularDecisionMax"
type="number" type="number"
placeholder="1000" placeholder="1000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
need consent from members present at a meeting (minimum 2 need consent from members present at a meeting (minimum 2
members). members).
@ -349,11 +359,14 @@
v-model="formData.majorDebtThreshold" v-model="formData.majorDebtThreshold"
type="number" type="number"
placeholder="5000" placeholder="5000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
</li> </li>
<li>Fundamental changes to our purpose or structure</li> <li>Fundamental changes to our purpose or structure</li>
<li>Dissolution of the cooperative</li> <li>
Dissolution of
{{ getDisplayName().toLowerCase() }}
</li>
</ul> </ul>
</div> </div>
@ -377,7 +390,7 @@
v-model="formData.emergencyNoticeHours" v-model="formData.emergencyNoticeHours"
type="number" type="number"
placeholder="24" placeholder="24"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
hours notice hours notice
</li> </li>
@ -404,8 +417,9 @@
Equal Ownership Equal Ownership
</h3> </h3>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
Each member owns an equal share of the cooperative, regardless Each member owns an equal share of
of hours worked or tenure. {{ getDisplayName().toLowerCase() }},
regardless of hours worked or tenure.
</p> </p>
</div> </div>
@ -414,7 +428,7 @@
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"> class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
Paying Ourselves Paying Ourselves
</h3> </h3>
<!-- Pay Policy Selection --> <!-- Pay Policy Selection -->
<UFormField label="Pay Policy" class="form-group-large mb-4"> <UFormField label="Pay Policy" class="form-group-large mb-4">
<USelect <USelect
@ -427,15 +441,20 @@
</UFormField> </UFormField>
<!-- Equal Pay Policy --> <!-- Equal Pay Policy -->
<div v-if="formData.payPolicy === 'equal-pay'" class="space-y-3"> <div
<p class="content-paragraph">All members receive equal compensation regardless of role or hours worked.</p> v-if="formData.payPolicy === 'equal-pay'"
class="space-y-3">
<p class="content-paragraph">
All members receive equal compensation regardless of role or
hours worked.
</p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Base rate: $<UInput Base rate: $<UInput
v-model="formData.baseRate" v-model="formData.baseRate"
type="number" type="number"
placeholder="25" placeholder="25"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" />/hour for all members @change="autoSave" />/hour for all members
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
@ -443,7 +462,7 @@
v-model="formData.monthlyDraw" v-model="formData.monthlyDraw"
type="number" type="number"
placeholder="2000" placeholder="2000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
per member per member
</li> </li>
@ -451,15 +470,19 @@
</div> </div>
<!-- Hours-Weighted Policy --> <!-- Hours-Weighted Policy -->
<div v-if="formData.payPolicy === 'hours-weighted'" class="space-y-3"> <div
<p class="content-paragraph">Compensation is proportional to hours worked by each member.</p> v-if="formData.payPolicy === 'hours-weighted'"
class="space-y-3">
<p class="content-paragraph">
Compensation is proportional to hours worked by each member.
</p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Hourly rate: $<UInput Hourly rate: $<UInput
v-model="formData.hourlyRate" v-model="formData.hourlyRate"
type="number" type="number"
placeholder="25" placeholder="25"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" />/hour @change="autoSave" />/hour
</li> </li>
<li>Members track their hours and are paid accordingly</li> <li>Members track their hours and are paid accordingly</li>
@ -468,18 +491,26 @@
</div> </div>
<!-- Needs-Weighted Policy --> <!-- Needs-Weighted Policy -->
<div v-if="formData.payPolicy === 'needs-weighted'" class="space-y-3"> <div
<p class="content-paragraph">Compensation is allocated based on each member's individual financial needs.</p> v-if="formData.payPolicy === 'needs-weighted'"
class="space-y-3">
<p class="content-paragraph">
Compensation is allocated based on each member's individual
financial needs.
</p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li>Members declare their minimum monthly needs</li> <li>Members declare their minimum monthly needs</li>
<li>Available payroll is distributed proportionally to cover needs</li> <li>
Available payroll is distributed proportionally to cover
needs
</li>
<li>Regular needs assessment and adjustment process</li> <li>Regular needs assessment and adjustment process</li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Minimum guaranteed amount: $<UInput Minimum guaranteed amount: $<UInput
v-model="formData.minGuaranteedPay" v-model="formData.minGuaranteedPay"
type="number" type="number"
placeholder="1000" placeholder="1000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" />/month @change="autoSave" />/month
</li> </li>
</ul> </ul>
@ -487,7 +518,9 @@
<!-- Common payment details --> <!-- Common payment details -->
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
<p class="content-paragraph font-semibold">Payment Schedule:</p> <p class="content-paragraph font-semibold">
Payment Schedule:
</p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Paid on the Paid on the
@ -495,9 +528,9 @@
v-model="formData.paymentDay" v-model="formData.paymentDay"
:items="dayOptions" :items="dayOptions"
placeholder="15th" placeholder="15th"
arrow
class="inline-field" class="inline-field"
@change="autoSave" /> @change="autoSave" />
of each month of each month
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
@ -525,7 +558,7 @@
v-model="formData.targetHours" v-model="formData.targetHours"
type="number" type="number"
placeholder="40" placeholder="40"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
(flexible based on capacity) (flexible based on capacity)
</li> </li>
@ -548,10 +581,7 @@
All members can access all financial records anytime All members can access all financial records anytime
</li> </li>
<li>Monthly financial check-ins at meetings</li> <li>Monthly financial check-ins at meetings</li>
<li> <li>Quarterly reviews of our runway</li>
Quarterly reviews of our runway (how many months we can
operate)
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -578,11 +608,11 @@
v-model="formData.roleRotationMonths" v-model="formData.roleRotationMonths"
type="number" type="number"
placeholder="6" placeholder="6"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
months. Current roles include: months. Current roles include:
</p> </p>
<UFormField label="Rotating Roles" class="form-group-large"> <UFormField class="form-group-large">
<UTextarea <UTextarea
v-model="formData.rotatingRoles" v-model="formData.rotatingRoles"
:rows="4" :rows="4"
@ -602,7 +632,7 @@
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
All members participate in: All members participate in:
</p> </p>
<UFormField label="Shared Responsibilities" class="form-group-large"> <UFormField class="form-group-large">
<UTextarea <UTextarea
v-model="formData.sharedResponsibilities" v-model="formData.sharedResponsibilities"
:rows="3" :rows="3"
@ -669,8 +699,13 @@
placeholder="year" placeholder="year"
class="inline-field" class="inline-field"
@change="autoSave" /> @change="autoSave" />
and update it through our consent process. Small clarifications and update it through our
can happen anytime; structural changes need full member consent. <span class="font-semibold">{{
getFrameworkLabel(formData.decisionFramework)
}}</span>
process. Small clarifications can happen anytime; structural
changes need
{{ getStructuralChangeRequirement(formData.decisionFramework) }}.
</p> </p>
</div> </div>
@ -683,7 +718,8 @@
<div class="space-y-4"> <div class="space-y-4">
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
If the cooperative dissolves: If
{{ getDisplayName().toLowerCase() }} dissolves:
</p> </p>
<ol class="content-list numbered my-2 pl-6 list-decimal"> <ol class="content-list numbered my-2 pl-6 list-decimal">
<li>Pay all debts and obligations</li> <li>Pay all debts and obligations</li>
@ -700,21 +736,33 @@
</div> </div>
</div> </div>
<!-- Section 9: Legal Bits --> <!-- Section 9: Legal Registration (Optional) -->
<div class="section-card"> <div class="section-card">
<h2 <div class="flex items-center justify-between mb-4">
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"> <h2
9. Legal Bits class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-0">
</h2> 9. Legal Registration
</h2>
<div class="flex items-center gap-2">
<label
class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Legally registered?
</label>
<USwitch
v-model="formData.isLegallyRegistered"
@change="autoSave" />
</div>
</div>
<div class="space-y-4"> <div v-if="formData.isLegallyRegistered" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Legal Structure" class="form-group-block"> <UFormField label="Legal Structure" class="form-group-block">
<UInput <UInput
v-model="formData.legalStructure" v-model="formData.legalStructure"
size="xl" size="xl"
class="w-full" class="w-full"
placeholder="Cooperative corporation, LLC, partnership, etc." /> placeholder="Cooperative corporation, LLC, partnership, etc."
@change="autoSave" />
</UFormField> </UFormField>
<UFormField label="Registered in" class="form-group-inline"> <UFormField label="Registered in" class="form-group-inline">
@ -754,8 +802,16 @@
but work to align our legal structure with our values. but work to align our legal structure with our values.
</p> </p>
</div> </div>
</div>
<div v-else class="text-neutral-600 dark:text-neutral-400 italic">
<p class="content-paragraph">
{{ getDisplayName() || "This cooperative" }} operates as
an informal collective. If we decide to register legally in the
future, we'll update this section with our legal structure
details.
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -771,6 +827,9 @@
<script setup> <script setup>
import { ref, watch } from "vue"; import { ref, watch } from "vue";
// Import centralized coop info
const { coopInfo, updateCoopInfo, getDisplayName } = useCoopInfo();
definePageMeta({ definePageMeta({
layout: false, layout: false,
}); });
@ -795,40 +854,194 @@ const monthOptions = [
const dayOptions = Array.from({ length: 31 }, (_, i) => ({ const dayOptions = Array.from({ length: 31 }, (_, i) => ({
value: i + 1, value: i + 1,
label: `${i + 1}${getOrdinalSuffix(i + 1)}` label: `${i + 1}${getOrdinalSuffix(i + 1)}`,
})); }));
// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.) // Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.)
function getOrdinalSuffix(num) { function getOrdinalSuffix(num) {
if (num >= 11 && num <= 13) { if (num >= 11 && num <= 13) {
return 'th'; return "th";
} }
switch (num % 10) { switch (num % 10) {
case 1: return 'st'; case 1:
case 2: return 'nd'; return "st";
case 3: return 'rd'; case 2:
default: return 'th'; return "nd";
case 3:
return "rd";
default:
return "th";
} }
} }
const payPolicyOptions = [ const payPolicyOptions = [
{ value: 'equal-pay', label: 'Equal Pay - All members receive equal compensation' }, {
{ value: 'hours-weighted', label: 'Hours-Weighted - Pay proportional to hours worked' }, value: "equal-pay",
{ value: 'needs-weighted', label: 'Needs-Weighted - Pay proportional to individual needs' } label: "Equal Pay - All members receive equal compensation",
},
{
value: "hours-weighted",
label: "Hours-Weighted - Pay proportional to hours worked",
},
{
value: "needs-weighted",
label: "Needs-Weighted - Pay proportional to individual needs",
},
]; ];
const decisionFrameworkOptions = [
{
value: "consent-based",
label: "Consent-Based - No one objects strongly enough to block",
},
{
value: "consensus",
label: "Full Consensus - Everyone agrees to support",
},
{
value: "consultative",
label: "Consultative - Gather input, then designated person decides",
},
{
value: "democratic-vote",
label: "Democratic Vote - Majority decides",
},
{
value: "advice-process",
label: "Advice Process - Decision-maker seeks input, then decides",
},
{
value: "delegation",
label: "Delegation - Empower responsible party to decide",
},
{
value: "defer-to-expert",
label: "Defer to Expert - Trust the person who knows best",
},
{
value: "facilitated-discussion",
label: "Facilitated Discussion - Talk it through with structure",
},
];
// Helper function to get framework label
function getFrameworkLabel(framework) {
const labels = {
"consent-based": "consent-based decision",
consensus: "consensus",
consultative: "consultative",
"democratic-vote": "democratic voting",
"advice-process": "advice",
delegation: "delegation",
"defer-to-expert": "expert-led",
"facilitated-discussion": "facilitated discussion",
};
return labels[framework] || "decision-making";
}
// Helper function to get structural change requirement text
function getStructuralChangeRequirement(framework) {
const requirements = {
"consent-based": "no blocking objections from any member",
consensus: "full consensus from all members",
consultative: "consultation with all members before the decision",
"democratic-vote": "a majority vote of all members",
"advice-process": "advice from all members before the decision",
delegation: "approval from the delegated authority",
"defer-to-expert":
"approval from the designated expert after member consultation",
"facilitated-discussion": "a facilitated discussion with all members",
};
return (
requirements[framework] || "approval through our chosen decision process"
);
}
// Helper function to get framework details
function getFrameworkDetails(framework) {
const details = {
"consent-based": {
tagline: "No one objects strongly enough to block",
description:
"Not everyone needs to love it, but no one sees it as harmful to our organization. Focus on addressing objections rather than optimizing preferences.",
practicalDescription:
"We use consent, not consensus. This means we move forward when no one has a principled objection that would harm our organization. An objection must explain how the proposal would contradict our values or threaten our sustainability.",
},
consensus: {
tagline: "Everyone agrees to support the decision",
description:
"Take the time to get real alignment on high-stakes decisions. Everyone can live with it, even if it's not their favorite option.",
practicalDescription:
"For major decisions, we work together until everyone can support the outcome. This doesn't mean it's everyone's first choice, but that everyone understands and commits to the decision.",
},
consultative: {
tagline: "Gather input, then designated person decides",
description:
"When no one has clear expertise but we need various perspectives, one person gathers input and makes the final call.",
practicalDescription:
"A designated decision owner seeks input from all stakeholders, researches options, and makes the decision with clear reasoning. They explain how input influenced the final decision.",
},
"democratic-vote": {
tagline: "Majority decides, move forward together",
description:
"For larger groups or time-sensitive decisions, voting provides clear resolution while respecting everyone's input.",
practicalDescription:
"After discussion, we vote and move forward with the majority decision. We document minority concerns and revisit them if needed. Anonymous voting reduces peer pressure.",
},
"advice-process": {
tagline: "Decision-maker seeks input, then decides",
description:
"Balances inclusion with efficiency. The decision owner genuinely considers input but isn't bound by it.",
practicalDescription:
"The person most affected or willing becomes the decision owner. They seek advice from those with expertise and those affected, then make the decision and explain their reasoning.",
},
delegation: {
tagline: "Empower the responsible party to decide",
description:
"Trust those closest to the work to make decisions within clear scope and constraints.",
practicalDescription:
"We delegate decisions to the people most affected or with the most expertise. They have authority within defined boundaries and report back on outcomes.",
},
"defer-to-expert": {
tagline: "Trust the person who knows this best",
description:
"When someone has clear expertise, let them lead while keeping everyone informed.",
practicalDescription:
"The expert proposes solutions with reasoning, answers clarifying questions, and makes the final call. They explain their thinking, not just the outcome.",
},
"facilitated-discussion": {
tagline: "Talk it through with structure",
description:
"Use structured discussion to find clarity before choosing a specific decision method.",
practicalDescription:
"We clarify what we're deciding, share all relevant information, and each person shares their perspective with time limits. We identify alignment and differences, then choose the appropriate method.",
},
};
return (
details[framework] || {
tagline: "Select a framework to see details",
description: "",
practicalDescription:
"Choose a decision-making framework above to see how it works in practice.",
}
);
}
const formData = ref({ const formData = ref({
cooperativeName: "", cooperativeName: "",
dateEstablished: "", dateEstablished: "",
purpose: "", purpose: "",
coreValues: "", coreValues: "",
memberRequirements: "Shares our values and purpose\nContributes labour to the cooperative (by doing actual work, not just investing money)\nCommits to collective decision-making\nParticipates in governance responsibilities", memberRequirements:
"Shares our values and purpose\nContributes labour to our organization (by doing actual work, not just investing money)\nCommits to collective decision-making\nParticipates in governance responsibilities",
members: [{ name: "", email: "", joinDate: "", role: "" }], members: [{ name: "", email: "", joinDate: "", role: "" }],
trialPeriodMonths: 3, trialPeriodMonths: 3,
buyInAmount: "", buyInAmount: "",
noticeDays: 30, noticeDays: 30,
surplusPayoutDays: 30, surplusPayoutDays: 30,
buyInReturnDays: 90, buyInReturnDays: 90,
decisionFramework: "consent-based", // Default to consent-based
dayToDayLimit: 100, dayToDayLimit: 100,
regularDecisionMin: 100, regularDecisionMin: 100,
regularDecisionMax: 1000, regularDecisionMax: 1000,
@ -845,10 +1058,13 @@ const formData = ref({
surplusFrequency: "quarter", surplusFrequency: "quarter",
targetHours: 40, targetHours: 40,
roleRotationMonths: 6, roleRotationMonths: 6,
rotatingRoles: "Financial coordinator (handles bookkeeping, not financial decisions)\nMeeting facilitator\nExternal communications\nOthers", rotatingRoles:
sharedResponsibilities: "Governance and decision-making\nStrategic planning\nMutual support and care", "Financial coordinator (handles bookkeeping, not financial decisions)\nMeeting facilitator\nExternal communications\nOthers",
sharedResponsibilities:
"Governance and decision-making\nStrategic planning\nMutual support and care",
reviewFrequency: "year", reviewFrequency: "year",
assetDonationTarget: "", assetDonationTarget: "",
isLegallyRegistered: false,
legalStructure: "", legalStructure: "",
registeredLocation: "", registeredLocation: "",
fiscalYearEndMonth: "December", fiscalYearEndMonth: "December",
@ -872,6 +1088,35 @@ const loadSavedData = () => {
// Load data immediately // Load data immediately
loadSavedData(); loadSavedData();
// Sync with centralized coop info
watch(
() => coopInfo.value,
(newCoopInfo) => {
formData.value.cooperativeName = newCoopInfo.cooperativeName || formData.value.cooperativeName;
formData.value.dateEstablished = newCoopInfo.dateEstablished || formData.value.dateEstablished;
formData.value.purpose = newCoopInfo.purpose || formData.value.purpose;
formData.value.coreValues = newCoopInfo.coreValues || formData.value.coreValues;
},
{ deep: true, immediate: true }
);
// Update centralized store when key fields change
watch(
() => ({
cooperativeName: formData.value.cooperativeName,
dateEstablished: formData.value.dateEstablished,
purpose: formData.value.purpose,
coreValues: formData.value.coreValues,
legalStructure: formData.value.legalStructure,
registeredLocation: formData.value.registeredLocation,
isLegallyRegistered: formData.value.isLegallyRegistered,
}),
(newData) => {
updateCoopInfo(newData);
},
{ deep: true }
);
// Auto-save to localStorage (removed immediate to prevent overwriting) // Auto-save to localStorage (removed immediate to prevent overwriting)
watch( watch(
formData, formData,
@ -1072,9 +1317,30 @@ const exportData = computed(() => ({
// Pass the complete formData object - this is what the export functions use // Pass the complete formData object - this is what the export functions use
formData: formData.value, formData: formData.value,
// Also provide direct access to key fields for backward compatibility // Also provide direct access to key fields for backward compatibility
cooperativeName: formData.value.cooperativeName || "Worker Cooperative", cooperativeName: getDisplayName(),
section: "membership-agreement", section: "membership-agreement",
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
// Include computed/dynamic values for complete export
decisionFrameworkDetails: getFrameworkDetails(
formData.value.decisionFramework
),
decisionFrameworkLabel: getFrameworkLabel(formData.value.decisionFramework),
structuralChangeRequirement: getStructuralChangeRequirement(
formData.value.decisionFramework
),
paymentDayLabel: formData.value.paymentDay
? `${formData.value.paymentDay}${getOrdinalSuffix(
formData.value.paymentDay
)}`
: "15th",
// Include selected dropdown labels for readability
decisionFrameworkName:
decisionFrameworkOptions.find(
(opt) => opt.value === formData.value.decisionFramework
)?.label || "Consent-Based - No one objects strongly enough to block",
payPolicyName:
payPolicyOptions.find((opt) => opt.value === formData.value.payPolicy)
?.label || "Equal Pay - All members receive equal compensation",
})); }));
</script> </script>