refactor: update app.vue and various components to improve routing paths, enhance UI consistency, and streamline layout for better user experience

This commit is contained in:
Jennie Robinson Faber 2025-09-11 11:51:48 +01:00
parent b6e8d3b7ec
commit 78af43770c
29 changed files with 1699 additions and 1990 deletions

View file

@ -85,13 +85,6 @@
v-if="suggestedCategories.length > 0">
Consider developing: {{ suggestedCategories.join(", ") }}
</p>
<p class="text-xs text-black dark:text-white">
<NuxtLink
to="/help#revenue-diversification"
class="text-white dark:text-neutral-100 hover:text-white dark:hover:text-white underline">
Learn how to develop these revenue streams
</NuxtLink>
</p>
</div>
</td>
</tr>

57
components/AppFooter.vue Normal file
View file

@ -0,0 +1,57 @@
<template>
<footer class="bg-neutral-50 dark:bg-neutral-900 border-t border-neutral-200 dark:border-neutral-800 mt-20">
<UContainer>
<div class="py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div class="col-span-1 md:col-span-2">
<h3 class="text-lg font-mono uppercase font-bold text-black dark:text-white mb-4">
Urgent Tools
</h3>
<p class="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Tools for cooperative planning, budgeting, and resource management.
</p>
</div>
<div>
<h4 class="text-sm font-semibold text-black dark:text-white mb-3">Tools</h4>
<ul class="space-y-2">
<li>
<NuxtLink to="/tools/coop-planner" class="text-sm text-neutral-600 dark:text-neutral-400 hover:text-black dark:hover:text-white transition-colors">
Budget Builder
</NuxtLink>
</li>
<li>
<NuxtLink to="/tools/wizards" class="text-sm text-neutral-600 dark:text-neutral-400 hover:text-black dark:hover:text-white transition-colors">
Wizards
</NuxtLink>
</li>
<li>
<NuxtLink to="/tools/resources" class="text-sm text-neutral-600 dark:text-neutral-400 hover:text-black dark:hover:text-white transition-colors">
Resources & Templates
</NuxtLink>
</li>
</ul>
</div>
</div>
<USeparator class="my-8" />
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-xs text-neutral-500 dark:text-neutral-500">
© {{ currentYear }} Urgent Tools. All rights reserved.
</p>
<div class="flex items-center gap-4">
<a href="https://github.com" target="_blank" rel="noopener noreferrer" class="text-neutral-500 dark:text-neutral-500 hover:text-black dark:hover:text-white transition-colors">
<UIcon name="i-simple-icons-github" class="w-5 h-5" />
</a>
</div>
</div>
</div>
</UContainer>
</footer>
</template>
<script setup lang="ts">
const currentYear = computed(() => new Date().getFullYear())
</script>

View file

@ -30,17 +30,17 @@ const coopBuilderItems = [
{
id: "coop-builder",
name: "Settings",
path: "/coop-builder",
path: "/tools/coop-builder",
},
{
id: "budget",
name: "Studio Budget",
path: "/budget",
path: "/tools/budget",
},
{
id: "project-budget",
name: "Project Budget",
path: "/project-budget",
path: "/tools/project-budget",
},
];

View file

@ -0,0 +1,40 @@
<template>
<USelectMenu
v-model="selectedCurrency"
:items="selectOptions"
value-key="value"
:ui="{
trigger: 'flex items-center gap-1.5 text-sm px-2.5 py-1.5 bg-white dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-md hover:bg-neutral-50 dark:hover:bg-neutral-900 transition-colors',
width: 'w-auto min-w-[120px]'
}"
size="xs"
variant="ghost"
color="neutral"
>
<template #label>
<span class="text-xs font-mono">{{ getCurrencySymbol(selectedCurrency) }} {{ selectedCurrency }}</span>
</template>
</USelectMenu>
</template>
<script setup lang="ts">
import { currencyOptions, getCurrencySymbol } from '~/utils/currency'
const { currency } = useCoopBuilder()
// Create options for USelectMenu
const selectOptions = computed(() =>
currencyOptions.map((curr) => ({
label: `${curr.symbol} ${curr.code}`,
value: curr.code,
}))
)
// Two-way binding with the store
const selectedCurrency = computed({
get: () => currency.value,
set: (value: string) => {
currency.value = value
}
})
</script>

View file

