684 lines
No EOL
27 KiB
Vue
684 lines
No EOL
27 KiB
Vue
<template>
|
||
<div class="min-h-screen bg-neutral-50 pb-24">
|
||
<div class="max-w-4xl mx-auto p-6">
|
||
<!-- Header -->
|
||
<div class="mb-8">
|
||
<div class="flex items-start justify-between">
|
||
<div>
|
||
<h1 class="text-3xl font-black text-black mb-2">
|
||
Turn skills into fair, sellable offers
|
||
</h1>
|
||
<p class="text-neutral-600">
|
||
Tell us what you're good at and who you help. We'll suggest offers that match your co-op's shared capacity.
|
||
</p>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<button
|
||
@click="skipCoach"
|
||
class="px-4 py-2 text-sm bg-neutral-50 border-2 border-neutral-300 rounded-lg text-neutral-700 hover:bg-neutral-100 hover:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||
:aria-label="'Skip coach and go to streams tab'"
|
||
>
|
||
Skip coach → Streams
|
||
</button>
|
||
<button
|
||
@click="loadSampleData"
|
||
class="px-4 py-2 text-sm bg-blue-50 border-2 border-blue-200 rounded-lg text-blue-700 hover:bg-blue-100 hover:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||
:aria-label="'Load sample data to see example offers'"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||
</svg>
|
||
Load sample data
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section A: Name your strengths -->
|
||
<section class="mb-8" aria-labelledby="strengths-heading">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<h2 id="strengths-heading" class="text-xl font-bold text-black">
|
||
A) Name your strengths
|
||
</h2>
|
||
<div class="relative group">
|
||
<button
|
||
class="w-4 h-4 text-neutral-400 hover:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-full"
|
||
aria-label="Why limit to 3 skills per member?"
|
||
>
|
||
<svg fill="currentColor" viewBox="0 0 20 20" class="w-4 h-4">
|
||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||
</svg>
|
||
</button>
|
||
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-black text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
|
||
Focus keeps offers shippable
|
||
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="text-neutral-600 mb-6">
|
||
Pick what you can reliably do as a team. We'll keep it simple.
|
||
</p>
|
||
|
||
<div class="space-y-6">
|
||
<div
|
||
v-for="member in members"
|
||
:key="member.id"
|
||
class="p-6 bg-white border-2 border-neutral-200 rounded-xl shadow-sm"
|
||
>
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h3 class="font-bold text-black">{{ member.name }}</h3>
|
||
<p v-if="member.role" class="text-sm text-neutral-600">{{ member.role }}</p>
|
||
</div>
|
||
<div class="text-sm text-neutral-500">
|
||
{{ getSelectedSkillsCount(member.id) }}/3 skills selected
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="skill in availableSkills"
|
||
:key="skill.id"
|
||
@click="toggleSkill(member.id, skill.id)"
|
||
:disabled="!canSelectSkill(member.id, skill.id)"
|
||
:class="[
|
||
'px-3 py-1.5 text-sm rounded-full border-2 transition-all duration-200',
|
||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||
isSkillSelected(member.id, skill.id)
|
||
? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700'
|
||
: canSelectSkill(member.id, skill.id)
|
||
? 'bg-white text-neutral-700 border-neutral-300 hover:border-blue-400 hover:text-blue-600'
|
||
: 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
|
||
]"
|
||
:aria-pressed="isSkillSelected(member.id, skill.id)"
|
||
:aria-label="`${isSkillSelected(member.id, skill.id) ? 'Remove' : 'Add'} ${skill.label} skill for ${member.name}`"
|
||
>
|
||
{{ skill.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Section B: Who do you help? -->
|
||
<section class="mb-8" aria-labelledby="problems-heading">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<h2 id="problems-heading" class="text-xl font-bold text-black">
|
||
B) Who do you help?
|
||
</h2>
|
||
<div class="relative group">
|
||
<button
|
||
class="w-4 h-4 text-neutral-400 hover:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-full"
|
||
aria-label="Why limit to 2 problem types?"
|
||
>
|
||
<svg fill="currentColor" viewBox="0 0 20 20" class="w-4 h-4">
|
||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||
</svg>
|
||
</button>
|
||
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-black text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
|
||
Focus keeps offers shippable
|
||
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="text-neutral-600 mb-6">
|
||
Choose the problems you can solve this month. We'll suggest time-boxed offers.
|
||
</p>
|
||
|
||
<div class="flex flex-wrap gap-3">
|
||
<div
|
||
v-for="problem in availableProblems"
|
||
:key="problem.id"
|
||
class="relative"
|
||
>
|
||
<button
|
||
@click="toggleProblem(problem.id)"
|
||
:disabled="!canSelectProblem(problem.id)"
|
||
:class="[
|
||
'px-4 py-2 text-sm rounded-lg border-2 transition-all duration-200',
|
||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||
isProblemSelected(problem.id)
|
||
? 'bg-green-600 text-white border-green-600 hover:bg-green-700'
|
||
: canSelectProblem(problem.id)
|
||
? 'bg-white text-neutral-700 border-neutral-300 hover:border-green-400 hover:text-green-600'
|
||
: 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
|
||
]"
|
||
:aria-pressed="isProblemSelected(problem.id)"
|
||
:aria-label="`${isProblemSelected(problem.id) ? 'Remove' : 'Add'} ${problem.label} problem type`"
|
||
>
|
||
{{ problem.label }}
|
||
</button>
|
||
|
||
<!-- Examples popover trigger -->
|
||
<button
|
||
@click="toggleExamples(problem.id)"
|
||
@keydown.escape="hideExamples"
|
||
class="ml-1 text-xs text-neutral-500 hover:text-neutral-700 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
|
||
:aria-label="`See examples for ${problem.label}`"
|
||
:aria-expanded="showExamples === problem.id"
|
||
>
|
||
see examples
|
||
</button>
|
||
|
||
<!-- Examples popover -->
|
||
<div
|
||
v-if="showExamples === problem.id"
|
||
class="absolute z-10 mt-2 p-3 bg-white border-2 border-neutral-200 rounded-lg shadow-lg min-w-64 max-w-sm"
|
||
role="tooltip"
|
||
:aria-label="`Examples for ${problem.label}`"
|
||
>
|
||
<div class="text-sm">
|
||
<p class="font-medium text-black mb-2">Examples:</p>
|
||
<ul class="space-y-1 text-neutral-700">
|
||
<li v-for="example in problem.examples" :key="example" class="flex items-start">
|
||
<span class="text-neutral-400 mr-2">•</span>
|
||
<span>{{ example }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<button
|
||
@click="hideExamples"
|
||
class="mt-2 text-xs text-blue-600 hover:text-blue-800 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
|
||
aria-label="Close examples"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 text-sm text-neutral-500">
|
||
{{ selectedProblems.length }}/2 problem types selected
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Section C: Suggested offers -->
|
||
<section class="mb-8" aria-labelledby="offers-heading">
|
||
<h2 id="offers-heading" class="text-xl font-bold text-black mb-4">
|
||
C) Suggested offers
|
||
</h2>
|
||
|
||
<!-- Loading state -->
|
||
<div
|
||
v-if="loading"
|
||
class="text-center py-12 bg-white border-2 border-dashed border-blue-200 rounded-xl"
|
||
>
|
||
<div class="max-w-md mx-auto">
|
||
<div class="w-16 h-16 mx-auto mb-4 bg-blue-50 rounded-full flex items-center justify-center">
|
||
<svg class="w-8 h-8 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
</div>
|
||
<h3 class="font-medium text-blue-900 mb-2">Generating offers...</h3>
|
||
<p class="text-blue-700">
|
||
Creating personalized revenue suggestions based on your selections.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Empty state -->
|
||
<div
|
||
v-else-if="suggestedOffers.length === 0"
|
||
class="text-center py-12 bg-white border-2 border-dashed border-neutral-300 rounded-xl"
|
||
>
|
||
<div class="max-w-md mx-auto">
|
||
<div class="w-16 h-16 mx-auto mb-4 bg-neutral-100 rounded-full flex items-center justify-center">
|
||
<svg class="w-8 h-8 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||
</svg>
|
||
</div>
|
||
<h3 class="font-medium text-neutral-900 mb-2">No offers yet</h3>
|
||
<p class="text-neutral-600 mb-4">
|
||
Pick a few skills and a problem—we'll suggest something you can sell this month.
|
||
</p>
|
||
<p class="text-sm text-neutral-500">
|
||
We need at least one shared skill and one problem type to suggest offers.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Offer cards -->
|
||
<div v-else class="grid gap-6 md:grid-cols-2">
|
||
<div
|
||
v-for="offer in suggestedOffers"
|
||
:key="offer.id"
|
||
class="p-6 bg-white border-2 border-neutral-200 rounded-xl shadow-sm hover:shadow-md transition-shadow"
|
||
role="article"
|
||
:aria-label="`Offer: ${offer.name}`"
|
||
>
|
||
<h3 class="font-bold text-black mb-3">{{ offer.name }}</h3>
|
||
|
||
<!-- Offer chips -->
|
||
<div class="flex flex-wrap gap-2 mb-4">
|
||
<span class="inline-flex items-center px-2 py-1 text-xs bg-green-50 text-green-700 border border-green-200 rounded-full">
|
||
Covers ~{{ calculateMonthlyCoverage(offer) }}% of monthly needs at baseline
|
||
</span>
|
||
<span class="inline-flex items-center px-2 py-1 text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded-full">
|
||
Typical payout: {{ getPayoutDaysRange(offer) }}
|
||
</span>
|
||
<span class="inline-flex items-center px-2 py-1 text-xs bg-purple-50 text-purple-700 border border-purple-200 rounded-full">
|
||
Why this
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Scope -->
|
||
<div class="mb-4">
|
||
<p class="text-sm font-medium text-neutral-700 mb-2">Scope:</p>
|
||
<ul class="space-y-1">
|
||
<li
|
||
v-for="item in offer.scope"
|
||
:key="item"
|
||
class="text-sm text-neutral-600 flex items-start"
|
||
>
|
||
<span class="text-neutral-400 mr-2">•</span>
|
||
<span>{{ item }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Price range -->
|
||
<div class="mb-4 p-3 bg-neutral-50 rounded-lg">
|
||
<div class="flex justify-between items-center mb-1">
|
||
<span class="text-sm font-medium text-neutral-700">Baseline:</span>
|
||
<span class="font-bold text-black">${{ offer.price.baseline.toLocaleString() }}</span>
|
||
</div>
|
||
<div class="flex justify-between items-center mb-2">
|
||
<span class="text-sm font-medium text-neutral-700">Stretch:</span>
|
||
<span class="font-bold text-green-600">${{ offer.price.stretch.toLocaleString() }}</span>
|
||
</div>
|
||
<p class="text-xs text-neutral-500">{{ offer.price.calcNote }}</p>
|
||
</div>
|
||
|
||
<!-- Payout delay -->
|
||
<div class="mb-4 flex items-center justify-between text-sm">
|
||
<span class="text-neutral-600">Payment timing:</span>
|
||
<span class="font-medium text-black">{{ offer.payoutDelayDays }} days</span>
|
||
</div>
|
||
|
||
<!-- Why this works -->
|
||
<div class="mb-4">
|
||
<p class="text-sm font-medium text-neutral-700 mb-2">Why this works for your co-op:</p>
|
||
<ul class="space-y-1">
|
||
<li
|
||
v-for="reason in offer.whyThis"
|
||
:key="reason"
|
||
class="text-sm text-neutral-600 flex items-start"
|
||
>
|
||
<span class="text-green-500 mr-2">✓</span>
|
||
<span>{{ updateLanguageToCoopTerms(reason) }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Risk notes (if any) -->
|
||
<div v-if="offer.riskNotes.length > 0" class="border-t border-neutral-200 pt-3">
|
||
<p class="text-sm font-medium text-amber-700 mb-2">Consider:</p>
|
||
<ul class="space-y-1">
|
||
<li
|
||
v-for="risk in offer.riskNotes"
|
||
:key="risk"
|
||
class="text-sm text-amber-600 flex items-start"
|
||
>
|
||
<span class="text-amber-500 mr-2">⚠</span>
|
||
<span>{{ risk }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- Sticky Footer -->
|
||
<div class="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-neutral-200 shadow-lg">
|
||
<div class="max-w-4xl mx-auto p-4">
|
||
<div class="flex items-center justify-between">
|
||
<button
|
||
@click="goBack"
|
||
class="px-4 py-2 text-neutral-700 hover:text-black focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-lg transition-colors"
|
||
aria-label="Go back to previous page"
|
||
>
|
||
← Back
|
||
</button>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<button
|
||
@click="regenerateOffers"
|
||
:disabled="!canRegenerate"
|
||
:class="[
|
||
'px-4 py-2 rounded-lg border-2 transition-all duration-200',
|
||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||
canRegenerate
|
||
? 'border-neutral-300 text-neutral-700 hover:border-blue-400 hover:text-blue-600'
|
||
: 'border-neutral-200 text-neutral-400 cursor-not-allowed'
|
||
]"
|
||
:aria-label="canRegenerate ? 'Regenerate offers with current selections' : 'Cannot regenerate - select skills and problems first'"
|
||
>
|
||
🔄 Regenerate
|
||
</button>
|
||
|
||
<button
|
||
@click="useOffers"
|
||
:disabled="suggestedOffers.length === 0"
|
||
:class="[
|
||
'px-6 py-2 rounded-lg font-medium transition-all duration-200',
|
||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||
suggestedOffers.length > 0
|
||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||
: 'bg-neutral-200 text-neutral-400 cursor-not-allowed'
|
||
]"
|
||
:aria-label="suggestedOffers.length > 0 ? 'Add these offers to cover co-op needs' : 'No offers to use - generate offers first'"
|
||
>
|
||
Add to plan
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
|
||
import { useDebounceFn } from "@vueuse/core";
|
||
import {
|
||
membersSample,
|
||
skillsCatalogSample,
|
||
problemsCatalogSample,
|
||
sampleSelections
|
||
} from "~/sample/skillsToOffersSamples";
|
||
|
||
// Store integration
|
||
const planStore = usePlanStore();
|
||
|
||
// Initialize with default data
|
||
const members = ref<Member[]>([
|
||
{ id: "1", name: "Alex Chen", role: "Game Designer", hourly: 75, availableHrs: 30 },
|
||
{ id: "2", name: "Jordan Smith", role: "Developer", hourly: 80, availableHrs: 35 },
|
||
{ id: "3", name: "Sam Rodriguez", role: "Artist", hourly: 70, availableHrs: 25 }
|
||
]);
|
||
|
||
const availableSkills = ref<SkillTag[]>([
|
||
{ id: "unity", label: "Unity Development" },
|
||
{ id: "art", label: "2D/3D Art" },
|
||
{ id: "design", label: "Game Design" },
|
||
{ id: "audio", label: "Audio Design" },
|
||
{ id: "writing", label: "Narrative Writing" },
|
||
{ id: "marketing", label: "Marketing" },
|
||
{ id: "business", label: "Business Strategy" },
|
||
{ id: "web", label: "Web Development" },
|
||
{ id: "mobile", label: "Mobile Development" },
|
||
{ id: "consulting", label: "Technical Consulting" }
|
||
]);
|
||
|
||
const availableProblems = ref<ProblemTag[]>([
|
||
{
|
||
id: "indie-games",
|
||
label: "Indie game development",
|
||
examples: [
|
||
"Small studios needing extra development capacity",
|
||
"Solo developers wanting art/audio support",
|
||
"Teams needing game design consultation"
|
||
]
|
||
},
|
||
{
|
||
id: "corporate-training",
|
||
label: "Corporate training games",
|
||
examples: [
|
||
"Companies wanting engaging employee training",
|
||
"HR departments needing onboarding tools",
|
||
"Safety training for industrial workers"
|
||
]
|
||
},
|
||
{
|
||
id: "educational",
|
||
label: "Educational technology",
|
||
examples: [
|
||
"Schools needing interactive learning tools",
|
||
"Universities wanting research simulations",
|
||
"Non-profits creating awareness campaigns"
|
||
]
|
||
},
|
||
{
|
||
id: "prototypes",
|
||
label: "Rapid prototyping",
|
||
examples: [
|
||
"Startups validating game concepts",
|
||
"Publishers testing market fit",
|
||
"Researchers creating proof-of-concepts"
|
||
]
|
||
}
|
||
]);
|
||
|
||
// Set members in store on component mount
|
||
onMounted(() => {
|
||
planStore.setMembers(members.value);
|
||
});
|
||
|
||
// Reactive state
|
||
const selectedSkills = ref<Record<string, string[]>>({});
|
||
const selectedProblems = ref<string[]>([]);
|
||
const showExamples = ref<string | null>(null);
|
||
const offers = ref<Offer[] | null>(null);
|
||
const loading = ref(false);
|
||
|
||
// Use offer suggestor composable
|
||
const { suggestOffers } = useOfferSuggestor();
|
||
|
||
// Catalogs for the suggestor
|
||
const catalogs = computed(() => ({
|
||
skills: availableSkills.value,
|
||
problems: availableProblems.value
|
||
}));
|
||
|
||
// Computed for suggested offers (for backward compatibility)
|
||
const suggestedOffers = computed(() => offers.value || []);
|
||
|
||
// Helper functions for offer chips
|
||
function calculateMonthlyCoverage(offer: Offer): number {
|
||
// Estimate monthly burn (simplified calculation)
|
||
const totalMemberHours = members.value.reduce((sum, m) => sum + m.availableHrs, 0);
|
||
const avgHourlyRate = members.value.reduce((sum, m) => sum + m.hourly, 0) / members.value.length;
|
||
const estimatedMonthlyBurn = totalMemberHours * avgHourlyRate * 1.25; // Add on-costs
|
||
|
||
return Math.round((offer.price.baseline / estimatedMonthlyBurn) * 100);
|
||
}
|
||
|
||
function getPayoutDaysRange(offer: Offer): string {
|
||
const days = offer.payoutDelayDays;
|
||
if (days <= 15) return "0–15 days";
|
||
if (days <= 30) return "15–30 days";
|
||
if (days <= 45) return "30–45 days";
|
||
return `${days} days`;
|
||
}
|
||
|
||
function updateLanguageToCoopTerms(text: string): string {
|
||
return text
|
||
.replace(/maximize|maximiz/gi, 'cover needs with')
|
||
.replace(/optimize|optimiz/gi, 'improve')
|
||
.replace(/competitive advantage/gi, 'shared capacity')
|
||
.replace(/market position/gi, 'community standing')
|
||
.replace(/profit/gi, 'surplus')
|
||
.replace(/revenue growth/gi, 'sustainable income')
|
||
.replace(/scale/gi, 'grow together')
|
||
.replace(/efficiency gains/gi, 'reduce risk')
|
||
.replace(/leverages/gi, 'uses')
|
||
.replace(/expertise/gi, 'shared skills')
|
||
.replace(/builds reputation/gi, 'builds trust in community')
|
||
.replace(/high-impact/gi, 'meaningful')
|
||
.replace(/productivity/gi, 'shared capacity');
|
||
}
|
||
|
||
// Sample data loading
|
||
function loadSampleData() {
|
||
// Replace data with samples
|
||
members.value = [...membersSample];
|
||
availableSkills.value = [...skillsCatalogSample];
|
||
availableProblems.value = [...problemsCatalogSample];
|
||
|
||
// Set pre-selected skills and problems
|
||
selectedSkills.value = { ...sampleSelections.selectedSkillsByMember };
|
||
selectedProblems.value = [...sampleSelections.selectedProblems];
|
||
|
||
// Update store with new members
|
||
planStore.setMembers(members.value);
|
||
|
||
// Trigger offer generation immediately
|
||
nextTick(() => {
|
||
debouncedGenerateOffers();
|
||
});
|
||
}
|
||
|
||
// Debounced offer generation
|
||
const debouncedGenerateOffers = useDebounceFn(async () => {
|
||
const hasSkills = Object.values(selectedSkills.value).some(skills => skills.length > 0);
|
||
const hasProblems = selectedProblems.value.length > 0;
|
||
|
||
if (!hasSkills || !hasProblems) {
|
||
offers.value = null;
|
||
return;
|
||
}
|
||
|
||
loading.value = true;
|
||
|
||
try {
|
||
const input = {
|
||
members: members.value,
|
||
selectedSkillsByMember: selectedSkills.value,
|
||
selectedProblems: selectedProblems.value
|
||
};
|
||
|
||
const suggestedOffers = suggestOffers(input, catalogs.value);
|
||
offers.value = suggestedOffers;
|
||
} catch (error) {
|
||
console.error('Failed to generate offers:', error);
|
||
offers.value = null;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}, 300);
|
||
|
||
// Skill management
|
||
function toggleSkill(memberId: string, skillId: string) {
|
||
if (!selectedSkills.value[memberId]) {
|
||
selectedSkills.value[memberId] = [];
|
||
}
|
||
|
||
const memberSkills = selectedSkills.value[memberId];
|
||
const index = memberSkills.indexOf(skillId);
|
||
|
||
if (index >= 0) {
|
||
memberSkills.splice(index, 1);
|
||
} else {
|
||
memberSkills.push(skillId);
|
||
}
|
||
|
||
debouncedGenerateOffers();
|
||
}
|
||
|
||
function isSkillSelected(memberId: string, skillId: string): boolean {
|
||
return selectedSkills.value[memberId]?.includes(skillId) || false;
|
||
}
|
||
|
||
function canSelectSkill(memberId: string, skillId: string): boolean {
|
||
if (isSkillSelected(memberId, skillId)) return true;
|
||
return getSelectedSkillsCount(memberId) < 3;
|
||
}
|
||
|
||
function getSelectedSkillsCount(memberId: string): number {
|
||
return selectedSkills.value[memberId]?.length || 0;
|
||
}
|
||
|
||
// Problem management
|
||
function toggleProblem(problemId: string) {
|
||
const index = selectedProblems.value.indexOf(problemId);
|
||
if (index >= 0) {
|
||
selectedProblems.value.splice(index, 1);
|
||
} else {
|
||
selectedProblems.value.push(problemId);
|
||
}
|
||
debouncedGenerateOffers();
|
||
}
|
||
|
||
function isProblemSelected(problemId: string): boolean {
|
||
return selectedProblems.value.includes(problemId);
|
||
}
|
||
|
||
function canSelectProblem(problemId: string): boolean {
|
||
if (isProblemSelected(problemId)) return true;
|
||
return selectedProblems.value.length < 2;
|
||
}
|
||
|
||
// Examples popover
|
||
function toggleExamples(problemId: string) {
|
||
showExamples.value = showExamples.value === problemId ? null : problemId;
|
||
}
|
||
|
||
function hideExamples() {
|
||
showExamples.value = null;
|
||
}
|
||
|
||
|
||
// Footer actions
|
||
const canRegenerate = computed(() => {
|
||
const hasSkills = Object.values(selectedSkills.value).some(skills => skills.length > 0);
|
||
const hasProblems = selectedProblems.value.length > 0;
|
||
return hasSkills && hasProblems;
|
||
});
|
||
|
||
function goBack() {
|
||
// Navigate back - would typically use router
|
||
window.history.back();
|
||
}
|
||
|
||
function regenerateOffers() {
|
||
if (canRegenerate.value) {
|
||
// Re-call suggestOffers with same inputs
|
||
debouncedGenerateOffers();
|
||
}
|
||
}
|
||
|
||
function useOffers() {
|
||
if (offers.value && offers.value.length > 0) {
|
||
// Add offers to plan store as streams
|
||
planStore.addStreamsFromOffers(offers.value);
|
||
|
||
// Navigate back to wizard with success message
|
||
const router = useRouter();
|
||
|
||
// Show success notification
|
||
console.log(`Added ${offers.value.length} offers as revenue streams to your plan.`);
|
||
|
||
// Navigate to wizard revenue step - adjust path as needed for your routing
|
||
router.push('/wizards'); // This would need to be the correct wizard path
|
||
|
||
// Note: The Streams tab activation would be handled by the wizard component
|
||
// when it detects new streams in the store
|
||
}
|
||
}
|
||
|
||
function skipCoach() {
|
||
// Navigate directly to wizard streams without adding offers
|
||
const router = useRouter();
|
||
router.push('/wizards'); // Navigate to wizard - streams tab would be activated there
|
||
}
|
||
|
||
// Close examples on click outside
|
||
onMounted(() => {
|
||
const handleClickOutside = (event: Event) => {
|
||
const target = event.target as HTMLElement;
|
||
if (!target.closest('[role="tooltip"]') && !target.closest('button[aria-expanded]')) {
|
||
showExamples.value = null;
|
||
}
|
||
};
|
||
|
||
document.addEventListener('click', handleClickOutside);
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('click', handleClickOutside);
|
||
});
|
||
});
|
||
</script> |