refactor: replace Wizard with CoopBuilder in navigation, enhance budget store structure, and streamline template components for improved user experience

This commit is contained in:
Jennie Robinson Faber 2025-08-17 17:25:04 +01:00
parent eede87a273
commit f67b138d95
33 changed files with 4970 additions and 2451 deletions

View file

@ -1,368 +1,320 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Operating Plan</h2>
<USelect
v-model="selectedMonth"
:options="months"
placeholder="Select month" />
<h2 class="text-2xl font-semibold">Budget Worksheet</h2>
<div class="flex items-center gap-4">
<UButton @click="forceReinitialize" variant="outline" size="sm" color="orange">Force Re-init</UButton>
<UButton @click="resetWorksheet" variant="outline" size="sm">Reset All</UButton>
<UButton @click="exportBudget" variant="outline" size="sm">Export</UButton>
</div>
</div>
<!-- Cash Waterfall Summary -->
<!-- Budget Worksheet Table -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">
Cash Waterfall - {{ selectedMonth }}
</h3>
</template>
<div
class="flex items-center justify-between py-4 border-b border-neutral-200">
<div class="flex items-center gap-8">
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">
{{ budgetMetrics.grossRevenue.toLocaleString() }}
</div>
<div class="text-xs text-neutral-600">Gross Revenue</div>
</div>
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
<div class="text-center">
<div class="text-2xl font-bold text-red-600">
-{{ budgetMetrics.totalFees.toLocaleString() }}
</div>
<div class="text-xs text-neutral-600">Fees</div>
</div>
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
<div class="text-center">
<div class="text-2xl font-bold text-green-600">
{{ budgetMetrics.netRevenue.toLocaleString() }}
</div>
<div class="text-xs text-neutral-600">Net Revenue</div>
</div>
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">
{{ Math.round(budgetMetrics.savingsAmount).toLocaleString() }}
</div>
<div class="text-xs text-neutral-600">To Savings</div>
</div>
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
<div class="text-center">
<div class="text-2xl font-bold text-purple-600">
{{ Math.round(budgetMetrics.totalPayroll).toLocaleString() }}
</div>
<div class="text-xs text-neutral-600">Payroll</div>
</div>
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
<div class="text-center">
<div class="text-2xl font-bold text-orange-600">
{{ budgetMetrics.totalOverhead.toLocaleString() }}
</div>
<div class="text-xs text-neutral-600">Overhead</div>
</div>
</div>
</div>
<div class="pt-4">
<div class="flex items-center justify-between">
<span class="text-lg font-medium">Available for Operations</span>
<span class="text-2xl font-bold text-green-600"
>{{
Math.round(budgetMetrics.availableForOps).toLocaleString()
}}</span
>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse border border-gray-300 text-sm">
<thead>
<tr class="bg-gray-50">
<th class="border border-gray-300 px-3 py-2 text-left min-w-40 sticky left-0 bg-gray-50 z-10">Category</th>
<!-- Monthly columns -->
<th v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-center min-w-20">{{ month.label }}</th>
</tr>
</thead>
<tbody>
<!-- Revenue Section -->
<tr class="bg-blue-50 font-medium">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-blue-50 z-10">
<div class="flex items-center justify-between">
<span>Revenue</span>
<UButton @click="addRevenueLine" size="xs" variant="soft">+</UButton>
</div>
</td>
<td class="border border-gray-300 px-2 py-2" :colspan="monthlyHeaders.length"></td>
</tr>
<!-- Revenue by Category -->
<template v-for="(category, categoryName) in budgetStore.groupedRevenue" :key="`revenue-${categoryName}`">
<tr v-if="category.length > 0" class="bg-blue-100 font-medium">
<td class="border border-gray-300 px-4 py-1 sticky left-0 bg-blue-100 z-10 text-sm text-blue-700">
{{ categoryName }} ({{ category.length }} items)
</td>
<td class="border border-gray-300 px-2 py-1" :colspan="monthlyHeaders.length"></td>
</tr>
<tr v-for="item in category" :key="item.id">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-white z-10">
<div class="space-y-2">
<div class="flex items-center justify-between">
<input
v-model="item.name"
@blur="saveWorksheet"
class="bg-transparent border-none outline-none w-full font-medium"
:class="{ 'italic text-gray-500': item.name === 'New Revenue Item' }"
/>
<UButton @click="removeItem('revenue', item.id)" size="xs" variant="ghost" color="error">×</UButton>
</div>
<div class="flex items-center gap-2">
<BudgetCategorySelector
v-model="item.subcategory"
type="revenue"
:main-category="item.mainCategory"
placeholder="Subcategory"
@update:model-value="saveWorksheet"
/>
</div>
</div>
</td>
<!-- Monthly columns -->
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-1 py-1">
<input
type="number"
:value="item.monthlyValues?.[month.key] || 0"
@input="updateMonthlyValue('revenue', item.id, month.key, $event.target.value)"
class="w-full text-right border-none outline-none bg-transparent"
placeholder="0"
/>
</td>
</tr>
</template>
<!-- Total Revenue Row -->
<tr class="bg-blue-100 font-bold">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-blue-100 z-10">Total Revenue</td>
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right">
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.revenue || 0) }}
</td>
</tr>
<!-- Expenses Section -->
<tr class="bg-red-50 font-medium">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-red-50 z-10">
<div class="flex items-center justify-between">
<span>Expenses</span>
<UButton @click="addExpenseLine" size="xs" variant="soft">+</UButton>
</div>
</td>
<td class="border border-gray-300 px-2 py-2" :colspan="monthlyHeaders.length"></td>
</tr>
<!-- Expenses by Category -->
<template v-for="(category, categoryName) in budgetStore.groupedExpenses" :key="`expense-${categoryName}`">
<tr v-if="category.length > 0" class="bg-red-100 font-medium">
<td class="border border-gray-300 px-4 py-1 sticky left-0 bg-red-100 z-10 text-sm text-red-700">
{{ categoryName }}
</td>
<td class="border border-gray-300 px-2 py-1" :colspan="monthlyHeaders.length"></td>
</tr>
<tr v-for="item in category" :key="item.id">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-white z-10">
<div class="space-y-2">
<div class="flex items-center justify-between">
<input
v-model="item.name"
@blur="saveWorksheet"
class="bg-transparent border-none outline-none w-full font-medium"
:class="{ 'italic text-gray-500': item.name === 'New Expense Item' }"
/>
<UButton @click="removeItem('expenses', item.id)" size="xs" variant="ghost" color="error">×</UButton>
</div>
<div class="flex items-center gap-2">
<BudgetCategorySelector
v-model="item.subcategory"
type="expenses"
:main-category="item.mainCategory"
placeholder="Subcategory"
@update:model-value="saveWorksheet"
/>
</div>
</div>
</td>
<!-- Monthly columns -->
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-1 py-1">
<input
type="number"
:value="item.monthlyValues?.[month.key] || 0"
@input="updateMonthlyValue('expenses', item.id, month.key, $event.target.value)"
class="w-full text-right border-none outline-none bg-transparent"
placeholder="0"
/>
</td>
</tr>
</template>
<!-- Total Expenses Row -->
<tr class="bg-red-100 font-bold">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-red-100 z-10">Total Expenses</td>
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right">
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.expenses || 0) }}
</td>
</tr>
<!-- Net Income Row -->
<tr class="bg-green-100 font-bold text-lg">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-green-100 z-10">Net Income</td>
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right"
:class="getNetIncomeClass(budgetStore.monthlyTotals[month.key]?.net || 0)">
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.net || 0) }}
</td>
</tr>
</tbody>
</table>
</div>
</UCard>
<!-- Monthly Revenue Table -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Revenue by Stream</h3>
</template>
<UTable :rows="revenueStreams" :columns="revenueColumns">
<template #name-data="{ row }">
<div class="flex items-center gap-2">
<span class="font-medium">{{ row.name }}</span>
<RestrictionChip :restriction="row.restrictions" size="xs" />
</div>
</template>
<template #target-data="{ row }">
<span class="font-medium">{{ row.target.toLocaleString() }}</span>
</template>
<template #committed-data="{ row }">
<span class="font-medium text-green-600"
>{{ row.committed.toLocaleString() }}</span
>
</template>
<template #actual-data="{ row }">
<span
class="font-medium"
:class="
row.actual >= row.committed ? 'text-green-600' : 'text-orange-600'
">
{{ row.actual.toLocaleString() }}
</span>
</template>
<template #variance-data="{ row }">
<span :class="row.variance >= 0 ? 'text-green-600' : 'text-red-600'">
{{ row.variance >= 0 ? "+" : "" }}{{
row.variance.toLocaleString()
}}
</span>
</template>
</UTable>
</UCard>
<!-- Costs Breakdown -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<UCard>
<template #header>
<h3 class="text-lg font-medium">Costs</h3>
</template>
<div class="space-y-4">
<div>
<h4 class="font-medium text-sm mb-2">Payroll</h4>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600"
>Wages ({{ budgetMetrics.totalHours }}h @ {{
budgetMetrics.hourlyWage
}})</span
>
<span class="font-medium"
>{{
Math.round(budgetMetrics.grossWages).toLocaleString()
}}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600"
>On-costs ({{ budgetMetrics.oncostPct }}%)</span
>
<span class="font-medium"
>{{
Math.round(budgetMetrics.oncosts).toLocaleString()
}}</span
>
</div>
<div
class="flex justify-between text-sm font-medium border-t pt-2">
<span>Total Payroll</span>
<span
>{{
Math.round(budgetMetrics.totalPayroll).toLocaleString()
}}</span
>
</div>
</div>
</div>
<div>
<h4 class="font-medium text-sm mb-2">Overhead</h4>
<div class="space-y-2">
<div
v-if="budgetStore.overheadCosts.length === 0"
class="text-sm text-neutral-500 italic">
No overhead costs added yet
</div>
<div
v-for="cost in budgetStore.overheadCosts"
:key="cost.id"
class="flex justify-between text-sm">
<span class="text-neutral-600">{{ cost.name }}</span>
<span class="font-medium"
>{{ (cost.amount || 0).toLocaleString() }}</span
>
</div>
<div
class="flex justify-between text-sm font-medium border-t pt-2">
<span>Total Overhead</span>
<span>{{ budgetMetrics.totalOverhead.toLocaleString() }}</span>
</div>
</div>
</div>
<div>
<h4 class="font-medium text-sm mb-2">Production</h4>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Dev kits</span>
<span class="font-medium">500</span>
</div>
<div
class="flex justify-between text-sm font-medium border-t pt-2">
<span>Total Production</span>
<span>500</span>
</div>
</div>
</div>
</div>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-medium">Net Impact on Savings</h3>
</template>
<div class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Net Revenue</span>
<span class="font-medium text-green-600"
>{{ budgetMetrics.netRevenue.toLocaleString() }}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Total Costs</span>
<span class="font-medium text-red-600"
>-{{
Math.round(budgetMetrics.totalCosts).toLocaleString()
}}</span
>
</div>
<div class="flex justify-between text-lg font-bold border-t pt-3">
<span>Net</span>
<span
:class="
budgetMetrics.monthlyNet >= 0
? 'text-green-600'
: 'text-red-600'
"
>{{ budgetMetrics.monthlyNet >= 0 ? "+" : "" }}{{
Math.round(budgetMetrics.monthlyNet).toLocaleString()
}}</span
>
</div>
</div>
<div class="bg-neutral-50 rounded-lg p-4">
<h4 class="font-medium text-sm mb-3">Allocation</h4>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">To Savings</span>
<span class="font-medium"
>{{
Math.round(budgetMetrics.savingsAmount).toLocaleString()
}}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Available</span>
<span class="font-medium"
>{{
Math.round(
budgetMetrics.availableAfterSavings
).toLocaleString()
}}</span
>
</div>
</div>
</div>
<div class="text-xs text-neutral-600 space-y-1">
<p>
<RestrictionChip restriction="Restricted" size="xs" /> funds can
only be used for approved purposes.
</p>
<p>
<RestrictionChip restriction="General" size="xs" /> funds have no
restrictions.
</p>
</div>
</div>
</UCard>
</div>
</section>
</template>
<script setup lang="ts">
// Use real store data
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore();
// Import components explicitly
import BudgetCategorySelector from '~/components/BudgetCategorySelector.vue';
// Use budget worksheet store
const budgetStore = useBudgetStore();
const cashStore = useCashStore();
const selectedMonth = ref("2024-01");
const months = ref([
{ label: "January 2024", value: "2024-01" },
{ label: "February 2024", value: "2024-02" },
{ label: "March 2024", value: "2024-03" },
]);
// Calculate budget values from real data
const budgetMetrics = computed(() => {
const totalHours = membersStore.capacityTotals.targetHours || 0;
const hourlyWage = policiesStore.equalHourlyWage || 0;
const oncostPct = policiesStore.payrollOncostPct || 0;
const grossWages = totalHours * hourlyWage;
const oncosts = grossWages * (oncostPct / 100);
const totalPayroll = grossWages + oncosts;
const totalOverhead = budgetStore.overheadCosts.reduce(
(sum, cost) => sum + (cost.amount || 0),
0
);
const grossRevenue = streamsStore.totalMonthlyAmount || 0;
// Calculate fees from streams with platform fees
const totalFees = streamsStore.streams.reduce((sum, stream) => {
const revenue = stream.targetMonthlyAmount || 0;
const platformFee = (stream.platformFeePct || 0) / 100;
const revShareFee = (stream.revenueSharePct || 0) / 100;
return sum + revenue * platformFee + revenue * revShareFee;
}, 0);
const netRevenue = grossRevenue - totalFees;
const totalCosts = totalPayroll + totalOverhead;
const monthlyNet = netRevenue - totalCosts;
const savingsAmount = Math.max(0, monthlyNet * 0.3); // Save 30% of positive net if possible
const availableAfterSavings = Math.max(0, monthlyNet - savingsAmount);
const availableForOps = Math.max(
0,
netRevenue - totalPayroll - totalOverhead - savingsAmount
);
return {
grossRevenue,
totalFees,
netRevenue,
totalCosts,
monthlyNet,
savingsAmount,
availableAfterSavings,
totalPayroll,
grossWages,
oncosts,
totalOverhead,
availableForOps,
totalHours,
hourlyWage,
oncostPct,
};
// Generate monthly headers for the next 12 months
const monthlyHeaders = computed(() => {
const headers = [];
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthName = date.toLocaleString('default', { month: 'short' });
const year = date.getFullYear();
headers.push({
key: `${year}-${String(date.getMonth() + 1).padStart(2, '0')}`,
label: `${monthName} ${year}`
});
}
return headers;
});
// Convert streams to budget table format
const revenueStreams = computed(() =>
streamsStore.streams.map((stream) => ({
id: stream.id,
name: stream.name,
target: stream.targetMonthlyAmount || 0,
committed: Math.round((stream.targetMonthlyAmount || 0) * 0.8), // 80% committed assumption
actual: Math.round((stream.targetMonthlyAmount || 0) * 0.9), // 90% actual assumption
variance: Math.round((stream.targetMonthlyAmount || 0) * 0.1), // 10% positive variance
restrictions: stream.restrictions || "General",
}))
);
// Initialize from wizard data on first load
onMounted(async () => {
console.log('Budget page mounted, initializing...');
if (!budgetStore.isInitialized) {
await budgetStore.initializeFromWizardData();
}
console.log('Budget worksheet:', budgetStore.budgetWorksheet);
console.log('Grouped revenue:', budgetStore.groupedRevenue);
console.log('Grouped expenses:', budgetStore.groupedExpenses);
});
const revenueColumns = [
{ id: "name", key: "name", label: "Stream" },
{ id: "target", key: "target", label: "Target" },
{ id: "committed", key: "committed", label: "Committed" },
{ id: "actual", key: "actual", label: "Actual" },
{ id: "variance", key: "variance", label: "Variance" },
];
// Budget worksheet functions
function updateValue(category: string, itemId: string, year: string, scenario: string, value: string) {
budgetStore.updateBudgetValue(category, itemId, year, scenario, value);
}
function updateMonthlyValue(category: string, itemId: string, monthKey: string, value: string) {
budgetStore.updateMonthlyValue(category, itemId, monthKey, value);
}
function addRevenueLine() {
console.log('Adding revenue line...');
budgetStore.addBudgetItem('revenue', 'New Revenue Item');
}
function addExpenseLine() {
console.log('Adding expense line...');
budgetStore.addBudgetItem('expenses', 'New Expense Item');
}
function removeItem(category: string, itemId: string) {
budgetStore.removeBudgetItem(category, itemId);
}
function saveWorksheet() {
// Auto-save is handled by the store persistence
console.log('Worksheet saved');
}
function resetWorksheet() {
if (confirm('Are you sure you want to reset all budget data? This cannot be undone.')) {
budgetStore.resetBudgetWorksheet();
// Force re-initialization
budgetStore.isInitialized = false;
budgetStore.initializeFromWizardData();
}
}
async function forceReinitialize() {
console.log('Force re-initializing budget...');
// Clear all persistent data
localStorage.removeItem('urgent-tools-budget');
localStorage.removeItem('urgent-tools-streams');
localStorage.removeItem('urgent-tools-members');
localStorage.removeItem('urgent-tools-policies');
// Reset the store state completely
budgetStore.isInitialized = false;
budgetStore.budgetWorksheet.revenue = [];
budgetStore.budgetWorksheet.expenses = [];
// Reset categories to defaults
budgetStore.revenueCategories = [
'Games & Products',
'Services & Contracts',
'Grants & Funding',
'Community Support',
'Partnerships',
'Investment Income',
'In-Kind Contributions'
];
budgetStore.expenseCategories = [
'Salaries & Benefits',
'Development Costs',
'Equipment & Technology',
'Marketing & Outreach',
'Office & Operations',
'Legal & Professional',
'Other Expenses'
];
// Force re-initialization
await budgetStore.initializeFromWizardData();
console.log('Re-initialization complete');
}
function exportBudget() {
const data = {
worksheet: budgetStore.budgetWorksheet,
totals: budgetStore.budgetTotals,
exportedAt: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `budget-worksheet-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Helper functions
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount || 0);
}
function getNetIncomeClass(amount: number): string {
if (amount > 0) return 'text-green-600';
if (amount < 0) return 'text-red-600';
return 'text-gray-600';
}
// SEO
useSeoMeta({
title: "Budget Worksheet - Plan Your Co-op's Financial Future",
description: "Interactive budget planning tool with multiple scenarios and multi-year projections for worker cooperatives.",
});
</script>

View file

@ -0,0 +1,684 @@
<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>
<button
@click="loadSampleData"
class="px-4 py-2 text-sm bg-blue-50 border-2 border-blue-200 rounded-lg text-blue-700 hover:bg-blue-100 hover:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
:aria-label="'Load sample data to see example offers'"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Load sample data
</div>
</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";
import {
membersSample,
skillsCatalogSample,
problemsCatalogSample,
sampleSelections
} from "~/sample/skillsToOffersSamples";
// Store integration
const planStore = usePlanStore();
// Initialize with default data
const members = ref<Member[]>([
{ id: "1", name: "Alex Chen", role: "Game Designer", hourly: 75, availableHrs: 30 },
{ id: "2", name: "Jordan Smith", role: "Developer", hourly: 80, availableHrs: 35 },
{ id: "3", name: "Sam Rodriguez", role: "Artist", hourly: 70, availableHrs: 25 }
]);
const availableSkills = ref<SkillTag[]>([
{ id: "unity", label: "Unity Development" },
{ id: "art", label: "2D/3D Art" },
{ id: "design", label: "Game Design" },
{ id: "audio", label: "Audio Design" },
{ id: "writing", label: "Narrative Writing" },
{ id: "marketing", label: "Marketing" },
{ id: "business", label: "Business Strategy" },
{ id: "web", label: "Web Development" },
{ id: "mobile", label: "Mobile Development" },
{ id: "consulting", label: "Technical Consulting" }
]);
const availableProblems = ref<ProblemTag[]>([
{
id: "indie-games",
label: "Indie game development",
examples: [
"Small studios needing extra development capacity",
"Solo developers wanting art/audio support",
"Teams needing game design consultation"
]
},
{
id: "corporate-training",
label: "Corporate training games",
examples: [
"Companies wanting engaging employee training",
"HR departments needing onboarding tools",
"Safety training for industrial workers"
]
},
{
id: "educational",
label: "Educational technology",
examples: [
"Schools needing interactive learning tools",
"Universities wanting research simulations",
"Non-profits creating awareness campaigns"
]
},
{
id: "prototypes",
label: "Rapid prototyping",
examples: [
"Startups validating game concepts",
"Publishers testing market fit",
"Researchers creating proof-of-concepts"
]
}
]);
// 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');
}
// Sample data loading
function loadSampleData() {
// Replace data with samples
members.value = [...membersSample];
availableSkills.value = [...skillsCatalogSample];
availableProblems.value = [...problemsCatalogSample];
// Set pre-selected skills and problems
selectedSkills.value = { ...sampleSelections.selectedSkillsByMember };
selectedProblems.value = [...sampleSelections.selectedProblems];
// Update store with new members
planStore.setMembers(members.value);
// Trigger offer generation immediately
nextTick(() => {
debouncedGenerateOffers();
});
}
// 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>

468
pages/coop-builder.vue Normal file
View file

@ -0,0 +1,468 @@
<template>
<div>
<!-- No WizardSubnav for co-op setup tool -->
<section class="py-8 max-w-4xl mx-auto font-mono">
<!-- Header -->
<div class="mb-10 text-center">
<h1
class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4"
>
Co-op Builder
</h1>
</div>
<!-- Completed State -->
<div v-if="isCompleted" class="text-center py-12 relative">
<!-- Dithered shadow background -->
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8"
>
<div
class="w-16 h-16 bg-black dark:bg-white border-2 border-black dark:border-white flex items-center justify-center mx-auto mb-4"
>
<UIcon name="i-heroicons-check" class="w-8 h-8 text-white dark:text-black" />
</div>
<h2
class="text-2xl font-bold text-black dark:text-white mb-2 uppercase tracking-wide"
>
You're all set!
</h2>
<p class="text-neutral-600 dark:text-neutral-400 mb-6">
Your co-op is configured and ready to go.
</p>
<div class="flex justify-center gap-4">
<button class="export-btn" @click="restartWizard" :disabled="isResetting">
Start Over
</button>
<button class="export-btn primary" @click="navigateTo('/budget')">
Go to Dashboard
</button>
</div>
</div>
</div>
<!-- Vertical Steps Layout -->
<div v-else class="space-y-4">
<!-- Step 1: Members -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 1"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 1 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(1)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
membersStore.isValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
"
>
<UIcon
v-if="membersStore.isValid"
name="i-heroicons-check"
class="w-4 h-4"
/>
<span v-else>1</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Add your team
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 1 }"
/>
</div>
</div>
<div
v-if="focusedStep === 1"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardMembersStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 2: Wage -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 2"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 2 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(2)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
policiesStore.isValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
"
>
<UIcon
v-if="policiesStore.isValid"
name="i-heroicons-check"
class="w-4 h-4"
/>
<span v-else>2</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Set your wage
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 2 }"
/>
</div>
</div>
<div
v-if="focusedStep === 2"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardPoliciesStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 3: Costs -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 3"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 3 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(3)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2 bg-black dark:bg-white text-white dark:text-black border-black dark:border-white"
>
<UIcon name="i-heroicons-check" class="w-4 h-4" />
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Monthly costs
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 3 }"
/>
</div>
</div>
<div
v-if="focusedStep === 3"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardCostsStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 4: Revenue -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 4"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 4 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(4)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
streamsStore.hasValidStreams
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
"
>
<UIcon
v-if="streamsStore.hasValidStreams"
name="i-heroicons-check"
class="w-4 h-4"
/>
<span v-else>4</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Revenue streams
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 4 }"
/>
</div>
</div>
<div
v-if="focusedStep === 4"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardRevenueStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 5: Review -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 5"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 5 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(5)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
canComplete
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
"
>
<UIcon v-if="canComplete" name="i-heroicons-check" class="w-4 h-4" />
<span v-else>5</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Review & finish
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 5 }"
/>
</div>
</div>
<div
v-if="focusedStep === 5"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardReviewStep @complete="completeWizard" @reset="resetWizard" />
</div>
</div>
</div>
<!-- Progress Actions -->
<div class="flex justify-between items-center pt-8">
<button class="export-btn" @click="resetWizard" :disabled="isResetting">
Start Over
</button>
<div class="flex items-center gap-4">
<!-- Save status -->
<div
class="flex items-center gap-2 text-sm font-mono uppercase tracking-wide"
>
<UIcon
v-if="saveStatus === 'saving'"
name="i-heroicons-arrow-path"
class="w-4 h-4 animate-spin text-neutral-500 dark:text-neutral-400"
/>
<UIcon
v-if="saveStatus === 'saved'"
name="i-heroicons-check-circle"
class="w-4 h-4 text-black dark:text-white"
/>
<span
v-if="saveStatus === 'saving'"
class="text-neutral-500 dark:text-neutral-400"
>Saving...</span
>
<span v-if="saveStatus === 'saved'" class="text-black dark:text-white"
>Saved</span
>
</div>
<button v-if="canComplete" class="export-btn primary" @click="completeWizard">
Complete Setup
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
// Stores
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const coopBuilderStore = useCoopBuilderStore();
// UI state
const focusedStep = ref(1);
const saveStatus = ref("");
const isResetting = ref(false);
const isCompleted = ref(false);
// Computed validation
const canComplete = computed(
() => membersStore.isValid && policiesStore.isValid && streamsStore.hasValidStreams
);
// Save status handler
function handleSaveStatus(status: "saving" | "saved" | "error") {
saveStatus.value = status;
if (status === "saved") {
// Clear status after delay
setTimeout(() => {
if (saveStatus.value === "saved") {
saveStatus.value = "";
}
}, 2000);
}
}
// Step management
function setFocusedStep(step: number) {
// Toggle if clicking on already focused step
if (focusedStep.value === step) {
focusedStep.value = 0; // Close the section
} else {
focusedStep.value = step; // Open the section
}
}
function completeWizard() {
// Mark setup as complete and show restart button for testing
isCompleted.value = true;
}
async function resetWizard() {
isResetting.value = true;
// Reset all stores
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
// Reset coop builder state
coopBuilderStore.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
async function restartWizard() {
isResetting.value = true;
// Reset completion state
isCompleted.value = false;
focusedStep.value = 1;
// Reset all stores and coop builder state
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
coopBuilderStore.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
// SEO
useSeoMeta({
title: "Co-op Builder - Build Your Financial Foundation",
description:
"Build your co-op's financial foundation: set up members, policies, costs, and revenue streams.",
});
</script>

View file

@ -1,8 +1,8 @@
<template>
<WizardPage />
<CoopBuilderPage />
</template>
<script setup lang="ts">
// Reuse the existing wizard content by importing it as a component
import WizardPage from "~/pages/wizard.vue";
// Reuse the existing coop builder content by importing it as a component
import CoopBuilderPage from "~/pages/coop-builder.vue";
</script>

View file

@ -344,7 +344,7 @@ const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const cashStore = useCashStore();
const sessionStore = useSessionStore();
const wizardStore = useWizardStore();
const coopBuilderStore = useCoopBuilderStore();
const isResetting = ref(false);
@ -556,7 +556,7 @@ async function restartWizard() {
sessionStore.resetSession();
// Reset wizard state
wizardStore.reset();
coopBuilderStore.reset();
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -222,29 +222,7 @@ useHead({
</script>
<style scoped>
/* Ubuntu font import */
@import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
/* Removed full-screen dither pattern to avoid gray haze in dark mode */
/* Exact shadow style from value-flow inspiration */
.dither-shadow {
background: black;
background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px;
}
@media (prefers-color-scheme: dark) {
.dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
}
:global(.dark) .dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
/* Template index specific styles - no longer duplicated in main.css */
.dither-shadow-disabled {
background: black;
@ -265,74 +243,6 @@ useHead({
background-image: radial-gradient(black 1px, transparent 1px);
}
/* Rely on Tailwind bg utilities on container */
.template-card {
@apply relative;
font-family: "Ubuntu", monospace;
}
.help-section {
@apply relative;
}
.coming-soon {
opacity: 0.7;
}
.dither-tag {
position: relative;
background: white;
}
.dither-tag::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
black 1px,
black 2px
);
opacity: 0.1;
pointer-events: none;
}
/* Button styling - pure bitmap, no colors */
.bitmap-button {
font-family: "Ubuntu Mono", monospace !important;
text-transform: uppercase;
font-weight: bold;
letter-spacing: 0.5px;
position: relative;
}
.bitmap-button:hover {
transform: translateY(-1px) translateX(-1px);
transition: transform 0.1s ease;
}
.bitmap-button:hover::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
right: -1px;
bottom: -1px;
border: 1px solid black;
background: white;
z-index: -1;
}
.disabled-button {
opacity: 0.6;
cursor: not-allowed;
}
/* Remove any inherited rounded corners */
.template-card > *,
.help-section > *,

View file

@ -1,18 +1,13 @@
<template>
<div>
<!-- Wizard Subnav -->
<WizardSubnav />
<!-- Export Options - Top -->
<ExportOptions
:export-data="exportData"
filename="membership-agreement"
title="Membership Agreement"
/>
title="Membership Agreement" />
<div
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100"
>
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
<!-- Document Container -->
<div class="document-page">
<div class="template-content">
@ -20,8 +15,9 @@
<div class="text-center mb-8">
<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"
:data-coop-name="formData.cooperativeName || 'Worker Cooperative'"
>
:data-coop-name="
formData.cooperativeName || 'Worker Cooperative'
">
MEMBERSHIP AGREEMENT
</h1>
</div>
@ -29,8 +25,7 @@
<!-- Section 1: Who We Are -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
1. Who We Are
</h2>
@ -42,8 +37,7 @@
size="xl"
class="w-full"
@input="debouncedAutoSave"
@change="autoSave"
/>
@change="autoSave" />
</UFormField>
<UFormField label="Date Established" class="form-group-large">
@ -52,8 +46,7 @@
type="date"
size="xl"
class="large-field"
@change="autoSave"
/>
@change="autoSave" />
</UFormField>
<UFormField label="Our Purpose" class="form-group-large">
@ -64,8 +57,7 @@
size="xl"
class="large-field"
@input="debouncedAutoSave"
@change="autoSave"
/>
@change="autoSave" />
</UFormField>
<UFormField label="Our Core Values" class="form-group-large">
@ -76,8 +68,7 @@
size="xl"
class="large-field"
@input="debouncedAutoSave"
@change="autoSave"
/>
@change="autoSave" />
</UFormField>
<div class="form-group-large">
@ -91,8 +82,7 @@
size="sm"
color="primary"
variant="outline"
icon="i-heroicons-plus"
>
icon="i-heroicons-plus">
Add Member
</UButton>
</div>
@ -101,10 +91,10 @@
<div
v-for="(member, index) in formData.members"
:key="index"
class="border border-neutral-600 rounded-lg p-4"
>
class="border border-neutral-600 rounded-lg p-4">
<div class="flex items-start justify-between mb-3">
<h4 class="font-medium text-neutral-900 dark:text-neutral-100">
<h4
class="font-medium text-neutral-900 dark:text-neutral-100">
Member {{ index + 1 }}
</h4>
<UButton
@ -113,8 +103,7 @@
size="sm"
color="red"
variant="ghost"
icon="i-heroicons-trash"
>
icon="i-heroicons-trash">
</UButton>
</div>
@ -126,8 +115,7 @@
size="xl"
class="large-field"
@input="debouncedAutoSave"
@change="autoSave"
/>
@change="autoSave" />
</UFormField>
<UFormField label="Email" class="form-group-large">
@ -138,8 +126,7 @@
size="xl"
class="large-field"
@input="debouncedAutoSave"
@change="autoSave"
/>
@change="autoSave" />
</UFormField>
<UFormField label="Join Date" class="form-group-large">
@ -148,22 +135,19 @@
type="date"
size="xl"
class="large-field"
@change="autoSave"
/>
@change="autoSave" />
</UFormField>
<UFormField
label="Current Role (Optional)"
class="form-group-large"
>
class="form-group-large">
<UInput
v-model="member.role"
placeholder="e.g., Coordinator, Developer, etc."
size="xl"
class="large-field"
@input="debouncedAutoSave"
@change="autoSave"
/>
@change="autoSave" />
</UFormField>
</div>
</div>
@ -175,16 +159,14 @@
<!-- Section 2: Membership -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
2. Membership
</h2>
<div class="space-y-4">
<div>
<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">
Who Can Be a Member
</h3>
<p class="content-paragraph mb-3 leading-relaxed text-left">
@ -193,8 +175,8 @@
<ul class="content-list my-2 pl-6 list-disc">
<li>Shares our values and purpose</li>
<li>
Contributes labour to the cooperative (by doing actual work, not just
investing money)
Contributes labour to the cooperative (by doing actual work,
not just investing money)
</li>
<li>Commits to collective decision-making</li>
<li>Participates in governance responsibilities</li>
@ -203,14 +185,14 @@
<div>
<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">
Becoming a Member
</h3>
<p class="content-paragraph">
New members join through a consent process, which means existing members
must agree that adding this person won't harm the cooperative.
New members join through a consent process, which means
existing members must agree that adding this person won't harm
the cooperative.
</p>
<ol class="content-list numbered my-2 pl-6 list-decimal">
@ -221,8 +203,7 @@
type="number"
placeholder="3"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
months working together
</li>
<li>Values alignment conversation</li>
@ -233,8 +214,7 @@
type="number"
placeholder="1000"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
(can be paid over time or waived based on need)
</li>
</ol>
@ -242,20 +222,19 @@
<div>
<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">
Leaving the Cooperative
</h3>
<p class="content-paragraph flex items-baseline gap-2 flex-wrap">
<p
class="content-paragraph flex items-baseline gap-2 flex-wrap">
Members can leave anytime with
<UInput
v-model="formData.noticeDays"
type="number"
placeholder="30"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
days notice. The cooperative will:
</p>
@ -267,8 +246,7 @@
type="number"
placeholder="30"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
days
</li>
<li class="flex items-baseline gap-2 flex-wrap">
@ -278,11 +256,12 @@
type="number"
placeholder="90"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
days
</li>
<li>Maintain respectful ongoing relationships when possible</li>
<li>
Maintain respectful ongoing relationships when possible
</li>
</ul>
</div>
</div>
@ -291,80 +270,71 @@
<!-- Section 3: How We Make Decisions -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
3. How We Make Decisions
</h2>
<div class="space-y-4">
<div>
<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
</h3>
<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 cooperative. An objection
must explain how the proposal would contradict our values or threaten
our sustainability.
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.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<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">
Day-to-Day Decisions
</h3>
<p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
>
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
Decisions under $<UInput
v-model="formData.dayToDayLimit"
type="number"
placeholder="100"
class="inline-field number-field"
@change="autoSave"
/>
can be made by any member. Just tell others what you did at the next
meeting.
@change="autoSave" />
can be made by any member. Just tell others what you did at
the next meeting.
</p>
</div>
<div>
<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">
Regular Decisions
</h3>
<p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
>
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
Decisions between $<UInput
v-model="formData.regularDecisionMin"
type="number"
placeholder="100"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
and $<UInput
v-model="formData.regularDecisionMax"
type="number"
placeholder="1000"
class="inline-field number-field"
@change="autoSave"
/>
need consent from members present at a meeting (minimum 2 members).
@change="autoSave" />
need consent from members present at a meeting (minimum 2
members).
</p>
</div>
</div>
<div>
<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">
Major Decisions
</h3>
<p class="content-paragraph mb-3 leading-relaxed text-left">
@ -379,8 +349,7 @@
type="number"
placeholder="5000"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
</li>
<li>Fundamental changes to our purpose or structure</li>
<li>Dissolution of the cooperative</li>
@ -389,8 +358,7 @@
<div>
<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">
Meeting Structure
</h3>
<ul class="content-list my-2 pl-6 list-disc">
@ -400,8 +368,7 @@
v-model="formData.meetingFrequency"
placeholder="weekly"
class="inline-field"
@change="autoSave"
/>
@change="autoSave" />
</li>
<li class="flex items-baseline gap-2 flex-wrap">
Emergency meetings need
@ -410,12 +377,13 @@
type="number"
placeholder="24"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
hours notice
</li>
<li>We rotate who facilitates meetings</li>
<li>Decisions and reasoning get documented in shared notes</li>
<li>
Decisions and reasoning get documented in shared notes
</li>
</ul>
</div>
</div>
@ -424,28 +392,25 @@
<!-- Section 4: Money and Labour -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
4. Money and Labour
</h2>
<div class="space-y-4">
<div>
<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">
Equal Ownership
</h3>
<p class="content-paragraph mb-3 leading-relaxed text-left">
Each member owns an equal share of the cooperative, regardless of hours
worked or tenure.
Each member owns an equal share of the cooperative, regardless
of hours worked or tenure.
</p>
</div>
<div>
<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">
Paying Ourselves
</h3>
<ul class="content-list my-2 pl-6 list-disc">
@ -455,8 +420,7 @@
type="number"
placeholder="25"
class="inline-field number-field"
@change="autoSave"
/>/hour for all members
@change="autoSave" />/hour for all members
</li>
<li class="flex items-baseline gap-2 flex-wrap">
Or: Equal monthly draw of $<UInput
@ -464,8 +428,7 @@
type="number"
placeholder="2000"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
per member
</li>
<li class="flex items-baseline gap-2 flex-wrap">
@ -475,8 +438,7 @@
:items="dayOptions"
placeholder="15"
class="inline-field"
@change="autoSave"
/>
@change="autoSave" />
of each month
</li>
<li class="flex items-baseline gap-2 flex-wrap">
@ -485,8 +447,7 @@
v-model="formData.surplusFrequency"
placeholder="quarter"
class="inline-field"
@change="autoSave"
/>
@change="autoSave" />
</li>
</ul>
</div>
@ -494,8 +455,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<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">
Work Expectations
</h3>
<ul class="content-list my-2 pl-6 list-disc">
@ -506,29 +466,31 @@
type="number"
placeholder="40"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
(flexible based on capacity)
</li>
<li>We explicitly reject crunch culture</li>
<li>Members communicate their capacity openly</li>
<li>
We adjust workload collectively when someone needs reduced hours
We adjust workload collectively when someone needs reduced
hours
</li>
</ul>
</div>
<div>
<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">
Financial Transparency
</h3>
<ul class="content-list my-2 pl-6 list-disc">
<li>All members can access all financial records anytime</li>
<li>
All members can access all financial records anytime
</li>
<li>Monthly financial check-ins at meetings</li>
<li>
Quarterly reviews of our runway (how many months we can operate)
Quarterly reviews of our runway (how many months we can
operate)
</li>
</ul>
</div>
@ -539,34 +501,31 @@
<!-- Section 5: Roles and Responsibilities -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
5. Roles and Responsibilities
</h2>
<div class="space-y-4">
<div>
<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">
Rotating Roles
</h3>
<p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
>
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
We rotate operational roles every
<UInput
v-model="formData.roleRotationMonths"
type="number"
placeholder="6"
class="inline-field number-field"
@change="autoSave"
/>
@change="autoSave" />
months. Current roles include:
</p>
<ul class="content-list">
<li>
Financial coordinator (handles bookkeeping, not financial decisions)
Financial coordinator (handles bookkeeping, not financial
decisions)
</li>
<li>Meeting facilitator</li>
<li>External communications</li>
@ -576,8 +535,7 @@
<div>
<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">
Shared Responsibilities
</h3>
<p class="content-paragraph mb-3 leading-relaxed text-left">
@ -595,16 +553,14 @@
<!-- Section 6: Conflict and Care -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
6. Conflict and Care
</h2>
<div class="space-y-4">
<div>
<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">
When Conflict Happens
</h3>
<ol class="content-list numbered my-2 pl-6 list-decimal">
@ -617,15 +573,16 @@
<div>
<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">
Care Commitments
</h3>
<ul class="content-list my-2 pl-6 list-disc">
<li>We check in about capacity and wellbeing regularly</li>
<li>We honour diverse access needs</li>
<li>We maintain flexibility for life circumstances</li>
<li>We contribute to mutual aid when members face hardship</li>
<li>
We contribute to mutual aid when members face hardship
</li>
</ul>
</div>
</div>
@ -634,31 +591,27 @@
<!-- Section 7: Changing This Agreement -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
7. Changing This Agreement
</h2>
<p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
>
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
This is a living document. We review it every
<UInput
v-model="formData.reviewFrequency"
placeholder="year"
class="inline-field"
@change="autoSave"
/>
and update it through our consent process. Small clarifications can happen
anytime; structural changes need full member consent.
@change="autoSave" />
and update it through our consent process. Small clarifications
can happen anytime; structural changes need full member consent.
</p>
</div>
<!-- Section 8: If We Need to Close -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
8. If We Need to Close
</h2>
@ -675,8 +628,7 @@
<UInput
v-model="formData.assetDonationTarget"
placeholder="Enter organization name"
class="inline-field wide-field"
/>
class="inline-field wide-field" />
</li>
</ol>
</div>
@ -685,8 +637,7 @@
<!-- Section 9: Legal Bits -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
9. Legal Bits
</h2>
@ -697,8 +648,7 @@
v-model="formData.legalStructure"
size="xl"
class="w-full"
placeholder="Cooperative corporation, LLC, partnership, etc."
/>
placeholder="Cooperative corporation, LLC, partnership, etc." />
</UFormField>
<UFormField label="Registered in" class="form-group-inline">
@ -707,8 +657,7 @@
placeholder="State/Province"
size="xl"
class="inline-field w-full"
@change="autoSave"
/>
@change="autoSave" />
</UFormField>
<div class="fiscal-year-group">
@ -720,25 +669,23 @@
placeholder="Month"
size="xl"
class="w-60"
@change="autoSave"
/>
@change="autoSave" />
<USelect
v-model="formData.fiscalYearEndDay"
:items="dayOptions"
placeholder="Day"
size="xl"
class="w-40"
@change="autoSave"
/>
@change="autoSave" />
</div>
</UFormField>
</div>
</div>
<p class="content-paragraph mb-3 leading-relaxed text-left">
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.
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.
</p>
</div>
</div>
@ -746,46 +693,39 @@
<!-- Section 10: Agreement Review -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
>
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
10. Agreement Review
</h2>
<div class="space-y-4">
<p class="content-paragraph mb-3 leading-relaxed text-left">
By using this agreement, we commit to these principles and to showing up
for each other.
By using this agreement, we commit to these principles and to
showing up for each other.
</p>
<div
class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300"
>
class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300">
<p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
>
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
This agreement was last updated on
<UInput
v-model="formData.lastUpdated"
type="date"
class="inline-field"
@change="autoSave"
/>. We commit to reviewing it on
@change="autoSave" />. We commit to reviewing it on
<UInput
v-model="formData.nextReview"
type="date"
class="inline-field"
@change="autoSave"
/>
@change="autoSave" />
or sooner if circumstances require.
</p>
</div>
<div
class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950"
>
class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950">
<p
class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic"
>
class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic">
[Space for member signatures when printed]
</p>
</div>
@ -799,8 +739,7 @@
<ExportOptions
:export-data="exportData"
filename="membership-agreement"
title="Membership Agreement"
/>
title="Membership Agreement" />
</div>
</template>
@ -898,7 +837,10 @@ onMounted(() => {
// Auto-save individual field changes immediately
const autoSave = () => {
localStorage.setItem("membership-agreement-data", JSON.stringify(formData.value));
localStorage.setItem(
"membership-agreement-data",
JSON.stringify(formData.value)
);
console.log("Manual auto-save triggered:", formData.value);
};
@ -950,8 +892,12 @@ const handlePrint = () => {
`;
// Add signature lines for each member
const membersWithNames = formData.value.members?.filter((m) => m.name) || [];
const numSignatures = Math.max(2, Math.min(8, membersWithNames.length || 4));
const membersWithNames =
formData.value.members?.filter((m) => m.name) || [];
const numSignatures = Math.max(
2,
Math.min(8, membersWithNames.length || 4)
);
for (let i = 0; i < numSignatures; i++) {
const memberName = membersWithNames[i]?.name || "";
@ -981,7 +927,8 @@ const handlePrint = () => {
value = formData.value.cooperativeName;
else if (input.closest('[label="Date Established"]'))
value = formData.value.dateEstablished;
else if (input.closest('[label="Our Purpose"]')) value = formData.value.purpose;
else if (input.closest('[label="Our Purpose"]'))
value = formData.value.purpose;
else if (input.closest('[label="Our Core Values"]'))
value = formData.value.coreValues;
else if (input.closest('[label="Legal Structure"]'))
@ -998,13 +945,16 @@ const handlePrint = () => {
// Handle member fields
else if (input.closest(".border-neutral-200")) {
const memberCard = input.closest(".border-neutral-200");
const memberIndex = Array.from(memberCard.parentNode.children).indexOf(memberCard);
const memberIndex = Array.from(memberCard.parentNode.children).indexOf(
memberCard
);
const member = formData.value.members?.[memberIndex];
if (member) {
if (input.closest('[label="Full Name"]')) value = member.name;
else if (input.closest('[label="Email"]')) value = member.email;
else if (input.closest('[label="Join Date"]')) value = member.joinDate;
else if (input.closest('[label="Current Role (Optional)"]')) value = member.role;
else if (input.closest('[label="Current Role (Optional)"]'))
value = member.role;
}
}
// Fallback to input.value
@ -1143,11 +1093,13 @@ const exportData = computed(() => ({
.template-content.font-ubuntu,
.template-content.font-ubuntu * {
font-family: "Ubuntu", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
font-family: "Ubuntu", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif !important;
}
.template-content.font-inter,
.template-content.font-inter * {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif !important;
}
</style>

View file

@ -1,26 +1,20 @@
<template>
<div>
<!-- Wizard Subnav -->
<WizardSubnav />
<!-- Export Options - Top -->
<ExportOptions
:export-data="exportData"
filename="tech-charter"
title="Technology Charter"
/>
title="Technology Charter" />
<div
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100"
>
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
<!-- Document Container -->
<div class="document-page">
<div class="template-content">
<!-- Document Header -->
<div class="text-center mb-8">
<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">
Tech Charter
</h1>
</div>
@ -30,9 +24,12 @@
<!-- Purpose Section -->
<div class="section-card">
<div>
<h2 class="text-2xl font-bold text-neutral-800 mb-4">Charter Purpose</h2>
<h2 class="text-2xl font-bold text-neutral-800 mb-4">
Charter Purpose
</h2>
<p class="text-neutral-600 mb-4">
Describe what this charter will guide and why it matters to your group.
Describe what this charter will guide and why it matters to
your group.
</p>
</div>
@ -40,8 +37,7 @@
<textarea
v-model="charterPurpose"
class="w-full min-h-32 p-4 border-2 border-neutral-300 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:border-black dark:focus:border-white transition-colors resize-y"
rows="4"
/>
rows="4" />
</div>
</div>
@ -52,37 +48,39 @@
Define Your Principles & Importance
</h2>
<p class="text-neutral-600 mb-6">
Select principles and set their importance. Zero means excluded, 5 means
critical.
Select principles and set their importance. Zero means
excluded, 5 means critical.
</p>
</div>
<div class="grid md:grid-cols-1 gap-4">
<div v-for="principle in principles" :key="principle.id" class="relative">
<div
v-for="principle in principles"
:key="principle.id"
class="relative">
<!-- Dithered shadow for selected cards -->
<div
v-if="principleWeights[principle.id] > 0"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
:class="[
'relative transition-all',
principleWeights[principle.id] > 0
? 'principle-selected border-2 border-black dark:border-white bg-white dark:bg-neutral-950'
? 'item-selected border-2 border-black dark:border-white bg-white dark:bg-neutral-950'
: 'border border-black dark:border-white bg-transparent',
]"
>
]">
<div class="p-6">
<div class="flex items-start gap-6">
<!-- Principle info -->
<div class="flex-1">
<div
:class="[
'principle-text-bg mb-3',
principleWeights[principle.id] > 0 ? 'selected' : '',
]"
>
'item-text-bg mb-3',
principleWeights[principle.id] > 0
? 'selected'
: '',
]">
<h3 class="font-bold text-lg mb-2">
{{ principle.name }}
</h3>
@ -92,8 +90,7 @@
? 'text-neutral-700'
: 'text-neutral-600'
"
class="text-sm"
>
class="text-sm">
{{ principle.description }}
</p>
</div>
@ -102,8 +99,7 @@
<!-- Importance selector -->
<div class="flex flex-col items-center gap-2">
<label
class="text-xs font-bold text-neutral-500 uppercase tracking-wider"
>
class="text-xs font-bold text-neutral-500 uppercase tracking-wider">
Importance
</label>
@ -119,8 +115,7 @@
? 'bg-black text-white border-black dark:bg-white dark:text-black dark:border-white'
: 'bg-white border-neutral-300 hover:border-neutral-500 dark:bg-neutral-950',
]"
:title="`Set importance to ${level}`"
>
:title="`Set importance to ${level}`">
{{ level }}
</button>
</div>
@ -131,7 +126,11 @@
{{ principleWeights[principle.id] || 0 }}
</div>
<div class="text-xs text-neutral-500">
{{ getWeightLabel(principleWeights[principle.id] || 0) }}
{{
getWeightLabel(
principleWeights[principle.id] || 0
)
}}
</div>
</div>
</div>
@ -140,20 +139,19 @@
<!-- Non-negotiable toggle (only shows for weights > 0) -->
<div
v-if="principleWeights[principle.id] > 0"
class="mt-4 pt-4 border-t border-neutral-200"
>
class="mt-4 pt-4 border-t border-neutral-200">
<label
:class="[
'flex items-center gap-3 cursor-pointer principle-label-bg px-2 py-1',
nonNegotiables.includes(principle.id) ? 'selected' : '',
]"
>
'flex items-center gap-3 cursor-pointer item-label-bg px-2 py-1',
nonNegotiables.includes(principle.id)
? 'selected'
: '',
]">
<input
type="checkbox"
:checked="nonNegotiables.includes(principle.id)"
@change="toggleNonNegotiable(principle.id)"
class="w-4 h-4"
/>
class="w-4 h-4" />
<span class="text-sm font-medium text-red-600">
Make this non-negotiable
</span>
@ -163,9 +161,9 @@
<!-- Show rubric description when selected -->
<div
v-if="principleWeights[principle.id] > 0"
class="mt-4 p-3 principle-label-bg selected border border-neutral-200"
>
<div class="text-xs font-bold uppercase text-neutral-500 mb-1">
class="mt-4 p-3 item-label-bg selected border border-neutral-200">
<div
class="text-xs font-bold uppercase text-neutral-500 mb-1">
Evaluation Criteria:
</div>
<div class="text-sm">
@ -183,8 +181,7 @@
<div>
<h2
class="text-2xl font-bold text-neutral-800 mb-2"
id="constraints-heading"
>
id="constraints-heading">
Technical Constraints
</h2>
</div>
@ -195,18 +192,15 @@
<div
class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup"
aria-labelledby="auth-heading"
>
aria-labelledby="auth-heading">
<div
v-for="option in authOptions"
:key="option.value"
class="relative"
>
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.sso === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="constraints.sso = option.value"
:aria-pressed="constraints.sso === option.value"
@ -217,8 +211,7 @@
constraints.sso === option.value
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]"
>
]">
{{ option.label }}
</button>
</div>
@ -230,18 +223,15 @@
<div
class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup"
aria-labelledby="hosting-heading"
>
aria-labelledby="hosting-heading">
<div
v-for="option in hostingOptions"
:key="option.value"
class="relative"
>
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.hosting === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="constraints.hosting = option.value"
:aria-pressed="constraints.hosting === option.value"
@ -252,8 +242,7 @@
constraints.hosting === option.value
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]"
>
]">
{{ option.label }}
</button>
</div>
@ -261,29 +250,32 @@
</fieldset>
<fieldset class="bg-neutral-50 p-6 rounded-lg">
<legend class="font-semibold text-lg">Required Integrations</legend>
<p class="text-sm text-neutral-600 mb-4">Select all that apply</p>
<legend class="font-semibold text-lg">
Required Integrations
</legend>
<p class="text-sm text-neutral-600 mb-4">
Select all that apply
</p>
<div class="flex flex-wrap gap-3 constraint-buttons">
<div
v-for="integration in integrationOptions"
:key="integration"
class="relative"
>
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.integrations.includes(integration)"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="toggleIntegration(integration)"
:aria-pressed="constraints.integrations.includes(integration)"
:aria-pressed="
constraints.integrations.includes(integration)
"
:class="[
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
constraints.integrations.includes(integration)
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]"
>
]">
{{ integration }}
</button>
</div>
@ -291,22 +283,21 @@
</fieldset>
<fieldset class="bg-neutral-50 p-6 rounded-lg">
<legend class="font-semibold text-lg">Support Expectations</legend>
<legend class="font-semibold text-lg">
Support Expectations
</legend>
<div
class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup"
aria-labelledby="support-heading"
>
aria-labelledby="support-heading">
<div
v-for="option in supportOptions"
:key="option.value"
class="relative"
>
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.support === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="constraints.support = option.value"
:aria-pressed="constraints.support === option.value"
@ -317,8 +308,7 @@
constraints.support === option.value
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]"
>
]">
{{ option.label }}
</button>
</div>
@ -326,22 +316,21 @@
</fieldset>
<fieldset class="bg-neutral-50 p-6 rounded-lg">
<legend class="font-semibold text-lg">Migration Timeline</legend>
<legend class="font-semibold text-lg">
Migration Timeline
</legend>
<div
class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup"
aria-labelledby="timeline-heading"
>
aria-labelledby="timeline-heading">
<div
v-for="option in timelineOptions"
:key="option.value"
class="relative"
>
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.timeline === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="constraints.timeline = option.value"
:aria-pressed="constraints.timeline === option.value"
@ -352,8 +341,7 @@
constraints.timeline === option.value
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]"
>
]">
{{ option.label }}
</button>
</div>
@ -367,8 +355,7 @@
<button
@click="resetForm"
class="export-btn"
title="Clear all form data and start over"
>
title="Clear all form data and start over">
<UIcon name="i-heroicons-arrow-path" />
Reset Form
</button>
@ -381,17 +368,19 @@
v-if="charterGenerated"
class="relative animate-fadeIn"
role="main"
aria-label="Generated Technology Charter"
>
aria-label="Generated Technology Charter">
<!-- Dithered shadow -->
<div class="absolute top-4 left-4 right-0 bottom-0 dither-shadow"></div>
<div
class="absolute top-4 left-4 right-0 bottom-0 dither-shadow"></div>
<!-- Charter container -->
<div
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8"
>
<div class="text-center mb-8 pb-6 border-b-2 border-black dark:border-white">
<h2 class="text-3xl font-bold text-neutral-800" id="charter-title">
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
<div
class="text-center mb-8 pb-6 border-b-2 border-black dark:border-white">
<h2
class="text-3xl font-bold text-neutral-800"
id="charter-title">
Technology Charter
</h2>
<p class="text-neutral-600 mt-2">
@ -407,8 +396,7 @@
<div class="mt-4">
<button
@click="scrollToTop"
class="text-sm text-neutral-600 hover:text-neutral-800 underline focus:outline-none focus:ring-2 focus:ring-neutral-500 rounded"
>
class="text-sm text-neutral-600 hover:text-neutral-800 underline focus:outline-none focus:ring-2 focus:ring-neutral-500 rounded">
Back to form
</button>
</div>
@ -418,10 +406,10 @@
<section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
<p class="text-neutral-700 leading-relaxed">
This charter guides our cooperative's technology decisions based on our
shared values and operational needs. It ensures we choose tools that
support our mission while respecting our principles of autonomy,
sustainability, and mutual aid.
This charter guides our cooperative's technology decisions
based on our shared values and operational needs. It ensures
we choose tools that support our mission while respecting our
principles of autonomy, sustainability, and mutual aid.
</p>
</section>
@ -429,21 +417,25 @@
class="mb-8"
v-if="
Object.keys(principleWeights).filter(
(p) => principleWeights[p] > 0 && !nonNegotiables.includes(p)
(p) =>
principleWeights[p] > 0 && !nonNegotiables.includes(p)
).length > 0
"
>
<h3 class="text-xl font-bold text-neutral-800 mb-3">Core Principles</h3>
">
<h3 class="text-xl font-bold text-neutral-800 mb-3">
Core Principles
</h3>
<ul class="space-y-2">
<li
v-for="principleId in Object.keys(principleWeights).filter(
(p) => principleWeights[p] > 0 && !nonNegotiables.includes(p)
(p) =>
principleWeights[p] > 0 && !nonNegotiables.includes(p)
)"
:key="principleId"
class="flex items-start"
>
class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span>{{ principles.find((p) => p.id === principleId)?.name }}</span>
<span>{{
principles.find((p) => p.id === principleId)?.name
}}</span>
</li>
</ul>
</section>
@ -453,16 +445,18 @@
Non-Negotiable Requirements
</h3>
<p class="text-red-600 font-semibold mb-3">
Any vendor failing these requirements is automatically disqualified.
Any vendor failing these requirements is automatically
disqualified.
</p>
<ul class="space-y-2">
<li
v-for="principleId in nonNegotiables"
:key="principleId"
class="flex items-start text-red-600 font-semibold"
>
class="flex items-start text-red-600 font-semibold">
<span class="mr-2"></span>
<span>{{ principles.find((p) => p.id === principleId)?.name }}</span>
<span>{{
principles.find((p) => p.id === principleId)?.name
}}</span>
</li>
</ul>
</section>
@ -477,7 +471,8 @@
<span
>Authentication:
{{
authOptions.find((o) => o.value === constraints.sso)?.label
authOptions.find((o) => o.value === constraints.sso)
?.label
}}</span
>
</li>
@ -486,11 +481,15 @@
<span
>Hosting:
{{
hostingOptions.find((o) => o.value === constraints.hosting)?.label
hostingOptions.find(
(o) => o.value === constraints.hosting
)?.label
}}</span
>
</li>
<li v-if="constraints.integrations.length > 0" class="flex items-start">
<li
v-if="constraints.integrations.length > 0"
class="flex items-start">
<span class="text-purple-600 mr-2"></span>
<span
>Required Integrations:
@ -502,7 +501,9 @@
<span
>Support Level:
{{
supportOptions.find((o) => o.value === constraints.support)?.label
supportOptions.find(
(o) => o.value === constraints.support
)?.label
}}</span
>
</li>
@ -511,8 +512,9 @@
<span
>Migration Timeline:
{{
timelineOptions.find((o) => o.value === constraints.timeline)
?.label
timelineOptions.find(
(o) => o.value === constraints.timeline
)?.label
}}</span
>
</li>
@ -520,27 +522,27 @@
</section>
<section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">Evaluation Rubric</h3>
<h3 class="text-xl font-bold text-neutral-800 mb-3">
Evaluation Rubric
</h3>
<p class="text-neutral-700 mb-4">
Score each vendor option using these weighted criteria (0-5 scale):
Score each vendor option using these weighted criteria (0-5
scale):
</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="bg-neutral-100">
<th
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left"
>
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
Criterion
</th>
<th
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left"
>
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
Description
</th>
<th
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center"
>
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center">
Weight
</th>
</tr>
@ -549,21 +551,17 @@
<tr
v-for="weight in sortedWeights"
:key="weight.id"
class="hover:bg-neutral-50"
>
class="hover:bg-neutral-50">
<td
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 font-semibold"
>
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 font-semibold">
{{ weight.name }}
</td>
<td
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-sm text-neutral-600"
>
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-sm text-neutral-600">
{{ weight.rubricDescription }}
</td>
<td
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center font-bold text-neutral-600"
>
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center font-bold text-neutral-600">
{{ principleWeights[weight.id] }}
</td>
</tr>
@ -580,8 +578,8 @@
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Any vendor failing a non-negotiable requirement is automatically
eliminated</span
>Any vendor failing a non-negotiable requirement is
automatically eliminated</span
>
</li>
<li class="flex items-start">
@ -594,8 +592,8 @@
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>When scores are within 10%, choose based on alignment with
cooperative values</span
>When scores are within 10%, choose based on alignment
with cooperative values</span
>
</li>
<li class="flex items-start">
@ -638,11 +636,16 @@
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span>Document any exceptions with clear justification</span>
<span
>Document any exceptions with clear justification</span
>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span>Share learnings with other cooperatives in our network</span>
<span
>Share learnings with other cooperatives in our
network</span
>
</li>
</ul>
</section>
@ -656,8 +659,7 @@
<ExportOptions
:export-data="exportData"
filename="tech-charter"
title="Technology Charter"
/>
title="Technology Charter" />
</div>
</template>
@ -705,13 +707,15 @@ const principles = [
id: "portability",
name: "Data Freedom",
description: "Easy export, no vendor lock-in, migration-friendly",
rubricDescription: "Export capabilities, proprietary formats, switching costs",
rubricDescription:
"Export capabilities, proprietary formats, switching costs",
defaultWeight: 4,
},
{
id: "opensource",
name: "Open Source & Community",
description: "FOSS preference, transparent development, community governance",
description:
"FOSS preference, transparent development, community governance",
rubricDescription: "License type, community involvement, code transparency",
defaultWeight: 3,
},
@ -719,7 +723,8 @@ const principles = [
id: "sustainability",
name: "Sustainable Operations",
description: "Predictable costs, green hosting, efficient resource use",
rubricDescription: "Total cost of ownership, carbon footprint, resource efficiency",
rubricDescription:
"Total cost of ownership, carbon footprint, resource efficiency",
defaultWeight: 3,
},
{
@ -732,8 +737,10 @@ const principles = [
{
id: "usability",
name: "User Experience",
description: "Intuitive interface, minimal learning curve, daily efficiency",
rubricDescription: "Onboarding time, user satisfaction, workflow integration",
description:
"Intuitive interface, minimal learning curve, daily efficiency",
rubricDescription:
"Onboarding time, user satisfaction, workflow integration",
defaultWeight: 3,
},
];
@ -769,7 +776,9 @@ const timelineOptions = [
const sortedWeights = computed(() => {
return principles
.filter((p) => principleWeights.value[p.id] > 0)
.sort((a, b) => principleWeights.value[b.id] - principleWeights.value[a.id]);
.sort(
(a, b) => principleWeights.value[b.id] - principleWeights.value[a.id]
);
});
const canGenerateCharter = computed(() => {
@ -862,7 +871,9 @@ const resetForm = () => {
};
const scrollToTop = () => {
document.querySelector(".template-wrapper").scrollIntoView({ behavior: "smooth" });
document
.querySelector(".template-wrapper")
.scrollIntoView({ behavior: "smooth" });
};
// Load saved data
@ -905,9 +916,13 @@ onMounted(() => {
});
// Auto-save when data changes
watch([charterPurpose, principleWeights, nonNegotiables, constraints], autoSave, {
deep: true,
});
watch(
[charterPurpose, principleWeights, nonNegotiables, constraints],
autoSave,
{
deep: true,
}
);
</script>
<style scoped>
@ -919,115 +934,6 @@ watch([charterPurpose, principleWeights, nonNegotiables, constraints], autoSave,
@apply mb-8 relative;
}
/* Principle card selected styling - using dithered shadow and background */
.principle-selected {
position: relative;
background: white;
}
.principle-selected::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
black 1px,
black 2px
);
opacity: 0.1;
pointer-events: none;
z-index: 0;
}
.principle-selected > * {
position: relative;
z-index: 1;
}
/* Dark mode */
html.dark .principle-selected {
background: #0a0a0a;
}
html.dark .principle-selected::after {
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
white 1px,
white 2px
);
}
/* Text background for better readability */
.principle-label-bg {
background: rgba(255, 255, 255, 0.85);
border-radius: 4px;
}
.principle-label-bg.selected {
background: rgba(255, 255, 255, 0.95);
}
/* Dark mode text backgrounds */
html.dark .principle-text-bg {
background: rgba(10, 10, 10, 0.9);
}
html.dark .principle-text-bg.selected {
background: rgba(10, 10, 10, 0.95);
}
html.dark .principle-label-bg {
background: rgba(10, 10, 10, 0.85);
}
html.dark .principle-label-bg.selected {
background: rgba(10, 10, 10, 0.95);
}
/* Constraint button selected styling - black background */
button.constraint-selected {
background: black !important;
color: white !important;
}
button.constraint-selected:hover {
background: black !important;
color: white !important;
}
/* Dark mode */
html.dark button.constraint-selected {
background: white !important;
color: black !important;
}
html.dark button.constraint-selected:hover {
background: white !important;
color: black !important;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out;
}
.content-title {
font-size: 2.5rem;
font-weight: 700;

View file

@ -1,372 +0,0 @@
<template>
<div>
<!-- Wizard Subnav -->
<WizardSubnav />
<section class="py-8 max-w-4xl mx-auto">
<!-- Header -->
<div class="mb-10">
<h1 class="text-5xl font-black text-black mb-4 leading-tight">
Set up your co-op
</h1>
<p class="text-xl text-neutral-700 font-medium">
Get your worker-owned co-op configured in a few simple steps. Jump to
any step or work through them in order.
</p>
</div>
<!-- Completed State -->
<div v-if="isCompleted" class="text-center py-12">
<div
class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<UIcon name="i-heroicons-check" class="w-8 h-8 text-green-600" />
</div>
<h2 class="text-2xl font-bold text-black mb-2">You're all set!</h2>
<p class="text-neutral-600 mb-6">
Your co-op is configured and ready to go.
</p>
<div class="flex justify-center gap-4">
<UButton
variant="outline"
color="gray"
@click="restartWizard"
:disabled="isResetting">
Start Over
</UButton>
<UButton
@click="navigateTo('/scenarios')"
size="lg"
variant="solid"
color="black">
Go to Dashboard
</UButton>
</div>
</div>
<!-- Vertical Steps Layout -->
<div v-else class="space-y-4">
<!-- Step 1: Members -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-yellow-50 transition-colors"
:class="{ 'bg-yellow-100': focusedStep === 1 }"
@click="setFocusedStep(1)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
:class="
membersStore.isValid
? 'bg-green-100 text-green-700'
: 'bg-white text-black border-2 border-black'
">
<UIcon
v-if="membersStore.isValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>1</span>
</div>
<div>
<h3 class="text-2xl font-black text-black">Add your team</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 1 }" />
</div>
</div>
<div v-if="focusedStep === 1" class="p-8 bg-yellow-25">
<WizardMembersStep @save-status="handleSaveStatus" />
</div>
</div>
<!-- Step 2: Wage -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-green-50 transition-colors"
:class="{ 'bg-green-100': focusedStep === 2 }"
@click="setFocusedStep(2)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
:class="
policiesStore.isValid
? 'bg-green-100 text-green-700'
: 'bg-white text-black border-2 border-black'
">
<UIcon
v-if="policiesStore.isValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>2</span>
</div>
<div>
<h3 class="text-2xl font-black text-black">Set your wage</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 2 }" />
</div>
</div>
<div v-if="focusedStep === 2" class="p-8 bg-green-25">
<WizardPoliciesStep @save-status="handleSaveStatus" />
</div>
</div>
<!-- Step 3: Costs -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-blue-50 transition-colors"
:class="{ 'bg-blue-100': focusedStep === 3 }"
@click="setFocusedStep(3)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-sm font-bold">
<UIcon name="i-heroicons-check" class="w-4 h-4" />
</div>
<div>
<h3 class="text-2xl font-black text-black">Monthly costs</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 3 }" />
</div>
</div>
<div v-if="focusedStep === 3" class="p-8 bg-blue-25">
<WizardCostsStep @save-status="handleSaveStatus" />
</div>
</div>
<!-- Step 4: Revenue -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-purple-50 transition-colors"
:class="{ 'bg-purple-100': focusedStep === 4 }"
@click="setFocusedStep(4)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
:class="
streamsStore.hasValidStreams
? 'bg-green-100 text-green-700'
: 'bg-white text-black border-2 border-black'
">
<UIcon
v-if="streamsStore.hasValidStreams"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>4</span>
</div>
<div>
<h3 class="text-2xl font-black text-black">
Revenue streams
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 4 }" />
</div>
</div>
<div v-if="focusedStep === 4" class="p-8 bg-purple-25">
<WizardRevenueStep @save-status="handleSaveStatus" />
</div>
</div>
<!-- Step 5: Review -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-orange-50 transition-colors"
:class="{ 'bg-orange-100': focusedStep === 5 }"
@click="setFocusedStep(5)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
:class="
canComplete
? 'bg-green-100 text-green-700'
: 'bg-white text-black border-2 border-black'
">
<UIcon
v-if="canComplete"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>5</span>
</div>
<div>
<h3 class="text-2xl font-black text-black">
Review & finish
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 5 }" />
</div>
</div>
<div v-if="focusedStep === 5" class="p-8 bg-orange-25">
<WizardReviewStep @complete="completeWizard" @reset="resetWizard" />
</div>
</div>
<!-- Progress Actions -->
<div class="flex justify-between items-center pt-8">
<UButton
variant="outline"
color="red"
@click="resetWizard"
:disabled="isResetting">
Start Over
</UButton>
<div class="flex items-center gap-4">
<!-- Save status -->
<div class="flex items-center gap-2 text-sm">
<UIcon
v-if="saveStatus === 'saving'"
name="i-heroicons-arrow-path"
class="w-4 h-4 animate-spin text-neutral-500" />
<UIcon
v-if="saveStatus === 'saved'"
name="i-heroicons-check-circle"
class="w-4 h-4 text-green-500" />
<span v-if="saveStatus === 'saving'" class="text-neutral-500"
>Saving...</span
>
<span v-if="saveStatus === 'saved'" class="text-green-600"
>Saved</span
>
</div>
<UButton
v-if="canComplete"
@click="completeWizard"
size="lg"
variant="solid"
color="black">
Complete Setup
</UButton>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
// Stores
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const wizardStore = useWizardStore();
// UI state
const focusedStep = ref(1);
const saveStatus = ref("");
const isResetting = ref(false);
const isCompleted = ref(false);
// Computed validation
const canComplete = computed(
() =>
membersStore.isValid &&
policiesStore.isValid &&
streamsStore.hasValidStreams
);
// Save status handler
function handleSaveStatus(status: "saving" | "saved" | "error") {
saveStatus.value = status;
if (status === "saved") {
// Clear status after delay
setTimeout(() => {
if (saveStatus.value === "saved") {
saveStatus.value = "";
}
}, 2000);
}
}
// Step management
function setFocusedStep(step: number) {
// Toggle if clicking on already focused step
if (focusedStep.value === step) {
focusedStep.value = 0; // Close the section
} else {
focusedStep.value = step; // Open the section
}
}
function completeWizard() {
// Mark setup as complete and show restart button for testing
isCompleted.value = true;
}
async function resetWizard() {
isResetting.value = true;
// Reset all stores
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
// Reset wizard state
wizardStore.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
async function restartWizard() {
isResetting.value = true;
// Reset completion state
isCompleted.value = false;
focusedStep.value = 1;
// Reset all stores and wizard state
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
wizardStore.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
// SEO
useSeoMeta({
title: "Setup Wizard - Configure Your Co-op",
description:
"Set up your co-op members, policies, costs, and revenue streams.",
});
</script>

View file

@ -1,11 +1,7 @@
<template>
<div>
<!-- Wizard Subnav -->
<WizardSubnav />
<div
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
style="font-family: 'Ubuntu', 'Ubuntu Mono', monospace"
>
<div class="max-w-6xl mx-auto px-4 relative">
<div class="mb-8">
@ -72,69 +68,6 @@
</div>
</div>
</div>
<div class="mt-12 help-section">
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6"
>
<h2
class="text-xl font-semibold text-neutral-900 dark:text-white mb-3"
style="font-family: 'Ubuntu', monospace"
>
How Wizards Work
</h2>
<div class="grid md:grid-cols-2 gap-6 text-neutral-900 dark:text-neutral-100">
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace"
>
FILL OUT FORMS
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
Wizards include form fields for all necessary information. Data
auto-saves as you type.
</p>
</div>
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace"
>
LOCAL STORAGE
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
All data saves in your browser only. Nothing is sent to external
servers.
</p>
</div>
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace"
>
EXPORT OPTIONS
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
Download as PDF, plain text, Markdown.
</p>
</div>
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace"
>
RESUME ANYTIME
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
Come back later and your progress will be saved. Clear browser data to
start fresh.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -158,7 +91,7 @@ const templates = [
},
{
id: "conflict-resolution-framework",
name: "Conflict Resolution Framework",
name: "Conflict Resolution",
description:
"A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
icon: "i-heroicons-scale",
@ -310,35 +243,4 @@ useHead({
background: white;
z-index: -1;
}
.disabled-button {
opacity: 0.6;
cursor: not-allowed;
}
.template-card > *,
.help-section > *,
button,
.px-4,
div[class*="border"] {
border-radius: 0 !important;
}
* {
border-radius: 0 !important;
font-family: "Ubuntu", monospace;
}
html.dark :deep(.text-neutral-700),
html.dark :deep(.text-neutral-500),
html.dark :deep(.bg-neutral-50),
html.dark :deep(.bg-neutral-100) {
color: white !important;
background-color: #0a0a0a !important;
}
:deep(.border-neutral-200),
:deep(.border-neutral-300) {
border-color: black !important;
}
</style>