@ -958,475 +958,13 @@ const formatMembershipAgreementAsMarkdown = (data: any): string => {
// Conflict Resolution Framework formatting - Complete document with all static and dynamic content
const formatConflictResolutionAsText = (data: any): string => {
const formData = data.formData || {};
const cooperativeName = formData.orgName || "[Cooperative Name]";
let content = `${cooperativeName.toUpperCase()} CONFLICT RESOLUTION POLICY\n${"=".repeat(
cooperativeName.length + 30
)}\n\n`;
content += `Framework Created: ${
formData.createdDate || new Date().toISOString().split("T")[0]
}\n`;
if (formData.reviewDate) {
content += `Next Review: ${formData.reviewDate}\n`;
}
content += `\n`;
// Core Values section (if enabled)
if (data.sectionsEnabled?.values !== false) {
content += `OUR VALUES\n----------\n\n`;
content += `This conflict resolution framework is guided by our core values:\n\n`;
if (formData.coreValuesList && formData.coreValuesList.length > 0) {
formData.coreValuesList.forEach((value: string) => {
content += `${value}\n`;
});
content += `\n`;
}
if (formData.customValues) {
content += `${formData.customValues}\n\n`;
}
}
// Resolution Philosophy
const approachDescriptions: { [key: string]: string } = {
restorative:
"We use a restorative/loving justice approach that focuses on healing, understanding root causes, and repairing relationships rather than punishment.",
mediation:
"We use a mediation-first approach where neutral third-party facilitators help parties dialogue and find solutions.",
progressive:
"We use progressive discipline with clear escalation steps and defined consequences for violations.",
hybrid:
"We use a hybrid approach that combines multiple methods based on the type and severity of conflict.",
};
if (formData.approach && approachDescriptions[formData.approach]) {
content += `OUR APPROACH\n------------\n\n`;
content += `${approachDescriptions[formData.approach]}\n\n`;
content += `We do our best to resolve conflicts at the lowest possible escalation step (direct resolution), but agree to escalate conflicts (to assisted resolution) if they are not resolved.\n\n`;
}
// Reflection Process (if enabled)
if (data.sectionsEnabled?.reflection !== false) {
content += `REFLECTION\n----------\n\n`;
content += `Before engaging in direct resolution, we encourage taking time for reflection:\n\n`;
content += `1. Set aside time to think through what happened. What was the other person's behaviour? How did it affect you? Distinguish other people's actions from your feelings about them.\n`;
content += `2. Consider uncertainties or misunderstandings that may have occurred.\n`;
content += `3. Distinguish disagreement from personal hostility. Disagreement and dissent are part of healthy discussion. Hostility is not.\n`;
content += `4. Use your personal support system (friends, family, therapist, etc.) to work through and clarify your perspective.\n`;
content += `5. Ask yourself what part you played, how you could have behaved differently, and what your needs are.\n\n`;
if (formData.customReflectionPrompts) {
content += `Additional Reflection Prompts:\n${formData.customReflectionPrompts}\n\n`;
}
const reflectionTiming =
formData.reflectionPeriod || "Before any escalation";
content += `Reflection Timing: ${reflectionTiming}\n\n`;
}
// Direct Resolution (if enabled)
if (data.sectionsEnabled?.directResolution !== false) {
content += `DIRECT RESOLUTION\n-----------------\n\n`;
content += `A direct resolution process occurs when individuals communicate their concerns and work together to resolve disputes without filing an informal or formal complaint.\n\n`;
content += `Have a Conversation\n-------------------\n\n`;
content += `When there is a disagreement, the involved people should first communicate with each other about their concerns.\n\n`;
content += `1. Choose a time and place to meet that is private and agreeable to both.\n`;
content += `2. Allow reasonable time for the conversation.\n`;
content += `3. The point is mutual understanding, not determining who is right or wrong. This requires patience and willingness to listen without immediately dismissing the other person's perspective.\n`;
content += `4. Express thoughts and feelings directly without belittling or dismissing. Use "I" statements and active listening techniques.\n`;
content += `5. Communicate your wants and needs and make offers and requests.\n`;
content += `6. Learn for the future. Ask questions like, "If what I/you said or did came across that way, what can we do to prevent this from happening in the future?"\n`;
if (formData.documentDirectResolution) {
content += `7. Keep a written record of the resolution agreed to by both parties.\n\n`;
} else {
content += `\n`;
}
// Communication Channels
if (formData.channelsList && formData.channelsList.length > 0) {
content += `Escalating Communication Bandwidth\n-----------------------------------\n\n`;
content += `Whenever a misunderstanding or conflict arises, escalate the bandwidth of the channel:\n\n`;
formData.channelsList.forEach((channel: string, index: number) => {
content += `${index + 1}. ${channel}\n`;
});
content += `\n`;
}
if (formData.requireDirectAttempt) {
content += `NOTE: Direct resolution must be attempted before escalating to assisted resolution, unless safety concerns prevent this.\n\n`;
}
}
// Assisted Resolution
content += `ASSISTED RESOLUTION\n-------------------\n\n`;
content += `If talking things out doesn't work, you can ask a responsible contact person for help in writing.\n\n`;
// Responsible Contact People
if (formData.receiversList && formData.receiversList.length > 0) {
content += `Initial Contact Options:\n`;
formData.receiversList.forEach((receiver: string) => {
content += `${receiver}\n`;
});
content += `\n`;
}
// Mediator Structure
if (formData.mediatorType) {
content += `Mediation/Facilitation Structure: ${formData.mediatorType}\n\n`;
if (formData.supportPeople) {
content += `Support People: Parties may bring a trusted person for emotional support during mediation sessions.\n\n`;
}
}
// Timeline
content += `Response Times:\n`;
if (formData.initialResponse) {
content += `• Initial Response: ${formData.initialResponse}\n`;
}
if (formData.resolutionTarget) {
content += `• Target Resolution: ${formData.resolutionTarget}\n\n`;
}
// Formal Complaints
content += `FORMAL COMPLAINTS\n-----------------\n\n`;
content += `If assisted resolution efforts do not result in an acceptable outcome within a reasonable timeframe, a formal complaint may be filed in writing.\n\n`;
// Required Elements
if (
formData.complaintElementsList &&
formData.complaintElementsList.length > 0
) {
content += `Written Complaint Requirements:\n`;
formData.complaintElementsList.forEach((element: string, index: number) => {
content += `${index + 1}. ${element}\n`;
});
content += `\n`;
}
// Formal Process Timeline
content += `Formal Process Timeline:\n`;
if (formData.formalAcknowledgmentTime) {
content += `• Acknowledgment: ${formData.formalAcknowledgmentTime}\n`;
}
if (formData.formalReviewTime) {
content += `• Review Completion: ${formData.formalReviewTime}\n\n`;
}
if (formData.requireExternalAdvice) {
content += `External Expertise: For complex complaints involving multiple parties or organizational leaders, external legal advice will be sought.\n\n`;
}
// Settlement Documentation
if (formData.requireMinutesOfSettlement) {
content += `Reaching Agreement\n------------------\n\n`;
content += `Any resolution agreed upon must be documented in "Minutes of Settlement" signed by both parties. These agreements will be kept confidential according to our privacy standards.\n\n`;
}
// Consequences and Actions
if (formData.actionsList && formData.actionsList.length > 0) {
content += `POSSIBLE OUTCOMES\n-----------------\n\n`;
content += `Depending on the situation, resolution may include:\n\n`;
formData.actionsList.forEach((action: string) => {
content += `${action}\n`;
});
content += `\n`;
}
if (formData.appealProcess) {
content += `Appeals Process: Parties may request review of decisions through our appeals process.\n\n`;
}
// Documentation and Privacy
if (data.sectionsEnabled?.documentation !== false) {
content += `DOCUMENTATION & PRIVACY\n-----------------------\n\n`;
if (formData.docLevel) {
content += `Documentation Level: ${formData.docLevel}\n\n`;
}
if (formData.confidentiality) {
content += `Confidentiality: ${formData.confidentiality}\n\n`;
}
if (formData.retention) {
content += `Record Retention: ${formData.retention}\n\n`;
}
}
// External Resources (if enabled)
if (data.sectionsEnabled?.externalResources !== false) {
content += `EXTERNAL RESOURCES\n------------------\n\n`;
if (formData.includeHumanRights) {
content += `Individuals who are not satisfied with the outcome of a harassment or discrimination complaint may file a complaint with the Canadian Human Rights Commission or their provincial human rights tribunal.\n\n`;
}
if (formData.additionalResources) {
content += `Additional Resources:\n${formData.additionalResources}\n\n`;
}
}
// Implementation
content += `POLICY MANAGEMENT\n-----------------\n\n`;
if (formData.training) {
content += `Training Requirements:\n${formData.training}\n\n`;
}
content += `Review and Updates:\n`;
if (formData.reviewSchedule) {
content += `This policy will be reviewed ${formData.reviewSchedule.toLowerCase()}.\n\n`;
}
if (formData.amendments) {
content += `Amendment Process: ${formData.amendments}\n\n`;
}
// Acknowledgments
if (formData.acknowledgments) {
content += `Acknowledgments:\n${formData.acknowledgments}\n\n`;
}
return content;
// Use the pre-generated content from the Vue component
return data.content || "No content available";
};
const formatConflictResolutionAsMarkdown = (data: any): string => {
const formData = data.formData || {};
const cooperativeName = formData.orgName || "[Cooperative Name]";
let content = `# ${cooperativeName} Conflict Resolution Policy\n\n`;
content += `*Framework Created: ${
formData.createdDate || new Date().toISOString().split("T")[0]
}*\n`;
if (formData.reviewDate) {
content += `*Next Review: ${formData.reviewDate}*\n`;
}
content += `\n---\n\n`;
// Core Values section (if enabled)
if (data.sectionsEnabled?.values !== false) {
content += `## Our Values\n\n`;
content += `This conflict resolution framework is guided by our core values:\n\n`;
if (formData.coreValuesList && formData.coreValuesList.length > 0) {
formData.coreValuesList.forEach((value: string) => {
content += `- **${value}**\n`;
});
content += `\n`;
}
if (formData.customValues) {
content += `${formData.customValues}\n\n`;
}
}
// Resolution Philosophy
const approachDescriptions: { [key: string]: string } = {
restorative:
"We use a **restorative/loving justice** approach that focuses on healing, understanding root causes, and repairing relationships rather than punishment.",
mediation:
"We use a **mediation-first** approach where neutral third-party facilitators help parties dialogue and find solutions.",
progressive:
"We use **progressive discipline** with clear escalation steps and defined consequences for violations.",
hybrid:
"We use a **hybrid approach** that combines multiple methods based on the type and severity of conflict.",
};
if (formData.approach && approachDescriptions[formData.approach]) {
content += `## Our Approach\n\n`;
content += `${approachDescriptions[formData.approach]}\n\n`;
content += `We do our best to resolve conflicts at the lowest possible escalation step (direct resolution), but agree to escalate conflicts (to assisted resolution) if they are not resolved.\n\n`;
}
// Reflection Process (if enabled)
if (data.sectionsEnabled?.reflection !== false) {
content += `## Reflection\n\n`;
content += `Before engaging in direct resolution, we encourage taking time for reflection:\n\n`;
content += `1. **Set aside time to think** through what happened. What was the other person's behaviour? How did it affect you? *Distinguish other people's **actions** from your **feelings** about them.*\n`;
content += `2. **Consider uncertainties** or misunderstandings that may have occurred.\n`;
content += `3. **Distinguish disagreement from personal hostility.** Disagreement and dissent are part of healthy discussion. Hostility is not.\n`;
content += `4. **Use your personal support system** (friends, family, therapist, etc.) to work through and clarify your perspective.\n`;
content += `5. **Ask yourself** what part you played, how you could have behaved differently, and what your needs are.\n\n`;
if (formData.customReflectionPrompts) {
content += `### Additional Reflection Prompts\n\n`;
content += `${formData.customReflectionPrompts}\n\n`;
}
const reflectionTiming =
formData.reflectionPeriod || "Before any escalation";
content += `**Reflection Timing:** ${reflectionTiming}\n\n`;
}
// Direct Resolution (if enabled)
if (data.sectionsEnabled?.directResolution !== false) {
content += `## Direct Resolution\n\n`;
content += `A *direct resolution* process occurs when individuals communicate their concerns and work together to resolve disputes without filing an informal or formal complaint.\n\n`;
content += `### Have a Conversation\n\n`;
content += `When there is a disagreement, the involved people should first **communicate with each other** about their concerns.\n\n`;
content += `1. **Choose a time and place** to meet that is private and agreeable to both.\n`;
content += `2. **Allow reasonable time** for the conversation.\n`;
content += `3. **The point is mutual understanding**, not determining who is right or wrong. This requires patience and willingness to listen without immediately dismissing the other person's perspective.\n`;
content += `4. **Express thoughts and feelings directly** without belittling or dismissing. Use "I" statements and active listening techniques.\n`;
content += `5. **Communicate your wants and needs** and make offers and requests.\n`;
content += `6. **Learn for the future.** Ask questions like, "If what I/you said or did came across that way, what can we do to prevent this from happening in the future?"\n`;
if (formData.documentDirectResolution) {
content += `7. **Keep a written record** of the resolution agreed to by both parties.\n\n`;
} else {
content += `\n`;
}
// Communication Channels
if (formData.channelsList && formData.channelsList.length > 0) {
content += `### Escalating Communication Bandwidth\n\n`;
content += `Whenever a misunderstanding or conflict arises, **escalate the bandwidth of the channel**:\n\n`;
formData.channelsList.forEach((channel: string, index: number) => {
content += `${index + 1}. ${channel}\n`;
});
content += `\n`;
}
if (formData.requireDirectAttempt) {
content += `> **Note:** Direct resolution must be attempted before escalating to assisted resolution, unless safety concerns prevent this.\n\n`;
}
}
// Assisted Resolution
content += `## Assisted Resolution\n\n`;
content += `If talking things out doesn't work, you can ask a responsible contact person for help in writing.\n\n`;
// Responsible Contact People
if (formData.receiversList && formData.receiversList.length > 0) {
content += `### Initial Contact Options\n\n`;
content += `You can report conflicts to any of the following:\n\n`;
formData.receiversList.forEach((receiver: string) => {
content += `- ${receiver}\n`;
});
content += `\n`;
}
// Mediator Structure
if (formData.mediatorType) {
content += `### Mediation/Facilitation\n\n`;
content += `**Structure:** ${formData.mediatorType}\n\n`;
if (formData.supportPeople) {
content += `**Support People:** Parties may bring a trusted person for emotional support during mediation sessions.\n\n`;
}
}
// Timeline
content += `### Response Times\n\n`;
if (formData.initialResponse) {
content += `- **Initial Response:** ${formData.initialResponse}\n`;
}
if (formData.resolutionTarget) {
content += `- **Target Resolution:** ${formData.resolutionTarget}\n\n`;
}
// Formal Complaints
content += `## Formal Complaints\n\n`;
content += `If assisted resolution efforts do not result in an acceptable outcome within a reasonable timeframe, a *formal complaint* may be filed in writing.\n\n`;
// Required Elements
if (
formData.complaintElementsList &&
formData.complaintElementsList.length > 0
) {
content += `### Written Complaint Requirements\n\n`;
content += `The formal complaint must include:\n\n`;
formData.complaintElementsList.forEach((element: string, index: number) => {
content += `${index + 1}. ${element}\n`;
});
content += `\n`;
}
// Formal Process Timeline
content += `### Formal Process Timeline\n\n`;
if (formData.formalAcknowledgmentTime) {
content += `- **Acknowledgment:** ${formData.formalAcknowledgmentTime}\n`;
}
if (formData.formalReviewTime) {
content += `- **Review Completion:** ${formData.formalReviewTime}\n\n`;
}
if (formData.requireExternalAdvice) {
content += `> **External Expertise:** For complex complaints involving multiple parties or organizational leaders, external legal advice will be sought.\n\n`;
}
// Settlement Documentation
if (formData.requireMinutesOfSettlement) {
content += `### Reaching Agreement\n\n`;
content += `Any resolution agreed upon must be documented in "Minutes of Settlement" signed by both parties. These agreements will be kept confidential according to our privacy standards.\n\n`;
}
// Consequences and Actions
if (formData.actionsList && formData.actionsList.length > 0) {
content += `## Possible Outcomes\n\n`;
content += `Depending on the situation, resolution may include:\n\n`;
formData.actionsList.forEach((action: string) => {
content += `- ${action}\n`;
});
content += `\n`;
}
if (formData.appealProcess) {
content += `### Appeals Process\n\n`;
content += `Parties may request review of decisions through our appeals process.\n\n`;
}
// Documentation and Privacy
if (data.sectionsEnabled?.documentation !== false) {
content += `## Documentation & Privacy\n\n`;
if (formData.docLevel) {
content += `**Documentation Level:** ${formData.docLevel}\n\n`;
}
if (formData.confidentiality) {
content += `**Confidentiality:** ${formData.confidentiality}\n\n`;
}
if (formData.retention) {
content += `**Record Retention:** ${formData.retention}\n\n`;
}
}
// External Resources (if enabled)
if (data.sectionsEnabled?.externalResources !== false) {
content += `## External Resources\n\n`;
if (formData.includeHumanRights) {
content += `Individuals who are not satisfied with the outcome of a harassment or discrimination complaint may file a complaint with the [Canadian Human Rights Commission](https://www.chrc-ccdp.gc.ca/eng) or their provincial human rights tribunal.\n\n`;
}
if (formData.additionalResources) {
content += `### Additional Resources\n\n`;
content += `${formData.additionalResources}\n\n`;
}
}
// Implementation
content += `## Policy Management\n\n`;
if (formData.training) {
content += `### Training Requirements\n\n`;
content += `${formData.training}\n\n`;
}
content += `### Review and Updates\n\n`;
if (formData.reviewSchedule) {
content += `This policy will be reviewed ${formData.reviewSchedule.toLowerCase()}.\n\n`;
}
if (formData.amendments) {
content += `**Amendment Process:** ${formData.amendments}\n\n`;
}
// Acknowledgments
if (formData.acknowledgments) {
content += `### Acknowledgments\n\n`;
content += `${formData.acknowledgments}\n\n`;
}
return content;
// Use the pre-generated content from the Vue component
return data.content || "No content available";
};
// Decision Framework formatting

View file

@ -1,51 +0,0 @@
<template>
<UTooltip
:text="definition"
:ui="{
background: 'bg-white dark:bg-neutral-900',
ring: 'ring-1 ring-neutral-200 dark:ring-neutral-800',
rounded: 'rounded-lg',
shadow: 'shadow-lg',
base: 'px-3 py-2 text-sm max-w-xs',
}"
:popper="{ arrow: true }">
<template #text>
<div class="space-y-2">
<div class="font-medium">{{ term }}</div>
<div class="text-neutral-600">{{ definition }}</div>
<NuxtLink
:to="`/glossary#${termId}`"
class="text-primary-600 hover:text-primary-700 text-xs inline-flex items-center gap-1"
@click="$emit('glossary-click')">
<UIcon name="i-heroicons-book-open" class="w-3 h-3" />
See full definition
</NuxtLink>
</div>
</template>
<span
class="underline decoration-dotted decoration-neutral-400 hover:decoration-primary-500 cursor-help"
:class="{ 'font-medium': emphasis }"
:tabindex="0"
:aria-describedby="`tooltip-${termId}`"
@keydown.enter="$emit('glossary-click')"
@keydown.space.prevent="$emit('glossary-click')">
<slot>{{ term }}</slot>
</span>
</UTooltip>
</template>
<script setup lang="ts">
interface Props {
term: string;
termId: string;
definition: string;
emphasis?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
emphasis: false,
});
defineEmits(["glossary-click"]);
</script>

