feat: first draft
This commit is contained in:
124
components/core/FourActions.tsx
Normal file
124
components/core/FourActions.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@components/ui/card';
|
||||
import { Button } from '@components/ui/button';
|
||||
import { Input } from '@components/ui/input';
|
||||
import { useGlobalState } from '@contexts/state/StateContext';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
|
||||
const FourActions = () => {
|
||||
const { state, dispatch } = useGlobalState();
|
||||
const [newItems, setNewItems] = useState({
|
||||
eliminate: '',
|
||||
reduce: '',
|
||||
raise: '',
|
||||
create: '',
|
||||
});
|
||||
|
||||
const addItem = (actionType: keyof typeof newItems) => {
|
||||
if (!newItems[actionType]) return;
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_ACTION',
|
||||
payload: {
|
||||
actionType,
|
||||
value: newItems[actionType],
|
||||
},
|
||||
});
|
||||
setNewItems((prev) => ({ ...prev, [actionType]: '' }));
|
||||
};
|
||||
|
||||
const removeItem = (
|
||||
actionType: keyof typeof state.fourActions,
|
||||
index: number
|
||||
) => {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
...state,
|
||||
fourActions: {
|
||||
...state.fourActions,
|
||||
[actionType]: state.fourActions[actionType].filter(
|
||||
(_, i) => i !== index
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderActionSection = (
|
||||
title: string,
|
||||
actionType: keyof typeof state.fourActions,
|
||||
description: string,
|
||||
colorClasses: string
|
||||
) => (
|
||||
<Card className={`${colorClasses} border-2`}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">{description}</p>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder={`Add ${title.toLowerCase()} factor...`}
|
||||
value={newItems[actionType]}
|
||||
onChange={(e) =>
|
||||
setNewItems((prev) => ({ ...prev, [actionType]: e.target.value }))
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={() => addItem(actionType)} variant="secondary">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.fourActions[actionType].map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-2 bg-background rounded-lg border"
|
||||
>
|
||||
<span className="flex-1">{item}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeItem(actionType, index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{renderActionSection(
|
||||
'Eliminate',
|
||||
'eliminate',
|
||||
'Which factors should be eliminated?',
|
||||
'border-red-500/50 dark:border-red-500/30 bg-red-50/50 dark:bg-red-950/10'
|
||||
)}
|
||||
{renderActionSection(
|
||||
'Reduce',
|
||||
'reduce',
|
||||
'Which factors should be reduced well below the industry standard?',
|
||||
'border-yellow-500/50 dark:border-yellow-500/30 bg-yellow-50/50 dark:bg-yellow-950/10'
|
||||
)}
|
||||
{renderActionSection(
|
||||
'Raise',
|
||||
'raise',
|
||||
'Which factors should be raised well above the industry standard?',
|
||||
'border-green-500/50 dark:border-green-500/30 bg-green-50/50 dark:bg-green-950/10'
|
||||
)}
|
||||
{renderActionSection(
|
||||
'Create',
|
||||
'create',
|
||||
'Which factors should be created that the industry has never offered?',
|
||||
'border-blue-500/50 dark:border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/10'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FourActions;
|
||||
301
components/core/PriceCorridor.tsx
Normal file
301
components/core/PriceCorridor.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@components/ui/card';
|
||||
import { Button } from '@components/ui/button';
|
||||
import { Input } from '@components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@components/ui/select';
|
||||
import { useGlobalState } from '@contexts/state/StateContext';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
const PriceCorridor = () => {
|
||||
const { state, dispatch } = useGlobalState();
|
||||
const [newCompetitor, setNewCompetitor] = useState<{
|
||||
name: string;
|
||||
price: number;
|
||||
category: 'same-form' | 'different-form' | 'different-function';
|
||||
}>({
|
||||
name: '',
|
||||
price: 0,
|
||||
category: 'same-form',
|
||||
});
|
||||
|
||||
const setTargetPrice = (price: number) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_TARGET_PRICE',
|
||||
payload: price,
|
||||
});
|
||||
};
|
||||
|
||||
const addCompetitor = () => {
|
||||
if (!newCompetitor.name || !newCompetitor.price) return;
|
||||
dispatch({
|
||||
type: 'ADD_COMPETITOR',
|
||||
payload: {
|
||||
name: newCompetitor.name,
|
||||
price: newCompetitor.price,
|
||||
category: newCompetitor.category,
|
||||
},
|
||||
});
|
||||
setNewCompetitor({ name: '', price: 0, category: 'same-form' });
|
||||
};
|
||||
|
||||
const removeCompetitor = (index: number) => {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
...state,
|
||||
priceCorridor: {
|
||||
...state.priceCorridor,
|
||||
competitors: state.priceCorridor.competitors.filter(
|
||||
(_, i) => i !== index
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const sortedCompetitors = [...state.priceCorridor.competitors].sort(
|
||||
(a, b) => a.price - b.price
|
||||
);
|
||||
|
||||
const prices = sortedCompetitors.map((c) => c.price);
|
||||
const upperBound = Math.max(...prices, 0);
|
||||
const lowerBound = Math.min(...prices, 0);
|
||||
const range = upperBound - lowerBound;
|
||||
|
||||
const corridorLower = lowerBound + range * 0.3;
|
||||
const corridorUpper = lowerBound + range * 0.8;
|
||||
|
||||
const chartData = sortedCompetitors.map((comp) => ({
|
||||
name: comp.name,
|
||||
price: comp.price,
|
||||
category: comp.category,
|
||||
}));
|
||||
|
||||
const categorizedCompetitors = {
|
||||
'same-form': sortedCompetitors.filter((c) => c.category === 'same-form'),
|
||||
'different-form': sortedCompetitors.filter(
|
||||
(c) => c.category === 'different-form'
|
||||
),
|
||||
'different-function': sortedCompetitors.filter(
|
||||
(c) => c.category === 'different-function'
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Price Corridor of the Mass</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px] w-full flex justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%" maxHeight={400}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 110, left: 50, bottom: 20 }}
|
||||
className="[&_.recharts-cartesian-grid-horizontal]:stroke-muted [&_.recharts-cartesian-grid-vertical]:stroke-muted [&_.recharts-cartesian-axis-line]:stroke-muted [&_.recharts-cartesian-axis-tick-line]:stroke-muted [&_.recharts-cartesian-axis-tick-value]:fill-foreground"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" stroke="currentColor" />
|
||||
<YAxis stroke="currentColor" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--background)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--foreground)',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="price"
|
||||
stroke="hsl(var(--primary))"
|
||||
/>
|
||||
{state.priceCorridor.targetPrice > 0 && (
|
||||
<ReferenceLine
|
||||
y={state.priceCorridor.targetPrice}
|
||||
stroke="hsl(var(--destructive))"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: 'Target Price',
|
||||
position: 'insideRight',
|
||||
fill: 'hsl(var(--destructive))',
|
||||
offset: 100,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{chartData.length > 0 && (
|
||||
<ReferenceArea
|
||||
y1={corridorLower}
|
||||
y2={corridorUpper}
|
||||
fill="hsl(var(--primary))"
|
||||
fillOpacity={0.1}
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: 'Price Corridor',
|
||||
position: 'insideRight',
|
||||
offset: 90,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Strategic Price</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{chartData.length > 0 && (
|
||||
<div className="text-sm space-y-2">
|
||||
<p>
|
||||
Suggested price corridor: {corridorLower.toFixed(2)} -{' '}
|
||||
{corridorUpper.toFixed(2)}
|
||||
</p>
|
||||
<p>This range typically captures 70-80% of target buyers</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
type="number"
|
||||
value={state.priceCorridor.targetPrice || ''}
|
||||
onChange={(e) => setTargetPrice(Number(e.target.value))}
|
||||
placeholder="Set target price..."
|
||||
className="w-48"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Current target: {state.priceCorridor.targetPrice || 'Not set'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Alternative</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={newCompetitor.name}
|
||||
onChange={(e) =>
|
||||
setNewCompetitor((prev) => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Price"
|
||||
value={newCompetitor.price || ''}
|
||||
onChange={(e) =>
|
||||
setNewCompetitor((prev) => ({
|
||||
...prev,
|
||||
price: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="w-32"
|
||||
/>
|
||||
<Select
|
||||
value={
|
||||
newCompetitor.category as
|
||||
| 'same-form'
|
||||
| 'different-form'
|
||||
| 'different-function'
|
||||
}
|
||||
onValueChange={(
|
||||
value: 'same-form' | 'different-form' | 'different-function'
|
||||
) => setNewCompetitor((prev) => ({ ...prev, category: value }))}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="same-form">Same Form</SelectItem>
|
||||
<SelectItem value="different-form">Different Form</SelectItem>
|
||||
<SelectItem value="different-function">
|
||||
Different Function
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={addCompetitor}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Price Alternatives Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{Object.entries(categorizedCompetitors).map(
|
||||
([category, competitors]) => (
|
||||
<div key={category} className="space-y-2">
|
||||
<h3 className="font-semibold capitalize">
|
||||
{category.replace('-', ' ')} Alternatives
|
||||
</h3>
|
||||
{competitors.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{competitors.map((comp, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-2 bg-muted rounded-lg"
|
||||
>
|
||||
<span className="flex-1">{comp.name}</span>
|
||||
<span className="w-32 text-right">{comp.price}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeCompetitor(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No alternatives added
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriceCorridor;
|
||||
156
components/core/SixPaths.tsx
Normal file
156
components/core/SixPaths.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@components/ui/card';
|
||||
import { Button } from '@components/ui/button';
|
||||
import { Input } from '@components/ui/input';
|
||||
import { Textarea } from '@components/ui/textarea';
|
||||
import { useGlobalState } from '@contexts/state/StateContext';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import type { PathType } from '@utils/types';
|
||||
|
||||
const pathDefinitions = {
|
||||
industries: {
|
||||
title: 'Alternative Industries',
|
||||
description: 'Look across substitute and alternative industries',
|
||||
},
|
||||
groups: {
|
||||
title: 'Strategic Groups',
|
||||
description: 'Look across strategic groups within your industry',
|
||||
},
|
||||
buyers: {
|
||||
title: 'Buyer Groups',
|
||||
description: 'Look across chain of buyers',
|
||||
},
|
||||
complementary: {
|
||||
title: 'Complementary Products',
|
||||
description: 'Look across complementary product and service offerings',
|
||||
},
|
||||
functional: {
|
||||
title: 'Functional/Emotional Appeal',
|
||||
description: 'Look across functional or emotional appeal to buyers',
|
||||
},
|
||||
trends: {
|
||||
title: 'Time Trends',
|
||||
description: 'Look across time and market trends',
|
||||
},
|
||||
};
|
||||
|
||||
const SixPaths = () => {
|
||||
const { state, dispatch } = useGlobalState();
|
||||
const [newOpportunities, setNewOpportunities] = useState<
|
||||
Record<PathType, string>
|
||||
>({
|
||||
industries: '',
|
||||
groups: '',
|
||||
buyers: '',
|
||||
complementary: '',
|
||||
functional: '',
|
||||
trends: '',
|
||||
});
|
||||
|
||||
const addOpportunity = (pathType: PathType) => {
|
||||
if (!newOpportunities[pathType]) return;
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_OPPORTUNITY',
|
||||
payload: {
|
||||
pathType,
|
||||
value: newOpportunities[pathType],
|
||||
},
|
||||
});
|
||||
setNewOpportunities((prev) => ({ ...prev, [pathType]: '' }));
|
||||
};
|
||||
|
||||
const removeOpportunity = (pathType: PathType, index: number) => {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
...state,
|
||||
sixPaths: {
|
||||
...state.sixPaths,
|
||||
[pathType]: {
|
||||
...state.sixPaths[pathType],
|
||||
opportunities: state.sixPaths[pathType].opportunities.filter(
|
||||
(_, i) => i !== index
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateNotes = (pathType: PathType, notes: string) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_PATH_NOTES',
|
||||
payload: { pathType, notes },
|
||||
});
|
||||
};
|
||||
|
||||
const renderPath = (pathType: PathType) => {
|
||||
const { title, description } = pathDefinitions[pathType];
|
||||
|
||||
return (
|
||||
<Card key={pathType}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-4">{description}</p>
|
||||
|
||||
<Textarea
|
||||
placeholder="Add notes about this path..."
|
||||
value={state.sixPaths[pathType].notes}
|
||||
onChange={(e) => updateNotes(pathType, e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder="Add opportunity..."
|
||||
value={newOpportunities[pathType]}
|
||||
onChange={(e) =>
|
||||
setNewOpportunities((prev) => ({
|
||||
...prev,
|
||||
[pathType]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={() => addOpportunity(pathType)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{state.sixPaths[pathType].opportunities.map(
|
||||
(opportunity, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-2 bg-background rounded-lg"
|
||||
>
|
||||
<span className="flex-1">{opportunity}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeOpportunity(pathType, index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{(Object.keys(pathDefinitions) as PathType[]).map((pathType) =>
|
||||
renderPath(pathType)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixPaths;
|
||||
386
components/core/StrategyCanvas.tsx
Normal file
386
components/core/StrategyCanvas.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
TooltipProps,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@components/ui/card';
|
||||
import { Button } from '@components/ui/button';
|
||||
import { Input } from '@components/ui/input';
|
||||
import { Textarea } from '@components/ui/textarea';
|
||||
import { useGlobalState } from '@contexts/state/StateContext';
|
||||
import { Trash2, Plus, MoveUp, MoveDown } from 'lucide-react';
|
||||
import {
|
||||
NameType,
|
||||
ValueType,
|
||||
} from 'recharts/types/component/DefaultTooltipContent';
|
||||
|
||||
interface Factor {
|
||||
id: string;
|
||||
name: string;
|
||||
marketScore: number;
|
||||
ideaScore: number;
|
||||
}
|
||||
|
||||
interface NewFactor {
|
||||
name: string;
|
||||
marketScore: number;
|
||||
ideaScore: number;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
name: string;
|
||||
Market: number;
|
||||
'Your Idea': number;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
interface CustomTooltipProps extends TooltipProps<ValueType, NameType> {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const StrategyCanvas: React.FC = () => {
|
||||
const { state, dispatch } = useGlobalState();
|
||||
const [newFactor, setNewFactor] = useState<NewFactor>({
|
||||
name: '',
|
||||
marketScore: 5,
|
||||
ideaScore: 5,
|
||||
});
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const validateFactor = (factor: NewFactor): boolean => {
|
||||
if (!factor.name.trim()) {
|
||||
setError('Factor name is required');
|
||||
return false;
|
||||
}
|
||||
if (factor.marketScore < 0 || factor.marketScore > 10) {
|
||||
setError('Market score must be between 0 and 10');
|
||||
return false;
|
||||
}
|
||||
if (factor.ideaScore < 0 || factor.ideaScore > 10) {
|
||||
setError('Idea score must be between 0 and 10');
|
||||
return false;
|
||||
}
|
||||
setError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const addFactor = useCallback((): void => {
|
||||
if (!validateFactor(newFactor)) return;
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_FACTOR',
|
||||
payload: {
|
||||
id: `factor-${Date.now()}`,
|
||||
...newFactor,
|
||||
},
|
||||
});
|
||||
setNewFactor({ name: '', marketScore: 5, ideaScore: 5 });
|
||||
}, [newFactor, dispatch]);
|
||||
|
||||
const moveFactor = useCallback(
|
||||
(id: string, direction: 'up' | 'down'): void => {
|
||||
const factors = [...state.strategyCanvas.factors];
|
||||
const index = factors.findIndex((f) => f.id === id);
|
||||
if (
|
||||
(direction === 'up' && index === 0) ||
|
||||
(direction === 'down' && index === factors.length - 1)
|
||||
)
|
||||
return;
|
||||
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
[factors[index], factors[newIndex]] = [factors[newIndex], factors[index]];
|
||||
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
...state,
|
||||
strategyCanvas: {
|
||||
...state.strategyCanvas,
|
||||
factors,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[state, dispatch]
|
||||
);
|
||||
|
||||
const updateFactorField = useCallback(
|
||||
(id: string, field: keyof Factor, value: string | number): void => {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
...state,
|
||||
strategyCanvas: {
|
||||
...state.strategyCanvas,
|
||||
factors: state.strategyCanvas.factors.map((f) =>
|
||||
f.id === id
|
||||
? {
|
||||
...f,
|
||||
[field]: field === 'name' ? value : Number(value),
|
||||
}
|
||||
: f
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[state, dispatch]
|
||||
);
|
||||
|
||||
const updateNotes = useCallback(
|
||||
(id: string, notes: string): void => {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
...state,
|
||||
strategyCanvas: {
|
||||
...state.strategyCanvas,
|
||||
notes: {
|
||||
...state.strategyCanvas.notes,
|
||||
[id]: notes,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[state, dispatch]
|
||||
);
|
||||
|
||||
const deleteFactor = useCallback(
|
||||
(id: string): void => {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
...state,
|
||||
strategyCanvas: {
|
||||
...state.strategyCanvas,
|
||||
factors: state.strategyCanvas.factors.filter((f) => f.id !== id),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[state, dispatch]
|
||||
);
|
||||
|
||||
const chartData: ChartDataPoint[] = state.strategyCanvas.factors.map((f) => ({
|
||||
name: f.name,
|
||||
Market: f.marketScore,
|
||||
'Your Idea': f.ideaScore,
|
||||
tooltip: `${f.name}\nMarket: ${f.marketScore}\nYour Idea: ${f.ideaScore}`,
|
||||
}));
|
||||
|
||||
const CustomTooltip: React.FC<CustomTooltipProps> = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background border rounded p-2 shadow-lg">
|
||||
<p className="font-medium">{label}</p>
|
||||
{payload.map((entry, index) => (
|
||||
<p key={index} style={{ color: entry.color }}>
|
||||
{entry.name}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Strategy Canvas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-96">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
className="[&_.recharts-cartesian-grid-horizontal]:stroke-muted [&_.recharts-cartesian-grid-vertical]:stroke-muted [&_.recharts-cartesian-axis-line]:stroke-muted [&_.recharts-cartesian-axis-tick-line]:stroke-muted [&_.recharts-cartesian-axis-tick-value]:fill-foreground"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="currentColor"
|
||||
padding={{ left: 50, right: 50 }}
|
||||
height={60}
|
||||
interval={0}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 10]}
|
||||
stroke="currentColor"
|
||||
ticks={[0, 2, 4, 6, 8, 10]}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="Market"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={{ strokeWidth: 2 }}
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="Your Idea"
|
||||
stroke="hsl(var(--destructive))"
|
||||
strokeWidth={2}
|
||||
dot={{ strokeWidth: 2 }}
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Factors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Factor name"
|
||||
value={newFactor.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNewFactor({ ...newFactor, name: e.target.value })
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Market score (0-10)"
|
||||
value={newFactor.marketScore}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNewFactor({
|
||||
...newFactor,
|
||||
marketScore: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={10}
|
||||
className="w-32"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Your score (0-10)"
|
||||
value={newFactor.ideaScore}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNewFactor({
|
||||
...newFactor,
|
||||
ideaScore: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={10}
|
||||
className="w-32"
|
||||
/>
|
||||
<Button onClick={addFactor}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{state.strategyCanvas.factors.map((factor, index) => (
|
||||
<div key={factor.id} className="p-4 border rounded-lg bg-card">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveFactor(factor.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<MoveUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveFactor(factor.id, 'down')}
|
||||
disabled={
|
||||
index === state.strategyCanvas.factors.length - 1
|
||||
}
|
||||
>
|
||||
<MoveDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={factor.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
updateFactorField(factor.id, 'name', e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={factor.marketScore}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
updateFactorField(
|
||||
factor.id,
|
||||
'marketScore',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
max={10}
|
||||
className="w-32"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={factor.ideaScore}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
updateFactorField(factor.id, 'ideaScore', e.target.value)
|
||||
}
|
||||
min={0}
|
||||
max={10}
|
||||
className="w-32"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteFactor(factor.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Notes about this factor..."
|
||||
value={state.strategyCanvas.notes[factor.id] || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
updateNotes(factor.id, e.target.value)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyCanvas;
|
||||
97
components/core/UtilityMap.tsx
Normal file
97
components/core/UtilityMap.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@components/ui/card';
|
||||
import { Textarea } from '@components/ui/textarea';
|
||||
import { Toggle } from '@components/ui/toggle';
|
||||
import { useGlobalState } from '@contexts/state/StateContext';
|
||||
|
||||
const stages = [
|
||||
'Purchase',
|
||||
'Delivery',
|
||||
'Use',
|
||||
'Supplements',
|
||||
'Maintenance',
|
||||
'Disposal',
|
||||
];
|
||||
const utilities = [
|
||||
'Customer Productivity',
|
||||
'Simplicity',
|
||||
'Convenience',
|
||||
'Risk',
|
||||
'Environmental Friendliness',
|
||||
'Fun',
|
||||
];
|
||||
|
||||
const UtilityMap = () => {
|
||||
const { state, dispatch } = useGlobalState();
|
||||
|
||||
const toggleCell = (key: string) => {
|
||||
dispatch({
|
||||
type: 'TOGGLE_UTILITY',
|
||||
payload: { key },
|
||||
});
|
||||
};
|
||||
|
||||
const updateNotes = (key: string, notes: string) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_UTILITY_NOTES',
|
||||
payload: { key, notes },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Buyer Utility Map</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 border"></th>
|
||||
{stages.map((stage) => (
|
||||
<th key={stage} className="p-2 border text-center">
|
||||
{stage}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{utilities.map((utility) => (
|
||||
<tr key={utility}>
|
||||
<td className="p-2 border font-medium">{utility}</td>
|
||||
{stages.map((stage) => {
|
||||
const key = `${utility}-${stage}`;
|
||||
return (
|
||||
<td key={stage} className="p-2 border">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Toggle
|
||||
pressed={state.utilityMap[key]?.value || false}
|
||||
onPressedChange={() => toggleCell(key)}
|
||||
className="w-full"
|
||||
>
|
||||
{state.utilityMap[key]?.value
|
||||
? 'Opportunity'
|
||||
: 'No opportunity'}
|
||||
</Toggle>
|
||||
<Textarea
|
||||
placeholder="Add notes..."
|
||||
value={state.utilityMap[key]?.notes || ''}
|
||||
onChange={(e) => updateNotes(key, e.target.value)}
|
||||
className="h-24 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UtilityMap;
|
||||
131
components/core/Validation.tsx
Normal file
131
components/core/Validation.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@components/ui/card';
|
||||
import { Checkbox } from '@components/ui/checkbox';
|
||||
import { Textarea } from '@components/ui/textarea';
|
||||
import { useGlobalState } from '@contexts/state/StateContext';
|
||||
|
||||
const nonCustomerTiers = [
|
||||
{
|
||||
id: 'soon',
|
||||
label: 'Soon-to-be non-customers who are on the edge of your market',
|
||||
},
|
||||
{
|
||||
id: 'refusing',
|
||||
label: 'Refusing non-customers who consciously choose against your market',
|
||||
},
|
||||
{
|
||||
id: 'unexplored',
|
||||
label: 'Unexplored non-customers who are in markets distant from yours',
|
||||
},
|
||||
];
|
||||
|
||||
const sequenceSteps = [
|
||||
{
|
||||
id: 'utility',
|
||||
label: 'Does your offering deliver exceptional utility to buyers?',
|
||||
},
|
||||
{ id: 'price', label: 'Is your price accessible to the mass of buyers?' },
|
||||
{
|
||||
id: 'cost',
|
||||
label: 'Can you attain your cost target to profit at your strategic price?',
|
||||
},
|
||||
{
|
||||
id: 'adoption',
|
||||
label: 'Are there adoption hurdles in actualizing your idea?',
|
||||
},
|
||||
];
|
||||
|
||||
const Validation = () => {
|
||||
const { state, dispatch } = useGlobalState();
|
||||
|
||||
const toggleNonCustomer = (key: string) => {
|
||||
dispatch({
|
||||
type: 'TOGGLE_NON_CUSTOMER',
|
||||
payload: { key },
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSequence = (key: string) => {
|
||||
dispatch({
|
||||
type: 'TOGGLE_SEQUENCE',
|
||||
payload: { key },
|
||||
});
|
||||
};
|
||||
|
||||
const updateNotes = (notes: string) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_VALIDATION_NOTES',
|
||||
payload: notes,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Non-Customer Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{nonCustomerTiers.map((tier) => (
|
||||
<div key={tier.id} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={tier.id}
|
||||
checked={state.validation.nonCustomers[tier.id] || false}
|
||||
onCheckedChange={() => toggleNonCustomer(tier.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={tier.id}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{tier.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Strategic Sequence</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{sequenceSteps.map((step) => (
|
||||
<div key={step.id} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={step.id}
|
||||
checked={state.validation.sequence[step.id] || false}
|
||||
onCheckedChange={() => toggleSequence(step.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={step.id}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{step.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
placeholder="Add validation notes..."
|
||||
value={state.validation.notes}
|
||||
onChange={(e) => updateNotes(e.target.value)}
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Validation;
|
||||
Reference in New Issue
Block a user