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

90
app.vue
View file

@ -9,12 +9,11 @@
<div class="relative flex items-center justify-center">
<NuxtLink
to="/"
class="flex items-center gap-2 hover:opacity-80 transition-opacity">
<UIcon
name="i-heroicons-rocket-launch"
class="text-primary-500" />
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<h1
class="font-semibold text-black dark:text-white text-center">
class="text-black dark:text-white text-center text-2xl font-mono uppercase font-bold"
>
Urgent Tools
</h1>
</NuxtLink>
@ -25,49 +24,41 @@
<nav
class="mt-4 flex items-center justify-center gap-1"
role="navigation"
aria-label="Main navigation">
aria-label="Main navigation"
>
<NuxtLink
to="/coop-planner"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{
'bg-neutral-100 dark:bg-neutral-800': isCoopSection,
}">
Co-Op in 6 Months
'bg-neutral-100 dark:bg-neutral-800': isCoopBuilderSection,
}"
>
Co-Op Builder
</NuxtLink>
<!-- Coach feature - hidden for now -->
<!-- <NuxtLink
to="/coach/skills-to-offers"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{
'bg-neutral-100 dark:bg-neutral-800': $route.path.startsWith('/coach'),
}"
>
Coach
</NuxtLink> -->
<NuxtLink
to="/wizards"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{
'bg-neutral-100 dark:bg-neutral-800':
$route.path === '/wizards',
}">
'bg-neutral-100 dark:bg-neutral-800': $route.path === '/wizards',
}"
>
Wizards
</NuxtLink>
<UButton
color="gray"
variant="ghost"
disabled
class="px-3 py-2 text-sm text-black dark:text-white rounded-md opacity-60 cursor-not-allowed">
Downloads
</UButton>
</nav>
<nav
v-if="isCoopSection"
class="mt-2 flex items-center justify-center gap-1"
role="navigation"
aria-label="Co-Op in 6 Months sub navigation">
<NuxtLink
v-for="item in coopMenu[0]"
:key="item.to"
:to="item.to"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{
'bg-neutral-100 dark:bg-neutral-800':
route.path === item.to,
}">
{{ item.label }}
</NuxtLink>
</nav>
<div class="py-8">
<CoopBuilderSubnav v-if="isCoopBuilderSection" />
<WizardSubnav v-if="isWizardSection" />
</div>
</header>
<NuxtPage />
</UContainer>
@ -82,17 +73,20 @@
</template>
<script setup lang="ts">
const coopMenu = [
[
{ label: "Dashboard", to: "/" },
{ label: "Revenue Mix", to: "/mix" },
{ label: "Budget", to: "/budget" },
{ label: "Scenarios", to: "/scenarios" },
{ label: "Cash", to: "/cash" },
{ label: "Glossary", to: "/glossary" },
],
];
const route = useRoute();
const isCoopSection = computed(() => route.path === "/coop-planner");
const isCoopBuilderSection = computed(
() =>
route.path === "/coop-planner" ||
route.path === "/coop-builder" ||
route.path === "/" ||
route.path === "/mix" ||
route.path === "/budget" ||
route.path === "/scenarios" ||
route.path === "/cash" ||
route.path === "/glossary"
);
const isWizardSection = computed(
() => route.path === "/wizards" || route.path.startsWith("/templates/")
);
</script>

View file