View file

@ -2,275 +2,296 @@
<div class="mx-auto">
<div class="relative">
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div class="relative bg-white dark:bg-neutral-950 border-1 border-black dark:border-neutral-400">
<!-- Controls -->
<div
class="p-6 border-b-1 border-black dark:border-neutral-400 bg-neutral-100 dark:bg-neutral-950">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2">
<UFormField label="Duration in months" class="">
<UInputNumber
id="duration"
v-model="durationMonths"
:min="3"
:max="24"
size="lg"
class="w-full" />
</UFormField>
class="relative bg-white dark:bg-neutral-950 border-1 border-black dark:border-neutral-400">
<!-- Controls -->
<div
class="p-6 border-b-1 border-black dark:border-neutral-400 bg-neutral-100 dark:bg-neutral-950">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2">
<UFormField label="Duration in months" class="">
<UInputNumber
id="duration"
v-model="durationMonths"
:min="3"
:max="24"
size="lg"
class="w-full" />
</UFormField>
</div>
</div>
</div>
</div>
<!-- Cost Summary -->
<div
class="p-6 border-b-1 border-black bg-neutral-100 dark:bg-neutral-950">
<ul class="space-y-2">
<!-- Two Column Layout -->
<li class="pb-2">
<div class="grid grid-cols-2 gap-6">
<!-- Left Column: Detailed Breakdown -->
<div
class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
<div class="font-bold font-display">
Monthly payroll breakdown:
</div>
<div>
Base hourly rate: {{ currency(theoreticalHourlyRate) }}/hour
</div>
<div class="pl-2 space-y-0.5">
<!-- Cost Summary -->
<div
class="p-6 border-b-1 border-black bg-neutral-100 dark:bg-neutral-950">
<ul class="space-y-2">
<!-- Two Column Layout -->
<li class="pb-2">
<div class="grid grid-cols-2 gap-6">
<!-- Left Column: Detailed Breakdown -->
<div
class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
<div class="font-bold font-display">
Monthly payroll breakdown:
</div>
<div>
Base hourly rate: {{ currency(theoreticalHourlyRate) }}/hour
</div>
<div class="pl-2 space-y-0.5">
<div
v-for="member in props.members"
:key="member.name"
class="flex justify-between">
<span
>{{ member.name }} @ {{ member.hoursPerMonth }}h:</span
>
<span class="font-mono">{{
currency(member.hoursPerMonth * theoreticalHourlyRate)
}}</span>
</div>
</div>
<div
v-for="member in props.members"
:key="member.name"
class="flex justify-between">
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-medium">
<span>Total base pay:</span>
<span class="font-mono">{{
currency(baseMonthlyPayroll)
}}</span>
</div>
<div class="flex justify-between">
<span
>{{ member.name }} @ {{ member.hoursPerMonth }}h:</span
>Payroll taxes & benefits ({{
percent(props.oncostRate)
}}):</span
>
<span class="font-mono">{{
currency(member.hoursPerMonth * theoreticalHourlyRate)
currency(theoreticalOncosts)
}}</span>
</div>
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold">
<span>Total monthly payroll:</span>
<span class="font-mono">{{
currency(baseMonthlyPayroll + theoreticalOncosts)
}}</span>
</div>
</div>
<!-- Right Column: Complete Project Budget Estimate -->
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-medium">
<span>Total base pay:</span>
<span class="font-mono">{{
currency(baseMonthlyPayroll)
}}</span>
</div>
<div class="flex justify-between">
<span
>Payroll taxes & benefits ({{
percent(props.oncostRate)
}}):</span
>
<span class="font-mono">{{
currency(theoreticalOncosts)
}}</span>
</div>
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold">
<span>Total monthly payroll:</span>
<span class="font-mono">{{
currency(baseMonthlyPayroll + theoreticalOncosts)
}}</span>
class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
<div class="font-bold font-display">
Complete project budget estimate:
</div>
<p
class="text-sm mb-2 italic text-neutral-500 dark:text-neutral-400">
This uses a 1.8x multiplier, based on industry standards.
</p>
<!-- Team Payroll -->
<div class="flex justify-between">
<span class="font-bold">Team Payroll:</span>
<span class="font-mono font-bold">{{
currency(projectBudget)
}}</span>
</div>
<!-- External Resources -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>External resources:</span>
<span class="font-mono">{{
currency(externalResources)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Freelancers, contractors, consultants, voice talent
</div>
</div>
<!-- Tools & Software -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Tools and software:</span>
<span class="font-mono">{{
currency(toolsSoftware)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Licenses, subscriptions, cloud services, development
tools/kits
</div>
</div>
<!-- Testing & QA -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Testing and QA:</span>
<span class="font-mono">{{ currency(testingQA) }}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
User testing sessions, focus groups, QA contractors,
playtesting, bug fixing
</div>
</div>
<!-- Marketing & Community -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Marketing and community:</span>
<span class="font-mono">{{
currency(marketingCommunity)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Community building, promotional materials, launch
preparation (minimum 10% for most funders)
</div>
</div>
<!-- Administration -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Administration:</span>
<span class="font-mono">{{
currency(administration)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Legal, accounting, insurance, project-specific business
costs
</div>
</div>
<!-- Subtotal -->
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold">
<span>Subtotal:</span>
<span class="font-mono">{{
currency(budgetSubtotal)
}}</span>
</div>
<!-- Contingency -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Contingency (10%):</span>
<span class="font-mono">{{ currency(contingency) }}</span>
</div>
</div>
<!-- Total -->
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold text-lg">
<span>TOTAL PROJECT:</span>
<span class="font-mono">{{
currency(totalProjectBudget)
}}</span>
</div>
</div>
</div>
<!-- Right Column: Complete Project Budget Estimate -->
<div
class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
<div class="font-bold font-display">
Complete project budget estimate:
</div>
<p
class="text-sm mb-2 italic text-neutral-500 dark:text-neutral-400">
This uses a 1.8x multiplier, based on industry standards.
</p>
<!-- Team Payroll -->
<div class="flex justify-between">
<span class="font-bold">Team Payroll:</span>
<span class="font-mono font-bold">{{
currency(projectBudget)
}}</span>
</div>
<!-- External Resources -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>External resources:</span>
<span class="font-mono">{{
currency(externalResources)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Freelancers, contractors, consultants, voice talent
</div>
</div>
<!-- Tools & Software -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Tools and software:</span>
<span class="font-mono">{{ currency(toolsSoftware) }}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Licenses, subscriptions, cloud services, development
tools/kits
</div>
</div>
<!-- Testing & QA -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Testing and QA:</span>
<span class="font-mono">{{ currency(testingQA) }}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
User testing sessions, focus groups, QA contractors,
playtesting, bug fixing
</div>
</div>
<!-- Marketing & Community -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Marketing and community:</span>
<span class="font-mono">{{
currency(marketingCommunity)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Community building, promotional materials, launch
preparation (minimum 10% for most funders)
</div>
</div>
<!-- Administration -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Administration:</span>
<span class="font-mono">{{
currency(administration)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Legal, accounting, insurance, project-specific business
costs
</div>
</div>
<!-- Subtotal -->
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold">
<span>Subtotal:</span>
<span class="font-mono">{{ currency(budgetSubtotal) }}</span>
</div>
<!-- Contingency -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Contingency (10%):</span>
<span class="font-mono">{{ currency(contingency) }}</span>
</div>
</div>
<!-- Total -->
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold text-lg">
<span>TOTAL PROJECT:</span>
<span class="font-mono">{{
currency(totalProjectBudget)
}}</span>
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- Break-Even Sketch -->
<div
class="text-black dark:text-white border-t border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-950">
<div class="p-6 text-black dark:text-white">
<h3 class="font-bold mb-4">Break-Even Sketch</h3>
<!-- Inputs -->
<div class="flex flex-wrap space-x-4 mb-6">
<div>
<label for="price" class="block font-bold text-sm mb-1"
>Price per copy:</label
>
<UInput
id="price"
v-model="price"
type="number"
placeholder="20.00"
size="lg"
class="w-32"
:ui="{ leading: 'pointer-events-none' }">
<template #leading>
<span class="text-sm font-mono">{{
formatCurrency(0, {
showSymbol: true,
precision: 0,
}).replace("0", "")
}}</span>
</template>
</UInput>
</div>
<div>
<label for="storeCut" class="block font-bold text-sm mb-1"
>Store cut:</label
>
<UInput
id="storeCut"
v-model="storeCutInput"
type="number"
placeholder="30"
size="lg"
class="w-24"
:ui="{ trailing: 'pointer-events-none' }">
<template #trailing>
<span class="text-sm font-mono">%</span>
</template>
</UInput>
</div>
<div>
<label for="reviewToSales" class="block font-bold text-sm mb-1"
>Sales per review:</label
>
<UInputNumber
id="reviewToSales"
v-model="reviewToSales"
:min="5"
:max="100"
size="lg" />
</div>
</div>
<!-- Outputs -->
<ul class="space-y-2 mb-4">
<li>
At {{ currency(price) }} per copy after store fees, you'd need
about
<strong>{{ unitsToBreakEven.toLocaleString() }} sales</strong> to
cover this budget.
</li>
<li>
That's roughly
<strong
>{{ reviewsToBreakEven.toLocaleString() }} Steam reviews</strong
>
( {{ reviewToSales }} sales per review).
</li>
</ul>
<p class="text-base text-neutral-600 dark:text-neutral-400">
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not
included.
</p>
</div>
</div>
<!-- Break-Even Sketch -->
<div
class="text-black dark:text-white border-t border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-950">
<div class="p-6 text-black dark:text-white">
<h3 class="font-bold mb-4">Break-Even Sketch</h3>
<p class="mb-4">
There are so many ways to guess at the sales figures of published
games. One is to use a review-to-sales ratio, formalized in the
Boxleiter method and later discussed and refined by many others.
<a
href="https://app.sensortower.com/vgi/insights/article/how-to-estimate-steam-video-game-sales"
>VGInsights</a
>
and <a href="https://gamediscover.co/">GameDiscoverCo</a> are good
sources for more information on this method. Your chosen ratio
will vary, typically between 30 and 70 reviews per sale, and is
dependent on factors like genre, pricing, and market conditions at
the time of release.
</p>
<!-- Inputs -->
<div class="flex flex-wrap space-x-4 mb-6">
<div>
<label for="price" class="block font-bold text-sm mb-1"
>Price per copy:</label
>
<UInput
id="price"
v-model="price"
type="number"
placeholder="20.00"
size="lg"
class="w-32"
:ui="{ leading: 'pointer-events-none' }">
<template #leading>
<span class="text-sm font-mono">{{
formatCurrency(0, {
showSymbol: true,
precision: 0,
}).replace("0", "")
}}</span>
</template>
</UInput>
</div>
<div>
<label for="storeCut" class="block font-bold text-sm mb-1"
>Store cut:</label
>
<UInput
id="storeCut"
v-model="storeCutInput"
type="number"
placeholder="30"
size="lg"
class="w-24"
:ui="{ trailing: 'pointer-events-none' }">
<template #trailing>
<span class="text-sm font-mono">%</span>
</template>
</UInput>
</div>
<div>
<label for="reviewToSales" class="block font-bold text-sm mb-1"
>Sales per review:</label
>
<UInputNumber
id="reviewToSales"
v-model="reviewToSales"
:min="5"
:max="100"
size="lg" />
</div>
</div>
<!-- Outputs -->
<ul class="space-y-2 mb-4">
<li>
At {{ currency(price) }} per copy after store fees, you'd need
about
<strong class="bg-yellow-200 dark:text-black"
>{{ unitsToBreakEven.toLocaleString() }} sales</strong
>
to cover this budget.
</li>
<li>
That's roughly
<strong class="bg-yellow-200 dark:text-black"
>{{ reviewsToBreakEven.toLocaleString() }} Steam
reviews</strong
>
( {{ reviewToSales }} sales per review).
</li>
</ul>
<p class="text-base text-neutral-600 dark:text-neutral-400">
Taxes not included.
</p>
</div>
</div>
</div>
</div>
</div>

View file

@ -35,24 +35,7 @@
This hourly rate applies to all paid work in your co-op
</p>
<div class="flex gap-4 items-start">
<!-- Currency Selection -->
<UFormField label="Currency" class="w-1/2">
<USelect
v-model="selectedCurrency"
:items="currencySelectOptions"
placeholder="Select currency"
size="xl"
class="w-full"
@update:model-value="updateCurrency">
<template #leading>
<span class="text-lg">{{
getCurrencySymbol(selectedCurrency)
}}</span>
</template>
</USelect>
</UFormField>
<UFormField label="Hourly Rate" class="w-1/2">
<UFormField label="Hourly Rate" class="w-full">
<UInput
v-model="wageText"
type="text"
@ -62,7 +45,7 @@
@update:model-value="validateAndSaveWage">
<template #leading>
<span class="text-neutral-500 text-xl">{{
getCurrencySymbol(selectedCurrency)
getCurrencySymbol(selectedCurrency.value)
}}</span>
</template>
</UInput>
@ -73,7 +56,7 @@
</template>
<script setup lang="ts">
import { currencyOptions, getCurrencySymbol } from "~/utils/currency";
import { getCurrencySymbol } from "~/utils/currency";
const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"];
@ -87,7 +70,7 @@ const store = useCoopBuilderStore();
const selectedPolicy = ref(coop.policy.value?.relationship || "equal-pay");
const roleBands = ref(coop.policy.value?.roleBands || {});
const wageText = ref(String(store.equalHourlyWage || ""));
const selectedCurrency = ref(coop.currency.value || "EUR");
const selectedCurrency = computed(() => coop.currency.value || "EUR");
function parseNumberInput(val: unknown): number {
if (typeof val === "number") return val;
@ -115,23 +98,11 @@ const policyOptions = [
},
];
// Currency options for USelect (simplified format)
const currencySelectOptions = computed(() =>
currencyOptions.map((currency) => ({
label: `${currency.name} (${currency.code})`,
value: currency.code,
}))
);
// Already initialized above with store values
// Removed uniqueRoles computed - no longer needed with simplified policies
function updateCurrency(value: string) {
selectedCurrency.value = value;
coop.setCurrency(value);
emit("save-status", "saved");
}
function updatePolicy(value: string) {
coop.setPolicy(value as "equal-pay" | "needs-weighted" | "hours-weighted");

View file

@ -1,9 +1,8 @@
<template>
<div class="mb-12">
<div class="">
<div class="w-full mx-auto">
<nav
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
>
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center">
<NuxtLink
v-for="wizard in templateWizards"
:key="wizard.id"
@ -13,8 +12,7 @@
isActive(wizard.path)
? 'bg-black text-white dark:bg-white dark:text-black no-underline'
: ''
"
>
">
{{ wizard.name }}
</NuxtLink>
</nav>
@ -29,22 +27,22 @@ const templateWizards = [
{
id: "membership-agreement",
name: "Membership Agreement",
path: "/templates/membership-agreement",
path: "/tools/templates/membership-agreement",
},
{
id: "conflict-resolution-framework",
name: "Conflict Resolution",
path: "/templates/conflict-resolution-framework",
path: "/tools/templates/conflict-resolution-framework",
},
{
id: "tech-charter",
name: "Tech Charter",
path: "/templates/tech-charter",
path: "/tools/templates/tech-charter",
},
{
id: "decision-framework",
name: "Decision Framework",
path: "/templates/decision-framework",
path: "/tools/templates/decision-framework",
},
];