289 lines
8.8 KiB
Vue
289 lines
8.8 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";
|
|
import { storeToRefs } from "pinia";
|
|
|
|
const emit = defineEmits<{
|
|
"save-status": [status: "saving" | "saved" | "error"];
|
|
}>();
|
|
|
|
// Store
|
|
const streamsStore = useStreamsStore();
|
|
const { streams } = storeToRefs(streamsStore);
|
|
|
|
// 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 {
|
|
// Set sensible defaults for hidden fields
|
|
stream.targetPct = 0; // Will be calculated automatically later
|
|
stream.certainty = "Aspirational";
|
|
stream.payoutDelayDays = 30; // Default 30 days
|
|
stream.terms = "Net 30";
|
|
stream.revenueSharePct = 0;
|
|
stream.platformFeePct = 0;
|
|
stream.restrictions = "General";
|
|
stream.seasonalityWeights = new Array(12).fill(1);
|
|
stream.effortHoursPerMonth = 0;
|
|
|
|
streamsStore.upsertStream(stream);
|
|
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(),
|
|
name: "",
|
|
category: "games",
|
|
subcategory: "",
|
|
targetPct: 0,
|
|
targetMonthlyAmount: 0,
|
|
certainty: "Aspirational",
|
|
payoutDelayDays: 30,
|
|
terms: "Net 30",
|
|
revenueSharePct: 0,
|
|
platformFeePct: 0,
|
|
restrictions: "General",
|
|
seasonalityWeights: new Array(12).fill(1),
|
|
effortHoursPerMonth: 0,
|
|
};
|
|
|
|
streamsStore.upsertStream(newStream);
|
|
}
|
|
|
|
function removeStream(id: string) {
|
|
streamsStore.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>
|