376 lines
12 KiB
Vue
376 lines
12 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">
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-if="streams.length === 0"
|
|
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
|
|
<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-2 border-black rounded-xl bg-white shadow-md">
|
|
<!-- First row: Category and Name with delete button -->
|
|
<div class="flex gap-4 mb-4">
|
|
<UFormField label="Category" required class="flex-1">
|
|
<USelect
|
|
v-model="stream.category"
|
|
:items="categoryOptions"
|
|
size="md"
|
|
class="text-sm font-medium w-full"
|
|
@update:model-value="saveCategoryChange(stream)" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Name" required class="flex-1">
|
|
<div class="flex gap-2">
|
|
<USelectMenu
|
|
v-model="stream.name"
|
|
:items="nameOptionsByCategory[stream.category] || []"
|
|
placeholder="Select or type a source name"
|
|
creatable
|
|
searchable
|
|
size="md"
|
|
class="text-sm font-medium w-full"
|
|
@update:model-value="saveStream(stream)" />
|
|
<UButton
|
|
size="md"
|
|
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>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<!-- Second row: Amount with toggle -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<UFormField :label="stream.amountType === 'annual' ? 'Annual amount' : 'Monthly amount'" required>
|
|
<div class="flex gap-2">
|
|
<UInput
|
|
:value="stream.amountType === 'annual' ? stream.targetAnnualAmount : stream.targetMonthlyAmount"
|
|
type="text"
|
|
:placeholder="stream.amountType === 'annual' ? '60000' : '5000'"
|
|
size="md"
|
|
class="text-sm font-medium w-full"
|
|
@update:model-value="validateAndSaveAmount($event, stream)"
|
|
@blur="saveStream(stream)">
|
|
<template #leading>
|
|
<span class="text-neutral-500">{{ currencySymbol }}</span>
|
|
</template>
|
|
</UInput>
|
|
<UButtonGroup size="md">
|
|
<UButton
|
|
:variant="stream.amountType === 'monthly' ? 'solid' : 'outline'"
|
|
color="primary"
|
|
@click="switchAmountType(stream, 'monthly')"
|
|
class="text-xs">
|
|
Monthly
|
|
</UButton>
|
|
<UButton
|
|
:variant="stream.amountType === 'annual' ? 'solid' : 'outline'"
|
|
color="primary"
|
|
@click="switchAmountType(stream, 'annual')"
|
|
class="text-xs">
|
|
Annual
|
|
</UButton>
|
|
</UButtonGroup>
|
|
</div>
|
|
<p class="text-xs text-neutral-500 mt-1">
|
|
<template v-if="stream.amountType === 'annual'">
|
|
{{ currencySymbol }}{{ Math.round((stream.targetAnnualAmount || 0) / 12) }} per month
|
|
</template>
|
|
<template v-else>
|
|
{{ currencySymbol }}{{ (stream.targetMonthlyAmount || 0) * 12 }} per year
|
|
</template>
|
|
</p>
|
|
</UFormField>
|
|
</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>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useDebounceFn } from "@vueuse/core";
|
|
|
|
const emit = defineEmits<{
|
|
"save-status": [status: "saving" | "saved" | "error"];
|
|
}>();
|
|
|
|
// Store and Currency
|
|
const coop = useCoopBuilder();
|
|
const { currencySymbol } = useCurrency();
|
|
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,
|
|
targetAnnualAmount: (s.annual || (s.monthly || 0) * 12),
|
|
amountType: s.amountType || 'monthly',
|
|
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) => {
|
|
const monthly = s.amountType === 'annual'
|
|
? Math.round((s.targetAnnualAmount || 0) / 12)
|
|
: (s.targetMonthlyAmount || 0);
|
|
return sum + monthly;
|
|
}, 0)
|
|
);
|
|
|
|
// Live-write with debounce
|
|
const debouncedSave = useDebounceFn((stream: any) => {
|
|
emit("save-status", "saving");
|
|
|
|
try {
|
|
// Convert component format back to store format
|
|
const monthly = stream.amountType === 'annual'
|
|
? Math.round((stream.targetAnnualAmount || 0) / 12)
|
|
: (stream.targetMonthlyAmount || 0);
|
|
|
|
const streamData = {
|
|
id: stream.id,
|
|
label: stream.name || '',
|
|
monthly: monthly,
|
|
annual: stream.targetAnnualAmount || monthly * 12,
|
|
amountType: stream.amountType || 'monthly',
|
|
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) {
|
|
const hasValidAmount = stream.amountType === 'annual'
|
|
? stream.targetAnnualAmount >= 0
|
|
: stream.targetMonthlyAmount >= 0;
|
|
|
|
if (stream.name && stream.category && hasValidAmount) {
|
|
debouncedSave(stream);
|
|
}
|
|
}
|
|
|
|
// Save category changes immediately even without a name
|
|
function saveCategoryChange(stream: any) {
|
|
// Always save category changes immediately
|
|
saveStreamImmediate(stream);
|
|
}
|
|
|
|
// Immediate save without debounce for UI responsiveness
|
|
function saveStreamImmediate(stream: any) {
|
|
try {
|
|
// Convert component format back to store format
|
|
const monthly = stream.amountType === 'annual'
|
|
? Math.round((stream.targetAnnualAmount || 0) / 12)
|
|
: (stream.targetMonthlyAmount || 0);
|
|
|
|
const streamData = {
|
|
id: stream.id,
|
|
label: stream.name || '',
|
|
monthly: monthly,
|
|
annual: stream.targetAnnualAmount || monthly * 12,
|
|
amountType: stream.amountType || 'monthly',
|
|
category: stream.category || 'games',
|
|
certainty: stream.certainty || 'Aspirational'
|
|
};
|
|
|
|
coop.upsertStream(streamData);
|
|
} catch (error) {
|
|
console.error("Failed to save stream:", error);
|
|
}
|
|
}
|
|
|
|
// Validation function for amount
|
|
function validateAndSaveAmount(value: string, stream: any) {
|
|
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
|
const validValue = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
|
|
|
if (stream.amountType === 'annual') {
|
|
stream.targetAnnualAmount = validValue;
|
|
stream.targetMonthlyAmount = Math.round(validValue / 12);
|
|
} else {
|
|
stream.targetMonthlyAmount = validValue;
|
|
stream.targetAnnualAmount = validValue * 12;
|
|
}
|
|
|
|
saveStream(stream);
|
|
}
|
|
|
|
// Function to switch between annual and monthly
|
|
function switchAmountType(stream: any, type: 'annual' | 'monthly') {
|
|
stream.amountType = type;
|
|
|
|
// Recalculate values based on new type
|
|
if (type === 'annual') {
|
|
if (!stream.targetAnnualAmount) {
|
|
stream.targetAnnualAmount = (stream.targetMonthlyAmount || 0) * 12;
|
|
}
|
|
} else {
|
|
if (!stream.targetMonthlyAmount) {
|
|
stream.targetMonthlyAmount = Math.round((stream.targetAnnualAmount || 0) / 12);
|
|
}
|
|
}
|
|
|
|
// Save immediately without debounce for instant UI update
|
|
saveStreamImmediate(stream);
|
|
}
|
|
|
|
function addRevenueStream() {
|
|
const newStream = {
|
|
id: Date.now().toString(),
|
|
label: "",
|
|
monthly: 0,
|
|
annual: 0,
|
|
amountType: "monthly",
|
|
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>
|