@ -1,29 +1,25 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
@import "tailwindcss";
@import "@nuxt/ui";
/* 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");
[data-theme="dark"] {
html { @apply bg-white text-neutral-900; }
html.dark { @apply bg-neutral-950 text-neutral-100; }
@theme {
--font-body: "Ubuntu", "Inter", sans-serif;
--font-mono: "Ubuntu Mono", monospace;
}
/* Disable all animations, transitions, and smooth scrolling app-wide */
html,
body {
scroll-behavior: auto !important;
@apply font-body bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100;
}
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
/* All headers use Inter font */
h1, h2, h3, h4, h5, h6 {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
/* =========================
TEMPLATE DOCUMENT LAYOUT
========================= */
@ -47,7 +43,7 @@ body {
========================= */
.section-card {
@apply border border-neutral-200 dark:border-neutral-800 rounded-lg p-5 mb-8;
@apply border border-neutral-200 dark:border-neutral-800 rounded-lg p-5 mb-8 relative;
}
.section-card::before {
@ -57,27 +53,24 @@ body {
left: 4px;
right: -4px;
bottom: -4px;
background: black;
@apply bg-black dark:bg-white;
background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px;
z-index: -1;
}
html.dark .section-card::before {
background-image: radial-gradient(black 1px, transparent 1px);
}
.section-title {
font-size: 1.75rem;
font-weight: 800;
color: inherit;
margin: 0 0 1rem 0;
@apply text-3xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
.subsection-title {
font-size: 1.25rem;
font-weight: 600;
color: #374151;
margin: 0 0 0.75rem 0;
text-decoration: none;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.25rem;
@apply text-xl font-semibold text-neutral-700 dark:text-neutral-300 mb-3 no-underline border-b border-neutral-200 dark:border-neutral-700 pb-1;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
/* =========================
@ -121,55 +114,36 @@ body {
}
.form-group-large .large-field {
@apply block w-full mt-2 text-lg rounded-md border-none transition-colors duration-150 ease-in-out;
@apply block w-full mt-2 text-lg rounded-md border-none transition-colors duration-150 ease-in-out bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100;
}
.form-group-large .large-field:focus {
background: #f3f4f6;
@apply bg-neutral-100 dark:bg-neutral-700 outline-2 outline-blue-500 -outline-offset-2;
box-shadow: none;
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
.inline-field {
display: inline-block;
margin: 0 0.25rem;
min-width: 120px;
border: none;
background: #f9fafb;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
@apply inline-block mx-1 min-w-[120px] border-none bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-2 py-1 rounded;
}
.inline-field:focus {
background: #f3f4f6;
outline: 1px solid #3b82f6;
outline-offset: -1px;
@apply bg-neutral-100 dark:bg-neutral-700 outline-1 outline-blue-500 -outline-offset-1;
}
.number-field {
min-width: 80px !important;
text-align: center;
@apply min-w-[80px] text-center;
}
.wide-field {
min-width: 250px !important;
@apply min-w-[250px];
}
.form-group-block .block-field {
display: block;
width: 100%;
margin-top: 0.25rem;
border: none;
background: #f9fafb;
padding: 0.5rem;
border-radius: 0.25rem;
@apply block w-full mt-1 border-none bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 p-2 rounded;
}
.form-group-block .block-field:focus {
background: #f3f4f6;
outline: 1px solid #3b82f6;
outline-offset: -1px;
@apply bg-neutral-100 dark:bg-neutral-700 outline-1 outline-blue-500 -outline-offset-1;
}
/* =========================
@ -263,16 +237,75 @@ body {
========================= */
.dither-shadow {
background: black;
@apply bg-black dark:bg-white;
background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px;
}
html.dark .dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
/* =========================
SELECTED ITEM PATTERN
========================= */
/* Pattern for selected items with dithered shadow and patterned background */
.item-selected {
@apply relative bg-white dark:bg-neutral-950;
}
.item-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;
}
html.dark .item-selected::after {
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
white 1px,
white 2px
);
}
.item-selected > * {
position: relative;
z-index: 1;
}
/* Text background for better readability on selected items */
.item-label-bg {
@apply bg-white/85 dark:bg-neutral-950/85 rounded;
}
.item-label-bg.selected {
@apply bg-white/95 dark:bg-neutral-950/95;
}
.item-text-bg {
@apply bg-white/90 dark:bg-neutral-950/90;
}
.item-text-bg.selected {
@apply bg-white/95 dark:bg-neutral-950/95;
}
/* =========================
BUTTON STYLING
========================= */
@ -330,123 +363,71 @@ html.dark .export-btn.primary:hover {
@apply bg-white text-black;
}
/* General buttons with bitmap styling */
button:not(.export-btn) {
background: white !important;
border: 1px solid black !important;
color: black !important;
font-family: "Ubuntu Mono", monospace !important;
text-transform: uppercase !important;
font-weight: bold !important;
letter-spacing: 0.5px !important;
cursor: pointer !important;
/* Bitmap button base styling - more targeted approach */
.bitmap-style {
@apply bg-white dark:bg-neutral-950 border border-black dark:border-white text-black dark:text-white font-mono uppercase font-bold tracking-wide cursor-pointer;
}
button:not(.export-btn):hover {
background: black !important;
color: white !important;
transform: translateY(-1px) translateX(-1px) !important;
.bitmap-style:hover {
@apply bg-black dark:bg-white text-white dark:text-black;
transform: translateY(-1px) translateX(-1px);
}
/* Dark mode buttons */
html.dark button:not(.export-btn) {
background: #0a0a0a !important;
border: 1px solid white !important;
color: white !important;
/* Bitmap button styling for template cards */
.bitmap-button {
@apply font-mono uppercase font-bold tracking-wider relative;
}
html.dark button:not(.export-btn):hover {
background: white !important;
color: black !important;
.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;
@apply border border-black dark:border-white bg-white dark:bg-neutral-950;
z-index: -1;
}
/* Constraint button selected styling */
.constraint-selected {
@apply bg-black dark:bg-white text-white dark:text-black;
}
.constraint-selected:hover {
@apply bg-black dark:bg-white text-white dark:text-black;
}
/* =========================
BITMAP AESTHETIC OVERRIDES
========================= */
/* Remove all rounded corners */
* {
border-radius: 0 !important;
font-family: "Ubuntu", monospace !important;
/* Bitmap form field styling - applied selectively */
.bitmap-input {
@apply border border-black dark:border-white bg-white dark:bg-neutral-950 text-black dark:text-white font-mono;
}
/* Form fields with bitmap styling */
input,
textarea,
select {
border: 1px solid black !important;
background: white !important;
color: black !important;
font-family: "Ubuntu Mono", monospace !important;
.bitmap-input:focus {
@apply outline-2 outline-black dark:outline-white -outline-offset-2 bg-white dark:bg-neutral-950;
}
input:focus,
textarea:focus,
select:focus {
outline: 2px solid black !important;
outline-offset: -2px !important;
background: white !important;
/* Checkbox and radio button styling for bitmap theme */
.bitmap-checkbox {
@apply border-2 border-black dark:border-white bg-white dark:bg-neutral-950;
}
/* Dark mode form fields */
html.dark input,
html.dark textarea,
html.dark select {
border: 1px solid white !important;
background: #0a0a0a !important;
color: white !important;
.bitmap-checkbox:checked {
@apply bg-black dark:bg-white text-white dark:text-black;
}
html.dark input:focus,
html.dark textarea:focus,
html.dark select:focus {
outline: 2px solid white !important;
background: #0a0a0a !important;
}
/* Checkbox and radio button styling */
input[type="checkbox"],
input[type="radio"] {
border: 2px solid black !important;
background: white !important;
}
input[type="checkbox"]:checked,
input[type="radio"]:checked {
background: black !important;
color: white !important;
}
html.dark input[type="checkbox"],
html.dark input[type="radio"] {
border: 2px solid white !important;
background: #0a0a0a !important;
}
html.dark input[type="checkbox"]:checked,
html.dark input[type="radio"]:checked {
background: white !important;
color: black !important;
}
/* Document titles */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Ubuntu", monospace !important;
color: inherit !important;
}
/* All text */
p,
span,
div {
color: inherit !important;
font-family: "Ubuntu", monospace !important;
}
/* =========================
HIDE ELEMENTS FROM PRINT
@ -523,6 +504,85 @@ div {
}
}
/* =========================
VALIDATION STYLES
========================= */
.validation-error {
@apply text-red-500 dark:text-red-400 text-sm font-medium mt-2 flex items-center gap-1;
}
/* =========================
TEMPLATE CARD STYLES
========================= */
.template-card {
@apply relative font-mono;
}
.help-section {
@apply relative;
}
.coming-soon {
@apply opacity-70;
}
.dither-tag {
@apply relative bg-white dark:bg-neutral-950;
}
.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;
}
html.dark .dither-tag::before {
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
white 1px,
white 2px
);
}
.disabled-button {
@apply opacity-60 cursor-not-allowed;
}
/* =========================
ANIMATIONS
========================= */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out;
}
/* =========================
MOBILE RESPONSIVENESS
========================= */

View file

@ -0,0 +1,67 @@
<template>
<select
v-model="selectedCategory"
@change="handleSelection(selectedCategory)"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option v-for="option in options" :key="option" :value="option">
{{ option }}
</option>
</select>
</template>
<script setup lang="ts">
interface Props {
modelValue: string;
type: 'revenue' | 'expenses';
mainCategory?: string;
placeholder?: string;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Select subcategory'
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const budgetStore = useBudgetStore();
const selectedCategory = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const options = computed(() => {
if (props.type === 'revenue' && props.mainCategory) {
// Show subcategories for the revenue main category
return budgetStore.revenueSubcategories[props.mainCategory] || [];
} else if (props.type === 'expenses' && props.mainCategory) {
// For expenses, we don't have predefined subcategories, so show common ones or empty
const expenseSubcategories = {
'Salaries & Benefits': ['Base wages and benefits', 'Health insurance', 'Retirement contributions', 'Payroll taxes'],
'Development Costs': ['Software tools', 'Development kits', 'Contractor fees', 'Testing costs'],
'Equipment & Technology': ['Hardware', 'Software licenses', 'Cloud services', 'IT support'],
'Marketing & Outreach': ['Advertising', 'Content creation', 'Event costs', 'PR services'],
'Office & Operations': ['Rent', 'Utilities', 'Insurance', 'Office supplies'],
'Legal & Professional': ['Legal fees', 'Accounting', 'Consulting', 'Professional services'],
'Other Expenses': ['Miscellaneous', 'Travel', 'Training', 'Other']
};
return expenseSubcategories[props.mainCategory] || ['Miscellaneous'];
} else {
// Fallback to main categories if no mainCategory provided
return props.type === 'revenue'
? budgetStore.revenueCategories
: budgetStore.expenseCategories;
}
});
function handleSelection(value: string) {
// If it's a new category (not in existing list), add it
if (value && !options.value.includes(value)) {
budgetStore.addCustomCategory(props.type, value);
}
emit('update:modelValue', value);
}
</script>

View file

@ -0,0 +1,56 @@
<template>
<div class="mb-12">
<div class="w-full mx-auto">
<nav
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
>
<NuxtLink
v-for="item in coopBuilderItems"
:key="item.id"
:to="item.path"
class="inline-flex items-center px-3 py-2 text-sm font-bold transition-colors whitespace-nowrap underline"
:class="
isActive(item.path)
? 'bg-black text-white dark:bg-white dark:text-black no-underline'
: ''
"
>
{{ item.name }}
</NuxtLink>
</nav>
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const coopBuilderItems = [
{
id: "coop-builder",
name: "Setup Wizard",
path: "/coop-builder",
},
{
id: "budget",
name: "Budget",
path: "/budget",
},
];
function isActive(path: string): boolean {
return route.path === path;
}
</script>
<style scoped>
/* Ensure horizontal scroll on mobile */
nav {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
nav::-webkit-scrollbar {
display: none; /* WebKit */
}
</style>

View file

@ -2,9 +2,8 @@
<div class="export-options" :class="containerClass">
<div class="export-content">
<div class="export-section">
<h3 class="export-title">Export Options:</h3>
<div class="export-buttons">
<button
<UButton
@click="copyToClipboard"
class="export-btn"
:disabled="isProcessing"
@ -13,9 +12,9 @@
<UIcon name="i-heroicons-clipboard" />
<span>Copy as Text</span>
<UIcon v-if="showCopySuccess" name="i-heroicons-check" class="success-icon" />
</button>
</UButton>
<button
<UButton
@click="downloadAsMarkdown"
class="export-btn"
:disabled="isProcessing"
@ -28,7 +27,7 @@
name="i-heroicons-check"
class="success-icon"
/>
</button>
</UButton>
</div>
</div>
</div>
@ -582,6 +581,7 @@ const downloadFile = (content: string, filename: string, type: string) => {
}
.export-buttons {
@apply font-mono;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;

View file

@ -1,132 +1,137 @@
<template>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header with Export Controls -->
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-2xl font-black text-black mb-2">
Where will your money come from?
</h3>
<p class="text-neutral-600">
Add sources like client work, grants, product sales, or donations.
</p>
</div>
<div class="flex items-center gap-3">
<UButton
variant="outline"
color="gray"
size="sm"
@click="exportStreams">
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
Export
</UButton>
<UButton
v-if="streams.length > 0"
@click="addRevenueStream"
size="sm"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-1" />
Add stream
</UButton>
</div>
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black mb-2">
Where will your money come from?
</h3>
<p class="text-neutral-600">
Add sources like client work, grants, product sales, or donations.
</p>
</div>
<div class="space-y-3">
<div
v-if="streams.length === 0"
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
<h4 class="font-medium text-neutral-900 mb-2">
No revenue streams yet
</h4>
<p class="text-sm text-neutral-500 mb-4">
Get started by adding your first revenue source.
</p>
<UButton
@click="addRevenueStream"
size="lg"
variant="solid"
color="primary">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add your first revenue stream
</UButton>
</div>
<!-- Removed Tab Navigation - showing streams directly -->
<div class="space-y-6">
<!-- Export Controls -->
<div class="flex justify-end">
<UButton
variant="outline"
color="gray"
size="sm"
@click="exportStreams">
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
Export
</UButton>
</div>
<div
v-for="stream in streams"
:key="stream.id"
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<UFormField label="Category" required>
<USelect
v-model="stream.category"
:items="categoryOptions"
size="xl"
class="text-xl font-bold w-full"
@update:model-value="saveStream(stream)" />
</UFormField>
<div class="space-y-3">
<div
v-if="streams.length === 0"
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
<h4 class="font-medium text-neutral-900 mb-2">
No revenue streams yet
</h4>
<p class="text-sm text-neutral-500 mb-4">
Get started by adding your first revenue source.
</p>
<UButton
@click="addRevenueStream"
size="lg"
variant="solid"
color="primary">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add your first revenue stream
</UButton>
</div>
<UFormField label="Revenue source name" required>
<USelectMenu
v-model="stream.name"
:items="nameOptionsByCategory[stream.category] || []"
placeholder="Select or type a source name"
creatable
searchable
size="xl"
class="text-xl font-bold w-full"
@update:model-value="saveStream(stream)" />
</UFormField>
<div
v-for="stream in streams"
:key="stream.id"
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<UFormField label="Category" required>
<USelect
v-model="stream.category"
:items="categoryOptions"
size="xl"
class="text-xl font-bold w-full"
@update:model-value="saveStream(stream)" />
</UFormField>
<UFormField label="Monthly amount" required>
<UInput
v-model="stream.targetMonthlyAmount"
type="text"
placeholder="5000"
size="xl"
class="text-xl font-black w-full"
@update:model-value="validateAndSaveAmount($event, stream)"
@blur="saveStream(stream)">
<template #leading>
<span class="text-neutral-500 text-xl">$</span>
</template>
</UInput>
</UFormField>
<UFormField label="Revenue source name" required>
<USelectMenu
v-model="stream.name"
:items="nameOptionsByCategory[stream.category] || []"
placeholder="Select or type a source name"
creatable
searchable
size="xl"
class="text-xl font-bold w-full"
@update:model-value="saveStream(stream)" />
</UFormField>
<UFormField label="Monthly amount" required>
<UInput
v-model="stream.targetMonthlyAmount"
type="text"
placeholder="5000"
size="xl"
class="text-xl font-black w-full"
@update:model-value="validateAndSaveAmount($event, stream)"
@blur="saveStream(stream)">
<template #leading>
<span class="text-neutral-500 text-xl">$</span>
</template>
</UInput>
</UFormField>
</div>
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
<UButton
size="xs"
variant="solid"
color="error"
@click="removeStream(stream.id)"
:ui="{
base: 'cursor-pointer hover:opacity-90 transition-opacity',
}">
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
</UButton>
</div>
</div>
<!-- Add Stream Button (when items exist) -->
<div v-if="streams.length > 0" class="flex justify-center">
<UButton
@click="addRevenueStream"
size="lg"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add another stream
</UButton>
</div>
<div v-if="streams.length > 0" class="flex items-center gap-3 justify-end">
<UButton
@click="addRevenueStream"
size="sm"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-1" />
Add stream
</UButton>
</div>
</div>
</div>
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
<UButton
size="xs"
variant="solid"
color="error"
@click="removeStream(stream.id)"
:ui="{
base: 'cursor-pointer hover:opacity-90 transition-opacity',
}">
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
</UButton>
</div>
</div>
<!-- Add Stream Button (when items exist) -->
<div v-if="streams.length > 0" class="flex justify-center">
<UButton
@click="addRevenueStream"
size="lg"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add another stream
</UButton>
</div>
</div>
</div>
</template>

View file

@ -1,20 +1,12 @@
<template>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header with Export Controls -->
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-2xl font-black text-black mb-2">Review & Complete</h3>
<p class="text-neutral-600">
Review your setup and complete the wizard to start using your co-op
tool.
</p>
</div>
<div class="flex items-center gap-3">
<UButton variant="outline" color="gray" size="sm" @click="exportSetup">
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
Export All
</UButton>
</div>
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black dark:text-white mb-2">Review & Complete</h3>
<p class="text-neutral-600 dark:text-neutral-400">
Review your setup and complete the wizard to start using your co-op
tool.
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@ -371,28 +363,4 @@ function completeSetup() {
}
}
function exportSetup() {
// Create export data
const setupData = {
members: members.value,
policies: policies.value,
overheadCosts: overheadCosts.value,
streams: streams.value,
exportedAt: new Date().toISOString(),
version: "1.0",
};
// Download as JSON
const blob = new Blob([JSON.stringify(setupData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `coop-setup-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>

View file

@ -1,52 +1,22 @@
<template>
<div
class="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900">
<div class="max-w-4xl mx-auto px-4 py-3">
<nav class="flex items-center space-x-1 overflow-x-auto">
<!-- Main Setup Wizard -->
<NuxtLink
to="/wizard"
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap"
:class="
isActive('/wizard')
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
">
<UIcon name="i-heroicons-cog-6-tooth" class="w-4 h-4 mr-2" />
Setup Wizard
</NuxtLink>
<!-- Divider -->
<div class="h-6 w-px bg-neutral-300 dark:bg-neutral-600 mx-2"></div>
<!-- Template Wizards -->
<div class="mb-12">
<div class="w-full mx-auto">
<nav
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
>
<NuxtLink
v-for="wizard in templateWizards"
:key="wizard.id"
:to="wizard.path"
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap"
class="inline-flex items-center px-3 py-2 text-sm font-bold transition-colors whitespace-nowrap underline"
:class="
isActive(wizard.path)
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
">
<UIcon :name="wizard.icon" class="w-4 h-4 mr-2" />
? 'bg-black text-white dark:bg-white dark:text-black no-underline'
: ''
"
>
{{ wizard.name }}
</NuxtLink>
<!-- All Wizards Link -->
<div class="h-6 w-px bg-neutral-300 dark:bg-neutral-600 mx-2"></div>
<NuxtLink
to="/wizards"
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap"
:class="
isActive('/wizards')
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
">
<UIcon name="i-heroicons-squares-plus" class="w-4 h-4 mr-2" />
All Wizards
</NuxtLink>
</nav>
</div>
</div>
@ -55,30 +25,25 @@
<script setup lang="ts">
const route = useRoute();
// Template wizards data - matches the wizards.vue page
const templateWizards = [
{
id: "membership-agreement",
name: "Membership Agreement",
icon: "i-heroicons-user-group",
path: "/templates/membership-agreement",
},
{
id: "conflict-resolution-framework",
name: "Conflict Resolution",
icon: "i-heroicons-scale",
path: "/templates/conflict-resolution-framework",
},
{
id: "tech-charter",
name: "Tech Charter",
icon: "i-heroicons-cog-6-tooth",
path: "/templates/tech-charter",
},
{
id: "decision-framework",
name: "Decision Helper",
icon: "i-heroicons-light-bulb",
name: "Decision Framework",
path: "/templates/decision-framework",
},
];

View file

@ -58,7 +58,7 @@ export const useFixtures = () => {
category: 'Services',
subcategory: 'Development',
targetPct: 65,
targetMonthlyAmount: 0,
targetMonthlyAmount: 13000,
certainty: 'Committed',
payoutDelayDays: 30,
terms: 'Net 30',
@ -73,7 +73,7 @@ export const useFixtures = () => {
category: 'Product',
subcategory: 'Digital Tools',
targetPct: 20,
targetMonthlyAmount: 0,
targetMonthlyAmount: 4000,
certainty: 'Probable',
payoutDelayDays: 14,
terms: 'Platform payout',
@ -88,7 +88,7 @@ export const useFixtures = () => {
category: 'Grant',
subcategory: 'Government',
targetPct: 10,
targetMonthlyAmount: 0,
targetMonthlyAmount: 2000,
certainty: 'Committed',
payoutDelayDays: 45,
terms: 'Quarterly disbursement',
@ -103,7 +103,7 @@ export const useFixtures = () => {
category: 'Donation',
subcategory: 'Individual',
targetPct: 3,
targetMonthlyAmount: 0,
targetMonthlyAmount: 600,
certainty: 'Aspirational',
payoutDelayDays: 3,
terms: 'Immediate',
@ -118,7 +118,7 @@ export const useFixtures = () => {
category: 'Other',
subcategory: 'Professional Services',
targetPct: 2,
targetMonthlyAmount: 0,
targetMonthlyAmount: 400,
certainty: 'Probable',
payoutDelayDays: 21,
terms: 'Net 21',

View file

@ -0,0 +1,256 @@
import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
import { offerTemplates, matchTemplateToInput, getTemplateHours } from '~/data/offerTemplates';
interface SuggestOffersInput {
members: Member[];
selectedSkillsByMember: Record<string, string[]>;
selectedProblems: string[];
}
interface Catalogs {
skills: SkillTag[];
problems: ProblemTag[];
}
export interface OfferTemplate {
id: string;
name: string;
type: 'workshop' | 'clinic' | 'sprint' | 'retainer';
skillRequirements: string[];
problemTargets: string[];
scope: string[];
defaultDays: number;
defaultHours?: Record<string, number>;
whyThisTemplate: string[];
riskTemplate: string;
}
export function useOfferSuggestor() {
// Payout delay defaults by offer type
const payoutDelays = {
workshop: 14,
clinic: 30,
sprint: 45,
retainer: 30
};
function suggestOffers(input: SuggestOffersInput, catalogs: Catalogs): Offer[] {
const { members, selectedSkillsByMember, selectedProblems } = input;
// Get all selected skills across all members
const allSelectedSkills = new Set<string>();
Object.values(selectedSkillsByMember).forEach(skills => {
skills.forEach(skill => allSelectedSkills.add(skill));
});
const selectedSkillsArray = Array.from(allSelectedSkills);
const offers: Offer[] = [];
// Try to match templates
const matchingTemplates = findMatchingTemplates(selectedSkillsArray, selectedProblems);
// Generate offers from matching templates (max 2)
for (const template of matchingTemplates.slice(0, 2)) {
const offer = generateOfferFromTemplate(template, input);
if (offer) {
offers.push(offer);
}
}
// If we have fewer than 2 offers, add default "1-Week Dev Sprint"
if (offers.length < 2) {
const devSprintOffer = generateDevSprintOffer(input);
if (devSprintOffer) {
offers.push(devSprintOffer);
}
}
// If no template matches at all, ensure we always have at least the dev sprint
if (offers.length === 0) {
const devSprintOffer = generateDevSprintOffer(input);
if (devSprintOffer) {
offers.push(devSprintOffer);
}
}
// Limit to 3 offers max
return offers.slice(0, 3);
}
function findMatchingTemplates(selectedSkills: string[], selectedProblems: string[]): OfferTemplate[] {
// Use the lightweight matching rules from the data file
return matchTemplateToInput(selectedSkills, selectedProblems);
}
function generateOfferFromTemplate(template: OfferTemplate, input: SuggestOffersInput): Offer | null {
const { members, selectedSkillsByMember } = input;
// Create skill-to-member mapping
const skillToMemberMap = new Map<string, Member[]>();
members.forEach(member => {
const memberSkills = selectedSkillsByMember[member.id] || [];
memberSkills.forEach(skill => {
if (!skillToMemberMap.has(skill)) {
skillToMemberMap.set(skill, []);
}
skillToMemberMap.get(skill)!.push(member);
});
});
// Generate hours allocation based on template's defaultHours
const hoursByMember: Array<{ memberId: string; hours: number }> = [];
if (template.defaultHours) {
// Use specific hour allocations from template
Object.entries(template.defaultHours).forEach(([skill, hours]) => {
const availableMembers = skillToMemberMap.get(skill) || [];
if (availableMembers.length > 0) {
// Assign to member with highest availability for this skill
const bestMember = availableMembers.sort((a, b) => b.availableHrs - a.availableHrs)[0];
// Check if this member already has hours assigned
const existingAllocation = hoursByMember.find(h => h.memberId === bestMember.id);
if (existingAllocation) {
existingAllocation.hours += hours;
} else {
hoursByMember.push({ memberId: bestMember.id, hours });
}
}
});
} else {
// Fallback to old method if no defaultHours specified
const relevantMembers = members.filter(member => {
const memberSkills = selectedSkillsByMember[member.id] || [];
return template.skillRequirements.some(skill => memberSkills.includes(skill));
});
if (relevantMembers.length === 0) return null;
const totalHours = template.defaultDays * 8; // 8 hours per day
hoursByMember.push(...distributeHours(relevantMembers, totalHours));
}
if (hoursByMember.length === 0) return null;
// Calculate pricing
const pricing = calculatePricing(hoursByMember, members);
return {
id: `${template.id}-${Date.now()}`,
name: `${template.name} (${template.defaultDays} ${template.defaultDays === 1 ? 'day' : 'days'})`,
scope: template.scope,
hoursByMember,
price: {
baseline: pricing.baseline,
stretch: pricing.stretch,
calcNote: pricing.calcNote
},
payoutDelayDays: payoutDelays[template.type],
whyThis: template.whyThisTemplate,
riskNotes: [template.riskTemplate]
};
}
function generateDevSprintOffer(input: SuggestOffersInput): Offer | null {
const { members, selectedSkillsByMember } = input;
// Find members with highest availability (if no skills selected, use all members)
const membersWithSkills = members
.filter(member => (selectedSkillsByMember[member.id] || []).length > 0);
const availableMembers = membersWithSkills.length > 0
? membersWithSkills.sort((a, b) => b.availableHrs - a.availableHrs)
: members.sort((a, b) => b.availableHrs - a.availableHrs);
if (availableMembers.length === 0) return null;
// Use top 2-3 highest availability members
const selectedMembers = availableMembers.slice(0, Math.min(3, availableMembers.length));
// 1 week = 40 hours, distributed among selected members
const hoursByMember = distributeHours(selectedMembers, 40);
const pricing = calculatePricing(hoursByMember, members);
// Get selected skill names for why this
const allSelectedSkills = new Set<string>();
Object.values(selectedSkillsByMember).forEach(skills => {
skills.forEach(skill => allSelectedSkills.add(skill));
});
return {
id: `dev-sprint-${Date.now()}`,
name: 'Development Sprint (1 week)',
scope: [
'Implement specific features or fixes',
'Code review and quality assurance',
'Documentation and handoff'
],
hoursByMember,
price: {
baseline: pricing.baseline,
stretch: pricing.stretch,
calcNote: pricing.calcNote
},
payoutDelayDays: payoutDelays.sprint,
whyThis: [
`Leverages your ${Array.from(allSelectedSkills).slice(0, 2).join(' and ')} skills`,
'Time-boxed sprint reduces risk',
'Fits within your available capacity'
],
riskNotes: ['Scope creep is common in open-ended development work']
};
}
function distributeHours(members: Member[], totalHours: number): Array<{ memberId: string; hours: number }> {
if (members.length === 0) return [];
// Simple distribution based on availability
const totalAvailability = members.reduce((sum, m) => sum + m.availableHrs, 0);
return members.map(member => {
const proportion = member.availableHrs / totalAvailability;
const allocatedHours = Math.round(totalHours * proportion);
return {
memberId: member.id,
hours: Math.min(allocatedHours, member.availableHrs)
};
});
}
function calculatePricing(
hoursByMember: Array<{ memberId: string; hours: number }>,
members: Member[]
): { baseline: number; stretch: number; calcNote: string } {
// Create member lookup
const memberMap = new Map(members.map(m => [m.id, m]));
// Calculate cost-plus pricing
let totalCost = 0;
let totalHours = 0;
for (const allocation of hoursByMember) {
const member = memberMap.get(allocation.memberId);
if (member) {
// memberHours * hourly * 1.25 (markup)
totalCost += allocation.hours * member.hourly * 1.25;
totalHours += allocation.hours;
}
}
// Apply 1.10 multiplier to total
const baseline = Math.round(totalCost * 1.10);
const stretch = Math.round(baseline * 1.2);
const avgRate = totalHours > 0 ? Math.round(totalCost / totalHours) : 0;
const calcNote = `${totalHours} hours at ~$${avgRate}/hr blended rate with markup`;
return { baseline, stretch, calcNote };
}
return {
suggestOffers
};
}

178
data/offerTemplates.ts Normal file
View file

@ -0,0 +1,178 @@
import type { OfferTemplate } from '~/composables/useOfferSuggestor';
export const offerTemplates: OfferTemplate[] = [
{
id: 'pitch-polish',
name: 'Pitch Polish',
type: 'clinic',
skillRequirements: ['writing', 'design'],
problemTargets: ['unclear-pitch', 'grant-budget-help'],
scope: [
'Comprehensive deck review and analysis',
'Rewrite key sections for clarity and impact',
'90-minute live presentation coaching session',
'Final edit pass with visual polish'
],
defaultDays: 2,
defaultHours: {
writing: 6,
design: 6,
pm: 2
},
whyThisTemplate: [
'Combines writing expertise with design polish',
'Time-boxed format keeps scope manageable',
'Live coaching session builds confidence',
'Immediate impact on funding success'
],
riskTemplate: 'Client may resist feedback on core concept or messaging'
},
{
id: 'brand-store-page-sprint',
name: 'Brand/Store Page Sprint',
type: 'sprint',
skillRequirements: ['design', 'dev'],
problemTargets: ['need-landing-store-page', 'marketing-assets'],
scope: [
'Develop clear messaging and brand voice',
'Design and build one-page marketing site',
'Create store page assets and layout',
'Implement responsive design and basic SEO'
],
defaultDays: 7,
defaultHours: {
design: 12,
writing: 6,
dev: 10,
pm: 4
},
whyThisTemplate: [
'Full-stack approach from concept to deployment',
'Combines brand strategy with technical execution',
'Creates immediate market presence',
'Scalable foundation for future marketing'
],
riskTemplate: 'Scope creep around additional pages or complex integrations'
},
{
id: 'dev-sprint',
name: 'Dev Sprint',
type: 'sprint',
skillRequirements: ['dev'],
problemTargets: ['vertical-slice', 'tech-debt'],
scope: [
'Backlog triage and feature prioritization',
'Implement 1-2 focused features or fixes',
'Create demo build for stakeholder review',
'Document changes and deployment process'
],
defaultDays: 7,
defaultHours: {
dev: 24,
qa: 4,
pm: 4
},
whyThisTemplate: [
'Focused development with clear deliverables',
'Includes quality assurance and project management',
'Demo build provides immediate feedback opportunity',
'Manageable scope reduces technical risk'
],
riskTemplate: 'Technical complexity may exceed initial estimates'
},
{
id: 'maintenance-retainer',
name: 'Maintenance Retainer',
type: 'retainer',
skillRequirements: ['dev', 'pm'],
problemTargets: ['launch-checklist'],
scope: [
'Handle small fixes and bug reports',
'Apply security and dependency updates',
'Provide technical support and guidance',
'Monthly progress reports and recommendations'
],
defaultDays: 30, // Monthly
defaultHours: {
dev: 6,
pm: 2
},
whyThisTemplate: [
'Predictable monthly income stream',
'Builds long-term client relationships',
'Low-risk work with defined boundaries',
'Efficient use of development skills'
],
riskTemplate: 'Client expectations may exceed allocated hours'
}
];
/**
* Lightweight matching rules for offer templates
*/
export function matchTemplateToInput(
selectedSkills: string[],
selectedProblems: string[]
): OfferTemplate[] {
const matches: OfferTemplate[] = [];
// Pitch Polish: Writing + Design + pitch/funding problems
if (selectedSkills.includes('writing') && selectedSkills.includes('design')) {
if (selectedProblems.includes('unclear-pitch') || selectedProblems.includes('grant-budget-help')) {
matches.push(offerTemplates[0]); // Pitch Polish
}
}
// Brand/Store Page: Design + Dev + website/marketing problems
if (selectedSkills.includes('design') && selectedSkills.includes('dev')) {
if (selectedProblems.includes('need-landing-store-page') || selectedProblems.includes('marketing-assets')) {
matches.push(offerTemplates[1]); // Brand/Store Page Sprint
}
}
// Dev Sprint: Dev + development-related problems
if (selectedSkills.includes('dev')) {
if (selectedProblems.includes('vertical-slice') || selectedProblems.includes('tech-debt')) {
matches.push(offerTemplates[2]); // Dev Sprint
}
}
// Maintenance Retainer: Dev + PM + launch/maintenance problems
if (selectedSkills.includes('dev') && selectedSkills.includes('pm')) {
if (selectedProblems.includes('launch-checklist') || matches.length === 0) { // Also use as fallback
matches.push(offerTemplates[3]); // Maintenance Retainer
}
}
return matches;
}
/**
* Get default hour allocation for a template based on available members
*/
export function getTemplateHours(
template: OfferTemplate,
availableSkills: string[]
): Array<{ skill: string; hours: number }> {
const allocations: Array<{ skill: string; hours: number }> = [];
// Convert template hours to skill-based allocations
if (template.defaultHours) {
Object.entries(template.defaultHours).forEach(([skill, hours]) => {
if (availableSkills.includes(skill)) {
allocations.push({ skill, hours });
}
});
}
return allocations;
}
/**
* Calculate total hours for a template
*/
export function getTemplateTotalHours(template: OfferTemplate): number {
if (!template.defaultHours) return template.defaultDays * 8; // Fallback: 8 hours per day
return Object.values(template.defaultHours).reduce((sum, hours) => sum + hours, 0);
}

157
data/skillsProblems.ts Normal file
View file

@ -0,0 +1,157 @@
import type { SkillTag, ProblemTag } from "~/types/coaching";
/**
* Standardized skills catalog for co-op offer generation
*/
export const skillsCatalog: SkillTag[] = [
{ id: "design", label: "Design" },
{ id: "writing", label: "Writing" },
{ id: "dev", label: "Dev" },
{ id: "pm", label: "PM" },
{ id: "qa", label: "QA" },
{ id: "teaching", label: "Teaching" },
{ id: "community", label: "Community" },
{ id: "marketing", label: "Marketing" },
{ id: "audio", label: "Audio" },
{ id: "art", label: "Art" },
{ id: "facilitation", label: "Facilitation" },
{ id: "ops", label: "Ops" }
];
/**
* Core problems that co-ops commonly solve, with realistic client examples
*/
export const problemsCatalog: ProblemTag[] = [
{
id: "unclear-pitch",
label: "Unclear pitch",
examples: [
"Our deck isn't landing with investors",
"Publishers don't get our concept",
"Feedback says the vision is confusing"
]
},
{
id: "need-landing-store-page",
label: "Need landing/store page",
examples: [
"We have no website presence",
"Need Steam page copy and layout",
"Want a simple marketing landing page"
]
},
{
id: "vertical-slice",
label: "Vertical slice",
examples: [
"Need a demo for potential funders",
"Want to test core mechanics with users",
"Prototype for investor meetings"
]
},
{
id: "community-plan",
label: "Community plan",
examples: [
"Our Discord server is inactive",
"We have no social media posting plan",
"Need a community building strategy"
]
},
{
id: "marketing-assets",
label: "Marketing assets",
examples: [
"Need trailer stills and screenshots",
"Press kit is completely missing",
"Want social media content templates"
]
},
{
id: "grant-budget-help",
label: "Grant budget help",
examples: [
"Need a budget narrative for arts council",
"Don't know how to price development time",
"Grant application requires detailed financials"
]
},
{
id: "launch-checklist",
label: "Launch checklist",
examples: [
"Need final QA pass before release",
"Store assets aren't ready",
"Want a pre-launch timeline and tasks"
]
},
{
id: "tech-debt",
label: "Tech debt",
examples: [
"Build process is broken and slow",
"Tooling needs major updates",
"Performance issues need addressing"
]
}
];
/**
* Skills grouped by common combinations for template matching
*/
export const skillCombinations = {
creative: ['design', 'art', 'writing'],
technical: ['dev', 'qa', 'ops'],
business: ['pm', 'marketing', 'facilitation'],
community: ['community', 'teaching', 'marketing'],
production: ['pm', 'qa', 'ops']
};
/**
* Problems grouped by solution type
*/
export const problemCategories = {
communication: ['unclear-pitch', 'grant-budget-help'],
marketing: ['need-landing-store-page', 'marketing-assets', 'community-plan'],
development: ['vertical-slice', 'tech-debt'],
operations: ['launch-checklist']
};
/**
* Helper function to get skills by category
*/
export function getSkillsByCategory(category: keyof typeof skillCombinations): SkillTag[] {
const skillIds = skillCombinations[category];
return skillsCatalog.filter(skill => skillIds.includes(skill.id));
}
/**
* Helper function to get problems by category
*/
export function getProblemsByCategory(category: keyof typeof problemCategories): ProblemTag[] {
const problemIds = problemCategories[category];
return problemsCatalog.filter(problem => problemIds.includes(problem.id));
}
/**
* Check if a skill combination is commonly used together
*/
export function areSkillsComplementary(skills: string[]): boolean {
// Check if skills fall within the same or complementary categories
const categories = Object.entries(skillCombinations);
for (const [category, categorySkills] of categories) {
const overlap = skills.filter(skill => categorySkills.includes(skill));
if (overlap.length >= 2) {
return true; // Found 2+ skills in same category
}
}
// Check cross-category combinations that work well together
const hasCreative = skills.some(s => skillCombinations.creative.includes(s));
const hasTechnical = skills.some(s => skillCombinations.technical.includes(s));
const hasBusiness = skills.some(s => skillCombinations.business.includes(s));
// Creative + Technical, Technical + Business, etc. are good combinations
return (hasCreative && hasTechnical) || (hasTechnical && hasBusiness) || (hasCreative && hasBusiness);
}

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" },
]);
// Generate monthly headers for the next 12 months
const monthlyHeaders = computed(() => {
const headers = [];
const today = new Date();
// 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;
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();
const grossWages = totalHours * hourlyWage;
const oncosts = grossWages * (oncostPct / 100);
const totalPayroll = grossWages + oncosts;
headers.push({
key: `${year}-${String(date.getMonth() + 1).padStart(2, '0')}`,
label: `${monthName} ${year}`
});
}
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,
};
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>

View file

@ -0,0 +1,40 @@
import type { Member, SkillTag, ProblemTag } from "~/types/coaching";
import { skillsCatalog, problemsCatalog } from "~/data/skillsProblems";
export const membersSample: Member[] = [
{
id: "sample-1",
name: "Maya Chen",
role: "Design Lead",
hourly: 32,
availableHrs: 40
},
{
id: "sample-2",
name: "Alex Rivera",
role: "Developer",
hourly: 45,
availableHrs: 30
},
{
id: "sample-3",
name: "Jordan Blake",
role: "Content Writer",
hourly: 28,
availableHrs: 20
}
];
export const skillsCatalogSample: SkillTag[] = skillsCatalog;
export const problemsCatalogSample: ProblemTag[] = problemsCatalog;
// Pre-selected sample data for quick demos
export const sampleSelections = {
selectedSkillsByMember: {
"sample-1": ["design", "facilitation"], // Maya: Design + Facilitation
"sample-2": ["dev", "pm"], // Alex: Dev + PM
"sample-3": ["writing", "marketing"] // Jordan: Writing + Marketing
},
selectedProblems: ["unclear-pitch", "need-landing-store-page"]
};

View file

@ -4,24 +4,143 @@ export const useBudgetStore = defineStore(
"budget",
() => {
// Schema version for persistence
const schemaVersion = "1.0";
const schemaVersion = "2.0";
// Monthly budget lines by period (YYYY-MM)
// Canonical categories from WizardRevenueStep - matches the wizard exactly
const revenueCategories = ref([
'Games & Products',
'Services & Contracts',
'Grants & Funding',
'Community Support',
'Partnerships',
'Investment Income',
'In-Kind Contributions'
]);
// Revenue subcategories by main category (for reference and grouping)
const revenueSubcategories = ref({
'Games & Products': ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise'],
'Services & Contracts': ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services'],
'Grants & Funding': ['Government funding', 'Arts council grants', 'Foundation support', 'Research grants'],
'Community Support': ['Patreon/subscriptions', 'Crowdfunding', 'Donations', 'Mutual aid received'],
'Partnerships': ['Corporate partnerships', 'Academic partnerships', 'Sponsorships'],
'Investment Income': ['Impact investment', 'Venture capital', 'Loans'],
'In-Kind Contributions': ['Office space', 'Equipment/hardware', 'Software licenses', 'Professional services', 'Marketing/PR services', 'Legal services']
});
const expenseCategories = ref([
'Salaries & Benefits',
'Development Costs',
'Equipment & Technology',
'Marketing & Outreach',
'Office & Operations',
'Legal & Professional',
'Other Expenses'
]);
// NEW: Budget worksheet structure (starts empty, populated from wizard data)
const budgetWorksheet = ref({
revenue: [],
expenses: []
});
// Track if worksheet has been initialized from wizard data
const isInitialized = ref(false);
// LEGACY: Keep for backward compatibility
const budgetLines = ref({});
// Overhead costs (recurring monthly)
const overheadCosts = ref([]);
// Production costs (variable monthly)
const productionCosts = ref([]);
// Current selected period - use current month/year
// Computed grouped data for category headers
const groupedRevenue = computed(() => {
const groups = {};
revenueCategories.value.forEach(category => {
groups[category] = budgetWorksheet.value.revenue.filter(item => item.mainCategory === category);
});
return groups;
});
const groupedExpenses = computed(() => {
const groups = {};
expenseCategories.value.forEach(category => {
groups[category] = budgetWorksheet.value.expenses.filter(item => item.mainCategory === category);
});
return groups;
});
// Computed totals for budget worksheet (legacy - keep for backward compatibility)
const budgetTotals = computed(() => {
const years = ['year1', 'year2', 'year3'];
const scenarios = ['best', 'worst', 'mostLikely'];
const totals = {};
years.forEach(year => {
totals[year] = {};
scenarios.forEach(scenario => {
// Calculate revenue total
const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => {
return sum + (item.values?.[year]?.[scenario] || 0);
}, 0);
// Calculate expenses total
const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => {
return sum + (item.values?.[year]?.[scenario] || 0);
}, 0);
// Calculate net income
const netIncome = revenueTotal - expensesTotal;
totals[year][scenario] = {
revenue: revenueTotal,
expenses: expensesTotal,
net: netIncome
};
});
});
return totals;
});
// Monthly totals computation
const monthlyTotals = computed(() => {
const totals = {};
// Generate month keys for next 12 months
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
// Calculate revenue total for this month
const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
}, 0);
// Calculate expenses total for this month
const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
}, 0);
// Calculate net income
const netIncome = revenueTotal - expensesTotal;
totals[monthKey] = {
revenue: revenueTotal,
expenses: expensesTotal,
net: netIncome
};
}
return totals;
});
// LEGACY: Keep for backward compatibility
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
const currentPeriod = ref(`${currentYear}-${currentMonth}`);
// Computed current budget
const currentBudget = computed(() => {
return (
budgetLines.value[currentPeriod.value] || {
@ -100,13 +219,418 @@ export const useBudgetStore = defineStore(
currentPeriod.value = period;
}
// Initialize worksheet from wizard data
async function initializeFromWizardData() {
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
console.log('Already initialized with data, skipping...');
return;
}
console.log('Initializing budget from wizard data...');
// Import stores dynamically to avoid circular deps
const { useStreamsStore } = await import('./streams');
const { useMembersStore } = await import('./members');
const { usePoliciesStore } = await import('./policies');
const streamsStore = useStreamsStore();
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
console.log('Streams:', streamsStore.streams.length, 'streams');
console.log('Members capacity:', membersStore.capacityTotals);
console.log('Policies wage:', policiesStore.equalHourlyWage);
// Clear existing data
budgetWorksheet.value.revenue = [];
budgetWorksheet.value.expenses = [];
// Add revenue streams from wizard
if (streamsStore.streams.length === 0) {
console.log('No wizard streams found, adding sample data');
// Initialize with minimal demo if no wizard data exists
await streamsStore.initializeWithFixtures();
}
streamsStore.streams.forEach(stream => {
const monthlyAmount = stream.targetMonthlyAmount || 0;
console.log('Adding stream:', stream.name, 'category:', stream.category, 'subcategory:', stream.subcategory, 'amount:', monthlyAmount);
console.log('Full stream object:', stream);
// Simple category mapping - just map the key categories we know exist
let mappedCategory = 'Games & Products'; // Default
const categoryLower = (stream.category || '').toLowerCase();
if (categoryLower === 'games' || categoryLower === 'product') mappedCategory = 'Games & Products';
else if (categoryLower === 'services' || categoryLower === 'service') mappedCategory = 'Services & Contracts';
else if (categoryLower === 'grants' || categoryLower === 'grant') mappedCategory = 'Grants & Funding';
else if (categoryLower === 'community') mappedCategory = 'Community Support';
else if (categoryLower === 'partnerships' || categoryLower === 'partnership') mappedCategory = 'Partnerships';
else if (categoryLower === 'investment') mappedCategory = 'Investment Income';
console.log('Mapped category from', stream.category, 'to', mappedCategory);
// Create monthly values - split the annual target evenly across 12 months
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = monthlyAmount;
}
console.log('Created monthly values for', stream.name, ':', monthlyValues);
budgetWorksheet.value.revenue.push({
id: `revenue-${stream.id}`,
name: stream.name,
mainCategory: mappedCategory,
subcategory: stream.subcategory || 'Direct sales', // Use actual subcategory from stream
source: 'wizard',
monthlyValues,
values: {
year1: { best: monthlyAmount * 12, worst: monthlyAmount * 6, mostLikely: monthlyAmount * 10 },
year2: { best: monthlyAmount * 15, worst: monthlyAmount * 8, mostLikely: monthlyAmount * 12 },
year3: { best: monthlyAmount * 18, worst: monthlyAmount * 10, mostLikely: monthlyAmount * 15 }
}
});
});
// Add payroll from wizard data
const totalHours = membersStore.capacityTotals.targetHours || 0;
const hourlyWage = policiesStore.equalHourlyWage || 0;
const oncostPct = policiesStore.payrollOncostPct || 0;
if (totalHours > 0 && hourlyWage > 0) {
const monthlyPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
// Create monthly values for payroll
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = monthlyPayroll;
}
budgetWorksheet.value.expenses.push({
id: 'expense-payroll',
name: 'Payroll',
mainCategory: 'Salaries & Benefits',
subcategory: 'Base wages and benefits',
source: 'wizard',
monthlyValues,
values: {
year1: { best: monthlyPayroll * 12, worst: monthlyPayroll * 8, mostLikely: monthlyPayroll * 12 },
year2: { best: monthlyPayroll * 14, worst: monthlyPayroll * 10, mostLikely: monthlyPayroll * 13 },
year3: { best: monthlyPayroll * 16, worst: monthlyPayroll * 12, mostLikely: monthlyPayroll * 15 }
}
});
}
// Add overhead costs from wizard
overheadCosts.value.forEach(cost => {
if (cost.amount > 0) {
const annualAmount = cost.amount * 12;
// Map overhead cost categories to expense categories
let expenseCategory = 'Other Expenses'; // Default
if (cost.category === 'Operations') expenseCategory = 'Office & Operations';
else if (cost.category === 'Technology') expenseCategory = 'Equipment & Technology';
else if (cost.category === 'Legal') expenseCategory = 'Legal & Professional';
else if (cost.category === 'Marketing') expenseCategory = 'Marketing & Outreach';
// Create monthly values for overhead costs
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = cost.amount;
}
budgetWorksheet.value.expenses.push({
id: `expense-${cost.id}`,
name: cost.name,
mainCategory: expenseCategory,
subcategory: cost.name, // Use the cost name as subcategory
source: 'wizard',
monthlyValues,
values: {
year1: { best: annualAmount, worst: annualAmount * 0.8, mostLikely: annualAmount },
year2: { best: annualAmount * 1.1, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.05 },
year3: { best: annualAmount * 1.2, worst: annualAmount, mostLikely: annualAmount * 1.1 }
}
});
}
});
// Add production costs from wizard
productionCosts.value.forEach(cost => {
if (cost.amount > 0) {
const annualAmount = cost.amount * 12;
// Create monthly values for production costs
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = cost.amount;
}
budgetWorksheet.value.expenses.push({
id: `expense-${cost.id}`,
name: cost.name,
mainCategory: 'Development Costs',
subcategory: cost.name, // Use the cost name as subcategory
source: 'wizard',
monthlyValues,
values: {
year1: { best: annualAmount, worst: annualAmount * 0.7, mostLikely: annualAmount * 0.9 },
year2: { best: annualAmount * 1.2, worst: annualAmount * 0.8, mostLikely: annualAmount },
year3: { best: annualAmount * 1.3, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.1 }
}
});
}
});
// If still no data after initialization, add a sample row
if (budgetWorksheet.value.revenue.length === 0) {
console.log('Adding sample revenue line');
// Create monthly values for sample revenue
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = 667; // ~8000/12
}
budgetWorksheet.value.revenue.push({
id: 'revenue-sample',
name: 'Sample Revenue',
mainCategory: 'Games & Products',
subcategory: 'Direct sales',
source: 'user',
monthlyValues,
values: {
year1: { best: 10000, worst: 5000, mostLikely: 8000 },
year2: { best: 12000, worst: 6000, mostLikely: 10000 },
year3: { best: 15000, worst: 8000, mostLikely: 12000 }
}
});
}
if (budgetWorksheet.value.expenses.length === 0) {
console.log('Adding sample expense line');
// Create monthly values for sample expense
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = 67; // ~800/12
}
budgetWorksheet.value.expenses.push({
id: 'expense-sample',
name: 'Sample Expense',
mainCategory: 'Other Expenses',
subcategory: 'Miscellaneous',
source: 'user',
monthlyValues,
values: {
year1: { best: 1000, worst: 500, mostLikely: 800 },
year2: { best: 1200, worst: 600, mostLikely: 1000 },
year3: { best: 1500, worst: 800, mostLikely: 1200 }
}
});
}
// Debug: Log all revenue items and their categories
console.log('Final revenue items:');
budgetWorksheet.value.revenue.forEach(item => {
console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`);
});
console.log('Final expense items:');
budgetWorksheet.value.expenses.forEach(item => {
console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`);
});
// Ensure all items have monthlyValues and new structure - migrate existing items
[...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => {
// Migrate to new structure if needed
if (item.category && !item.mainCategory) {
console.log('Migrating item structure for:', item.name);
item.mainCategory = item.category; // Old category becomes mainCategory
// Set appropriate subcategory based on the main category and item name
if (item.category === 'Games & Products') {
const gameSubcategories = ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise'];
item.subcategory = gameSubcategories.includes(item.name) ? item.name : 'Direct sales';
} else if (item.category === 'Services & Contracts') {
const serviceSubcategories = ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services'];
item.subcategory = serviceSubcategories.includes(item.name) ? item.name : 'Contract development';
} else if (item.category === 'Investment Income') {
const investmentSubcategories = ['Impact investment', 'Venture capital', 'Loans'];
item.subcategory = investmentSubcategories.includes(item.name) ? item.name : 'Impact investment';
} else if (item.category === 'Salaries & Benefits') {
item.subcategory = 'Base wages and benefits';
} else if (item.category === 'Office & Operations') {
// Map specific office tools to appropriate subcategories
if (item.name.toLowerCase().includes('rent') || item.name.toLowerCase().includes('office')) {
item.subcategory = 'Rent';
} else if (item.name.toLowerCase().includes('util')) {
item.subcategory = 'Utilities';
} else if (item.name.toLowerCase().includes('insurance')) {
item.subcategory = 'Insurance';
} else {
item.subcategory = 'Office supplies';
}
} else {
// For other categories, use appropriate default
item.subcategory = 'Miscellaneous';
}
delete item.category; // Remove old property
}
if (!item.monthlyValues) {
console.log('Migrating item to monthly values:', item.name);
item.monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
// Try to use most likely value divided by 12, or default to 0
const yearlyValue = item.values?.year1?.mostLikely || 0;
item.monthlyValues[monthKey] = Math.round(yearlyValue / 12);
}
console.log('Added monthly values to', item.name, ':', item.monthlyValues);
}
});
console.log('Initialization complete. Revenue items:', budgetWorksheet.value.revenue.length, 'Expense items:', budgetWorksheet.value.expenses.length);
isInitialized.value = true;
}
// NEW: Budget worksheet functions
function updateBudgetValue(category, itemId, year, scenario, value) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
item.values[year][scenario] = Number(value) || 0;
}
}
function updateMonthlyValue(category, itemId, monthKey, value) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
if (!item.monthlyValues) {
item.monthlyValues = {};
}
item.monthlyValues[monthKey] = Number(value) || 0;
}
}
function addBudgetItem(category, name, selectedCategory = '') {
const id = `${category}-${Date.now()}`;
// Create empty monthly values for next 12 months
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = 0;
}
const newItem = {
id,
name,
mainCategory: selectedCategory || (category === 'revenue' ? 'Games & Products' : 'Other Expenses'),
subcategory: '', // Will be set by user via dropdown
source: 'user',
monthlyValues,
values: {
year1: { best: 0, worst: 0, mostLikely: 0 },
year2: { best: 0, worst: 0, mostLikely: 0 },
year3: { best: 0, worst: 0, mostLikely: 0 }
}
};
budgetWorksheet.value[category].push(newItem);
return id;
}
function removeBudgetItem(category, itemId) {
const items = budgetWorksheet.value[category];
const index = items.findIndex(i => i.id === itemId);
if (index > -1) {
items.splice(index, 1);
}
}
function renameBudgetItem(category, itemId, newName) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
item.name = newName;
}
}
function updateBudgetCategory(category, itemId, newCategory) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
item.category = newCategory;
}
}
function addCustomCategory(type, categoryName) {
if (type === 'revenue' && !revenueCategories.value.includes(categoryName)) {
revenueCategories.value.push(categoryName);
} else if (type === 'expenses' && !expenseCategories.value.includes(categoryName)) {
expenseCategories.value.push(categoryName);
}
}
// Reset function
function resetBudgetOverhead() {
overheadCosts.value = [];
productionCosts.value = [];
}
function resetBudgetWorksheet() {
// Reset all values to 0 but keep the structure
[...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => {
Object.keys(item.values).forEach(year => {
Object.keys(item.values[year]).forEach(scenario => {
item.values[year][scenario] = 0;
});
});
});
}
return {
// NEW: Budget worksheet
budgetWorksheet,
budgetTotals,
monthlyTotals,
revenueCategories,
expenseCategories,
revenueSubcategories,
groupedRevenue,
groupedExpenses,
isInitialized,
initializeFromWizardData,
updateBudgetValue,
updateMonthlyValue,
addBudgetItem,
removeBudgetItem,
renameBudgetItem,
updateBudgetCategory,
addCustomCategory,
resetBudgetWorksheet,
// LEGACY: Keep for backward compatibility
budgetLines,
overheadCosts,
productionCosts,
@ -128,7 +652,7 @@ export const useBudgetStore = defineStore(
{
persist: {
key: "urgent-tools-budget",
paths: ["overheadCosts", "productionCosts", "currentPeriod"],
paths: ["budgetWorksheet", "revenueCategories", "expenseCategories", "isInitialized", "overheadCosts", "productionCosts", "currentPeriod"],
},
}
);

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
export const useWizardStore = defineStore(
"wizard",
export const useCoopBuilderStore = defineStore(
"coop-builder",
() => {
const currentStep = ref(1);

22
stores/plan.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineStore } from 'pinia';
import type { Member, Offer } from '~/types/coaching';
import type { StreamRow } from '~/utils/offerToStream';
import { offersToStreams } from '~/utils/offerToStream';
export const usePlanStore = defineStore('plan', {
state: () => ({
members: [] as Member[],
streams: [] as StreamRow[]
}),
actions: {
setMembers(m: Member[]) {
this.members = m;
},
addStreamsFromOffers(o: Offer[]) {
const newStreams = offersToStreams(o, this.members);
this.streams.push(...newStreams);
}
}
});

View file

@ -87,6 +87,31 @@ export const useStreamsStore = defineStore(
}
}
// Initialize with fixture data if empty
async function initializeWithFixtures() {
if (streams.value.length === 0) {
const { useFixtures } = await import('~/composables/useFixtures');
const fixtures = useFixtures();
const { revenueStreams } = await fixtures.loadStreams();
revenueStreams.forEach(stream => {
upsertStream(stream);
});
}
}
// Load realistic demo data (for better user experience)
async function loadDemoData() {
resetStreams();
const { useFixtures } = await import('~/composables/useFixtures');
const fixtures = useFixtures();
const { revenueStreams } = await fixtures.loadStreams();
revenueStreams.forEach(stream => {
upsertStream(stream);
});
}
// Reset function
function resetStreams() {
streams.value = [];
@ -102,6 +127,8 @@ export const useStreamsStore = defineStore(
// Wizard actions
upsertStream,
resetStreams,
initializeWithFixtures,
loadDemoData,
// Legacy actions
addStream,
updateStream,

View file

@ -1,14 +1,5 @@
import type { Config } from 'tailwindcss'
import type { Config } from "tailwindcss";
export default {
darkMode: 'class',
theme: {
extend: {
colors: {
neutral: {
950: '#0a0a0a'
}
}
}
}
} satisfies Config
darkMode: "class",
} satisfies Config;

View file

@ -0,0 +1,431 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { nextTick } from 'vue';
// Import components and utilities
import CoachSkillsToOffers from '~/pages/coach/skills-to-offers.vue';
import WizardRevenueStep from '~/components/WizardRevenueStep.vue';
import { useOfferSuggestor } from '~/composables/useOfferSuggestor';
import { usePlanStore } from '~/stores/plan';
import { offerToStream, offersToStreams } from '~/utils/offerToStream';
import {
membersSample,
skillsCatalogSample,
problemsCatalogSample,
sampleSelections
} from '~/sample/skillsToOffersSamples';
// Mock router
vi.mock('vue-router', () => ({
useRouter: () => ({
push: vi.fn()
})
}));
// Mock stores
const mockStreamsStore = {
streams: [],
upsertStream: vi.fn(),
removeStream: vi.fn()
};
vi.mock('~/stores/streams', () => ({
useStreamsStore: () => mockStreamsStore
}));
describe('Coach Integration Tests', () => {
let pinia: any;
let planStore: any;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
planStore = usePlanStore();
// Reset stores
planStore.members = [];
planStore.streams = [];
mockStreamsStore.streams = [];
// Clear mocks
vi.clearAllMocks();
});
describe('Offer Generation with Sample Data', () => {
it('generates "Pitch Polish (2 days)" offer for Design+Writing and "Unclear pitch"', () => {
const { suggestOffers } = useOfferSuggestor();
// Setup input with sample data
const input = {
members: membersSample,
selectedSkillsByMember: {
[membersSample[0].id]: ['design'], // Maya: Design
[membersSample[2].id]: ['writing'] // Jordan: Writing
},
selectedProblems: ['unclear-pitch']
};
const catalogs = {
skills: skillsCatalogSample,
problems: problemsCatalogSample
};
// Generate offers
const offers = suggestOffers(input, catalogs);
// Should generate at least one offer
expect(offers.length).toBeGreaterThan(0);
// Should include Pitch Polish offer
const pitchPolishOffer = offers.find(offer =>
offer.name.includes('Pitch Polish') && offer.name.includes('2 days')
);
expect(pitchPolishOffer).toBeDefined();
expect(pitchPolishOffer?.name).toBe('Pitch Polish (2 days)');
});
it('calculates baseline price using correct formula: sum(hours*hourly*1.25) * 1.10', () => {
const { suggestOffers } = useOfferSuggestor();
const input = {
members: membersSample,
selectedSkillsByMember: {
[membersSample[0].id]: ['design'], // Maya: 32€/h
[membersSample[2].id]: ['writing'] // Jordan: 28€/h
},
selectedProblems: ['unclear-pitch']
};
const catalogs = {
skills: skillsCatalogSample,
problems: problemsCatalogSample
};
const offers = suggestOffers(input, catalogs);
const pitchPolishOffer = offers.find(offer =>
offer.name.includes('Pitch Polish')
);
expect(pitchPolishOffer).toBeDefined();
// Calculate expected price manually
// Pitch Polish is 2 days = 16 hours total (8 hours per day)
// Assume hours are distributed between Maya and Jordan
let expectedCost = 0;
for (const allocation of pitchPolishOffer!.hoursByMember) {
const member = membersSample.find(m => m.id === allocation.memberId);
if (member) {
expectedCost += allocation.hours * member.hourly * 1.25;
}
}
const expectedBaseline = Math.round(expectedCost * 1.10);
// Allow for ±1 rounding difference
expect(pitchPolishOffer!.price.baseline).toBeCloseTo(expectedBaseline, 0);
expect(Math.abs(pitchPolishOffer!.price.baseline - expectedBaseline)).toBeLessThanOrEqual(1);
});
it('generates offers that include required whyThis and riskNotes', () => {
const { suggestOffers } = useOfferSuggestor();
const input = {
members: membersSample,
selectedSkillsByMember: sampleSelections.selectedSkillsByMember,
selectedProblems: sampleSelections.selectedProblems
};
const catalogs = {
skills: skillsCatalogSample,
problems: problemsCatalogSample
};
const offers = suggestOffers(input, catalogs);
expect(offers.length).toBeGreaterThan(0);
offers.forEach(offer => {
expect(offer.whyThis).toBeDefined();
expect(offer.whyThis.length).toBeGreaterThan(0);
expect(offer.riskNotes).toBeDefined();
expect(offer.riskNotes.length).toBeGreaterThan(0);
expect(offer.price.calcNote).toBeDefined();
expect(offer.price.calcNote.length).toBeGreaterThan(0);
});
});
});
describe('Coach Page Integration', () => {
it('loads sample data and generates offers automatically', async () => {
const wrapper = mount(CoachSkillsToOffers, {
global: {
plugins: [pinia]
}
});
// Trigger sample data loading
await wrapper.vm.loadSampleData();
await nextTick();
// Wait for debounced offer generation
await new Promise(resolve => setTimeout(resolve, 350));
// Should have loaded sample members
expect(wrapper.vm.members).toEqual(membersSample);
// Should have pre-selected skills and problems
expect(wrapper.vm.selectedSkills).toEqual(sampleSelections.selectedSkillsByMember);
expect(wrapper.vm.selectedProblems).toEqual(sampleSelections.selectedProblems);
// Should have generated offers
expect(wrapper.vm.offers).toBeDefined();
expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
});
it('handles "Use these" action correctly', async () => {
const wrapper = mount(CoachSkillsToOffers, {
global: {
plugins: [pinia]
}
});
// Load sample data and generate offers
await wrapper.vm.loadSampleData();
await nextTick();
await new Promise(resolve => setTimeout(resolve, 350));
// Ensure we have offers
expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
const initialOffers = wrapper.vm.offers!;
// Trigger "Use these" action
await wrapper.vm.useOffers();
// Should have added streams to plan store
expect(planStore.streams.length).toBe(initialOffers.length);
// Verify streams are properly converted
planStore.streams.forEach((stream: any, index: number) => {
const originalOffer = initialOffers[index];
expect(stream.id).toBe(`offer-${originalOffer.id}`);
expect(stream.name).toBe(originalOffer.name);
expect(stream.unitPrice).toBe(originalOffer.price.baseline);
expect(stream.payoutDelayDays).toBe(originalOffer.payoutDelayDays);
expect(stream.feePercent).toBe(3);
expect(stream.notes).toBe(originalOffer.whyThis.join('. '));
});
});
});
describe('Stream Conversion and Validation', () => {
it('converts offers to streams with correct structure', () => {
const mockOffer = {
id: 'test-offer',
name: 'Test Offer (5 days)',
scope: ['Test scope item'],
hoursByMember: [
{ memberId: membersSample[0].id, hours: 20 },
{ memberId: membersSample[1].id, hours: 15 }
],
price: { baseline: 2000, stretch: 2400, calcNote: 'Test calculation' },
payoutDelayDays: 30,
whyThis: ['Test reason 1', 'Test reason 2'],
riskNotes: ['Test risk']
};
const stream = offerToStream(mockOffer, membersSample);
expect(stream.id).toBe('offer-test-offer');
expect(stream.name).toBe('Test Offer (5 days)');
expect(stream.unitPrice).toBe(2000);
expect(stream.payoutDelayDays).toBe(30);
expect(stream.feePercent).toBe(3);
expect(stream.notes).toBe('Test reason 1. Test reason 2');
expect(stream.restrictions).toBe('General');
expect(stream.certainty).toBe('Probable');
});
it('handles batch conversion correctly', () => {
const offers = [
{
id: 'offer1',
name: 'Workshop Offer',
scope: [],
hoursByMember: [{ memberId: membersSample[0].id, hours: 8 }],
price: { baseline: 800, stretch: 960, calcNote: 'Workshop calc' },
payoutDelayDays: 14,
whyThis: ['Quick workshop'],
riskNotes: ['Time constraint']
},
{
id: 'offer2',
name: 'Sprint Offer',
scope: [],
hoursByMember: [{ memberId: membersSample[1].id, hours: 40 }],
price: { baseline: 2000, stretch: 2400, calcNote: 'Sprint calc' },
payoutDelayDays: 45,
whyThis: ['Full development'],
riskNotes: ['Scope creep']
}
];
const streams = offersToStreams(offers, membersSample);
expect(streams.length).toBe(2);
expect(streams[0].name).toBe('Workshop Offer');
expect(streams[1].name).toBe('Sprint Offer');
expect(streams[0].unitsPerMonth).toBe(1); // Workshop
expect(streams[1].unitsPerMonth).toBe(0); // Sprint
});
});
describe('Wizard Integration', () => {
it('displays coach streams with proper highlighting', () => {
// Add coach streams to plan store
planStore.streams = [
{
id: 'offer-test',
name: 'Test Coach Offer',
unitPrice: 1500,
unitsPerMonth: 0, // Needs setup
payoutDelayDays: 30,
feePercent: 3,
notes: 'AI-suggested offer',
targetMonthlyAmount: 0,
category: 'services'
}
];
const wrapper = mount(WizardRevenueStep, {
global: {
plugins: [pinia]
}
});
// Should show coach stream with special styling
const coachStreamElements = wrapper.findAll('[data-testid="coach-stream"]');
expect(coachStreamElements.length).toBeGreaterThan(0);
// Should show hint about setting units
expect(wrapper.text()).toContain('Setting just 1 unit per offer');
});
it('allows proceeding to Review when unitsPerMonth is set to 1', () => {
// Setup coach stream with units set
planStore.streams = [
{
id: 'offer-test',
name: 'Test Coach Offer',
unitPrice: 1500,
unitsPerMonth: 1,
payoutDelayDays: 30,
feePercent: 3,
targetMonthlyAmount: 1500,
category: 'services'
}
];
const wrapper = mount(WizardRevenueStep, {
global: {
plugins: [pinia]
}
});
// Should not show setup hint when units are configured
expect(wrapper.text()).not.toContain('Setting just 1 unit per offer');
// Should have proper monthly amount calculated
const stream = planStore.streams[0];
expect(stream.targetMonthlyAmount).toBe(1500); // 1500 * 1
});
it('calculates summary metrics correctly', () => {
planStore.streams = [
{
id: 'offer-1',
targetMonthlyAmount: 3000,
payoutDelayDays: 30
},
{
id: 'offer-2',
targetMonthlyAmount: 2000,
payoutDelayDays: 45
}
];
const wrapper = mount(WizardRevenueStep, {
global: {
plugins: [pinia]
}
});
// Top source should be 60% (3000 out of 5000 total)
expect(wrapper.vm.getTopSourcePercentage()).toBe(60);
// Weighted payout should be 36 days ((3000*30 + 2000*45) / 5000)
expect(wrapper.vm.getWeightedPayoutDelay()).toBe(36);
});
});
describe('Error Handling and Edge Cases', () => {
it('handles empty selections gracefully', () => {
const { suggestOffers } = useOfferSuggestor();
const input = {
members: membersSample,
selectedSkillsByMember: {},
selectedProblems: []
};
const catalogs = {
skills: skillsCatalogSample,
problems: problemsCatalogSample
};
const offers = suggestOffers(input, catalogs);
// Should return empty array for invalid input
expect(offers).toEqual([]);
});
it('handles missing members gracefully', () => {
const mockOffer = {
id: 'test',
name: 'Test',
scope: [],
hoursByMember: [{ memberId: 'nonexistent', hours: 10 }],
price: { baseline: 1000, stretch: 1200, calcNote: 'Test' },
payoutDelayDays: 30,
whyThis: ['Test'],
riskNotes: ['Test']
};
expect(() => {
offerToStream(mockOffer, membersSample);
}).toThrow('Member not found: nonexistent');
});
it('handles zero amounts in calculations', () => {
planStore.streams = [
{
id: 'test-1',
targetMonthlyAmount: 0,
payoutDelayDays: 30
}
];
const wrapper = mount(WizardRevenueStep, {
global: {
plugins: [pinia]
}
});
expect(wrapper.vm.getTopSourcePercentage()).toBe(0);
expect(wrapper.vm.getWeightedPayoutDelay()).toBe(0);
});
});
});

29
types/coaching.ts Normal file
View file

@ -0,0 +1,29 @@
export type Member = {
id: string;
name: string;
role?: string;
hourly: number;
availableHrs: number;
};
export type SkillTag = {
id: string;
label: string;
};
export type ProblemTag = {
id: string;
label: string;
examples: string[];
};
export type Offer = {
id: string;
name: string;
scope: string[];
hoursByMember: Array<{ memberId: string; hours: number }>;
price: { baseline: number; stretch: number; calcNote: string };
payoutDelayDays: number;
whyThis: string[];
riskNotes: string[];
};

183
utils/offerToStream.ts Normal file
View file

@ -0,0 +1,183 @@
import type { Member, Offer } from "~/types/coaching";
interface StreamRow {
id: string;
name: string;
category: string;
subcategory: string;
targetPct: number;
targetMonthlyAmount: number;
certainty: "Committed" | "Probable" | "Aspirational";
payoutDelayDays: number;
terms: string;
revenueSharePct: number;
platformFeePct: number;
restrictions: "Restricted" | "General";
seasonalityWeights: number[];
effortHoursPerMonth: number;
// Extended fields for offer-derived streams
unitPrice?: number;
unitsPerMonth?: number;
feePercent?: number;
notes?: string;
costBreakdown?: {
totalHours: number;
totalCost: number;
memberCosts: Array<{
memberId: string;
memberName: string;
hours: number;
hourlyRate: number;
baseCost: number;
onCostAmount: number;
totalCost: number;
}>;
};
}
interface OfferToStreamOptions {
onCostPercent?: number; // Default 25% on-cost
category?: string; // Default category for offer-derived streams
}
export function offerToStream(
offer: Offer,
members: Member[],
options: OfferToStreamOptions = {}
): StreamRow {
const { onCostPercent = 25, category = "services" } = options;
// Create member lookup map
const memberMap = new Map(members.map(m => [m.id, m]));
// Determine units per month based on offer type
const unitsPerMonth = getUnitsPerMonth(offer);
// Calculate cost breakdown
const costBreakdown = calculateCostBreakdown(offer, memberMap, onCostPercent);
// Generate terms string
const terms = offer.payoutDelayDays <= 15 ? "Net 15" :
offer.payoutDelayDays <= 30 ? "Net 30" :
offer.payoutDelayDays <= 45 ? "Net 45" :
`Net ${offer.payoutDelayDays}`;
// Calculate monthly amount
const targetMonthlyAmount = unitsPerMonth > 0 ? offer.price.baseline * unitsPerMonth : 0;
return {
id: `offer-${offer.id}`,
name: offer.name,
category,
subcategory: inferSubcategory(offer.name),
targetPct: 0, // Will be calculated later when all streams are known
targetMonthlyAmount,
certainty: "Probable" as const, // Offers are more than aspirational but not fully committed
payoutDelayDays: offer.payoutDelayDays,
terms,
revenueSharePct: 0,
platformFeePct: 0,
restrictions: "General" as const,
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: calculateEffortHours(offer),
// Extended fields
unitPrice: offer.price.baseline,
unitsPerMonth,
feePercent: 3, // Default 3% processing/platform fee
notes: offer.whyThis.join(". "),
costBreakdown
};
}
export function offersToStreams(
offers: Offer[],
members: Member[],
options: OfferToStreamOptions = {}
): StreamRow[] {
return offers.map(offer => offerToStream(offer, members, options));
}
// Helper functions
function getUnitsPerMonth(offer: Offer): number {
const offerName = offer.name.toLowerCase();
// Clinics and workshops are typically one-time deliverables per month
if (offerName.includes("clinic") || offerName.includes("workshop")) {
return 1;
}
// Check for specific patterns that indicate recurring work
if (offerName.includes("retainer") || offerName.includes("ongoing")) {
return 1;
}
// Sprints and other project-based work default to 0 (custom/project basis)
return 0;
}
function inferSubcategory(offerName: string): string {
const name = offerName.toLowerCase();
if (name.includes("training") || name.includes("workshop")) {
return "Workshops/teaching";
}
if (name.includes("consulting") || name.includes("review") || name.includes("clinic")) {
return "Consulting";
}
if (name.includes("development") || name.includes("sprint") || name.includes("prototype")) {
return "Contract development";
}
if (name.includes("narrative") || name.includes("writing")) {
return "Technical services";
}
if (name.includes("art") || name.includes("pipeline")) {
return "Technical services";
}
return "Contract development"; // Default fallback
}
function calculateEffortHours(offer: Offer): number {
// Sum up total hours across all members
return offer.hoursByMember.reduce((total, allocation) => total + allocation.hours, 0);
}
function calculateCostBreakdown(
offer: Offer,
memberMap: Map<string, Member>,
onCostPercent: number
): StreamRow["costBreakdown"] {
const memberCosts = offer.hoursByMember.map(allocation => {
const member = memberMap.get(allocation.memberId);
if (!member) {
throw new Error(`Member not found: ${allocation.memberId}`);
}
const baseCost = allocation.hours * member.hourly;
const onCostAmount = baseCost * (onCostPercent / 100);
const totalCost = baseCost + onCostAmount;
return {
memberId: member.id,
memberName: member.name,
hours: allocation.hours,
hourlyRate: member.hourly,
baseCost,
onCostAmount,
totalCost
};
});
const totalHours = memberCosts.reduce((sum, mc) => sum + mc.hours, 0);
const totalCost = memberCosts.reduce((sum, mc) => sum + mc.totalCost, 0);
return {
totalHours,
totalCost,
memberCosts
};
}
// Type export for use in other files
export type { StreamRow };