app/components/WizardRevenueStep.vue

295 lines
8.9 KiB
Vue

<template>
<div class="max-w-4xl mx-auto space-y-6">
<!-- 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>
<!-- 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 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>
<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="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>
</template>
<script setup lang="ts">
import { useDebounceFn } from "@vueuse/core";
const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"];
}>();
// Store
const coop = useCoopBuilder();
const streams = computed(() =>
coop.streams.value.map(s => ({
// Map store fields to component expectations
id: s.id,
name: s.label,
category: s.category || 'games',
targetMonthlyAmount: s.monthly || 0,
subcategory: '',
targetPct: 0,
certainty: s.certainty || 'Aspirational',
payoutDelayDays: 30,
terms: 'Net 30',
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0,
}))
);
// Original category options
const categoryOptions = [
{ label: "Games & Products", value: "games" },
{ label: "Services & Contracts", value: "services" },
{ label: "Grants & Funding", value: "grants" },
{ label: "Community Support", value: "community" },
{ label: "Partnerships", value: "partnerships" },
{ label: "Investment Income", value: "investment" },
{ label: "In-Kind Contributions", value: "inkind" },
];
// Suggested names per category (subcategories)
const nameOptionsByCategory: Record<string, string[]> = {
games: [
"Direct sales",
"Platform revenue share",
"DLC/expansions",
"Merchandise",
],
services: [
"Contract development",
"Consulting",
"Workshops/teaching",
"Technical services",
],
grants: [
"Government funding",
"Arts council grants",
"Foundation support",
"Research grants",
],
community: [
"Patreon/subscriptions",
"Crowdfunding",
"Donations",
"Mutual aid received",
],
partnerships: [
"Corporate partnerships",
"Academic partnerships",
"Sponsorships",
],
investment: ["Impact investment", "Venture capital", "Loans"],
inkind: [
"Office space",
"Equipment/hardware",
"Software licenses",
"Professional services",
"Marketing/PR services",
"Legal services",
],
};
// Computed
const totalMonthlyAmount = computed(() =>
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
);
// Live-write with debounce
const debouncedSave = useDebounceFn((stream: any) => {
emit("save-status", "saving");
try {
// Convert component format back to store format
const streamData = {
id: stream.id,
label: stream.name || '',
monthly: stream.targetMonthlyAmount || 0,
category: stream.category || 'games',
certainty: stream.certainty || 'Aspirational'
};
coop.upsertStream(streamData);
emit("save-status", "saved");
} catch (error) {
console.error("Failed to save stream:", error);
emit("save-status", "error");
}
}, 300);
function saveStream(stream: any) {
if (stream.name && stream.category && stream.targetMonthlyAmount >= 0) {
debouncedSave(stream);
}
}
// Validation function for amount
function validateAndSaveAmount(value: string, stream: any) {
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
stream.targetMonthlyAmount = isNaN(numValue) ? 0 : Math.max(0, numValue);
saveStream(stream);
}
function addRevenueStream() {
const newStream = {
id: Date.now().toString(),
label: "",
monthly: 0,
category: "games",
certainty: "Aspirational"
};
coop.upsertStream(newStream);
}
function removeStream(id: string) {
coop.removeStream(id);
}
function exportStreams() {
const exportData = {
streams: streams.value,
exportedAt: new Date().toISOString(),
section: "revenue",
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `coop-revenue-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>