app/components/WizardRevenueStep.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>