app/pages/coach/skills-to-offers.vue

684 lines
No EOL
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 "015 days";
if (days <= 30) return "1530 days";
if (days <= 45) return "3045 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>