diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..c65d382 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "extends": [ + "next/core-web-vitals", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "plugins": ["@typescript-eslint", "prettier"], + "rules": { + "prettier/prettier": "error", + "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-explicit-any": "error", + "react-hooks/exhaustive-deps": "warn" + } +} diff --git a/.gitignore b/.gitignore index c6bba59..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,130 +1,41 @@ -# Logs -logs -*.log +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug npm-debug.log* yarn-debug.log* yarn-error.log* -lerna-debug.log* .pnpm-debug.log* -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# env files (can opt-in for committing if needed) +.env* -# Runtime data -pids -*.pid -*.seed -*.pid.lock +# vercel +.vercel -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache +# typescript *.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +next-env.d.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..123d3e8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/README.md b/README.md index a98445a..1fcf509 100644 --- a/README.md +++ b/README.md @@ -1 +1,126 @@ -# blue-ocean \ No newline at end of file +# Blue Ocean Strategy Analysis Tool + +A comprehensive web application for visualizing and analyzing business strategies using the Blue Ocean Strategy framework. This tool helps strategists and business analysts create, validate, and visualize market-creating strategies through an interactive interface. + +## Features + +### ๐Ÿ’น Strategy Canvas + +- Interactive line chart visualization +- Factor management with market vs. idea comparison +- Notes and annotations for each factor +- Drag-and-drop factor reordering + +### ๐ŸŽฏ Four Actions Framework + +- Organize strategic factors into Eliminate/Reduce/Raise/Create +- Link factors with strategy canvas +- Notes per action category +- Color-coded sections for clarity + +### ๐Ÿ›ฃ๏ธ Six Paths Framework + +- Analysis across alternative industries +- Strategic group evaluation +- Buyer chain exploration +- Complementary products/services assessment +- Functional/emotional appeal analysis +- Time trend tracking + +### ๐Ÿ“Š Buyer Utility Map + +- Interactive grid visualization +- Toggle opportunities +- Notes per cell +- Six stages ร— six utility levers + +### ๐Ÿ’ฐ Price Corridor + +- Target price setting +- Competitor price tracking +- Visual price band analysis +- Three-tier market segmentation + +### โœ… Strategy Validation + +- Non-customer analysis +- Strategic sequence validation +- Implementation notes +- Progress tracking + +## Technical Stack + +- **Framework**: Next.js 14 with App Router +- **Language**: TypeScript 5 +- **Styling**: Tailwind CSS + shadcn/ui +- **Storage**: Redis +- **Charts**: Recharts +- **Utilities**: Lodash + +## Getting Started + +1. Clone the repository + +```bash +git clone https://github.com/riccardosenica/blue-ocean.git +cd blue-ocean +``` + +2. Install dependencies + +```bash +yarn install +``` + +3. Set up environment variables + +```bash +cp .env.example .env.local +``` + +4. Update the following variables in `.env.local`: + +``` +KV_REST_API_URL=your_kv_url +KV_REST_API_TOKEN=your_kv_token +``` + +5. Run the development server + +```bash +yarn dev +``` + +6. Open [http://localhost:3000](http://localhost:3000) in your browser + +## State Management + +The application uses React Context for state management with the following structure: + +- Strategy Canvas data +- Four Actions framework entries +- Six Paths analysis +- Utility Map toggles and notes +- Price Corridor data +- Validation checkpoints + +## Storage + +- Primary storage in browser's localStorage +- Backup functionality to Redis +- Automatic state persistence +- Import/Export capabilities + +## Development + +### Available Scripts + +- `yarn dev`: Start development server +- `yarn build`: Build for production +- `yarn start`: Start production server +- `yarn lint`: Run ESLint +- `yarn typecheck`: Run TypeScript compiler + +## Acknowledgments + +- Blue Ocean Strategy by W. Chan Kim and Renรฉe Mauborgne diff --git a/app/api/backup/route.ts b/app/api/backup/route.ts new file mode 100644 index 0000000..0148f25 --- /dev/null +++ b/app/api/backup/route.ts @@ -0,0 +1,15 @@ +import { redis } from '@utils/redis'; +import { NextResponse } from 'next/server'; +import crypto from 'crypto'; + +export async function POST(req: Request) { + try { + const { state } = await req.json(); + const key = crypto.randomBytes(8).toString('hex'); + await redis.set(key, JSON.stringify(state)); + return NextResponse.json({ success: true, key }); + } catch (error) { + console.error(error); + return NextResponse.json({ error: 'Backup failed' }, { status: 500 }); + } +} diff --git a/app/api/restore/route.ts b/app/api/restore/route.ts new file mode 100644 index 0000000..b63754f --- /dev/null +++ b/app/api/restore/route.ts @@ -0,0 +1,35 @@ +import { redis } from '@utils/redis'; +import { validateState } from '@utils/validateState'; +import { NextResponse } from 'next/server'; + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const key = searchParams.get('key'); + + if (!key) { + return NextResponse.json({ error: 'No key provided' }, { status: 400 }); + } + + const state = await redis.get(key); + + if (!state) { + return NextResponse.json( + { error: 'No data found for this key' }, + { status: 404 } + ); + } + + const validatedState = validateState(state); + return NextResponse.json({ data: validatedState }); + } catch (error) { + console.error('Restore operation failed:', error); + return NextResponse.json( + { + error: 'Restore failed', + details: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..7a01a76 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..cb25e6d --- /dev/null +++ b/app/globals.css @@ -0,0 +1,193 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 190 95% 95%; + --foreground: 200 50% 3%; + + --card: 0 0% 100%; + --card-foreground: 200 50% 3%; + + --popover: 0 0% 100%; + --popover-foreground: 200 50% 3%; + + --primary: 190 100% 50%; /* Bright cyan */ + --primary-foreground: 0 0% 100%; + + --secondary: 185 100% 85%; + --secondary-foreground: 200 50% 3%; + + --muted: 185 35% 88%; + --muted-foreground: 200 40% 40%; + + --accent: 170 85% 45%; /* Bold turquoise */ + --accent-foreground: 200 50% 3%; + + --destructive: 345 84% 60%; /* Coral red */ + --destructive-foreground: 210 40% 98%; + + --border: 190 95% 85%; + --input: 190 95% 85%; + --ring: 190 100% 50%; + + --radius: 0.5rem; + } + + .dark { + --background: 200 50% 3%; + --foreground: 210 40% 98%; + + --card: 200 50% 3%; + --card-foreground: 210 40% 98%; + + --popover: 200 50% 3%; + --popover-foreground: 210 40% 98%; + + --primary: 190 100% 50%; + --primary-foreground: 200 50% 3%; + + --secondary: 200 34% 20%; + --secondary-foreground: 210 40% 98%; + + --muted: 200 34% 20%; + --muted-foreground: 200 40% 60%; + + --accent: 170 85% 45%; + --accent-foreground: 210 40% 98%; + + --destructive: 345 62% 40%; + --destructive-foreground: 210 40% 98%; + + --border: 200 34% 20%; + --input: 200 34% 20%; + --ring: 190 100% 50%; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + background: linear-gradient( + 180deg, + hsl(190 95% 95%) 0%, + hsl(185 90% 92%) 100% + ); + min-height: 100vh; + } + + /* Animated waves for the header */ + @keyframes wave { + 0% { + transform: translateX(0) translateZ(0) scaleY(1); + } + 50% { + transform: translateX(-25%) translateZ(0) scaleY(0.8); + } + 100% { + transform: translateX(-50%) translateZ(0) scaleY(1); + } + } + + @keyframes swell { + 0%, + 100% { + transform: translateY(-12px); + } + 50% { + transform: translateY(12px); + } + } + + .wave { + background: url("data:image/svg+xml,%3Csvg viewBox='0 0 800 88.7' width='800' height='88.7' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M800 56.9c-155.5 0-204.9-50-405.5-49.9-200 0-250 49.9-394.5 49.9v31.8h800v-.2-31.6z' fill='%23ffffff33'/%3E%3C/svg%3E"); + position: absolute; + width: 200%; + height: 100%; + animation: wave 12s linear infinite; + transform-origin: center bottom; + } + + .wave-2 { + animation: wave 18s linear reverse infinite; + opacity: 0.5; + } + + .wave-3 { + animation: wave 20s linear infinite; + opacity: 0.2; + } + + .swell { + position: absolute; + width: 100%; + height: 100%; + animation: swell 7s ease -1.25s infinite; + transform-origin: center bottom; + } + + /* Glass effect for cards */ + .glass-card { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(31, 38, 135, 0.15); + } + + .dark .glass-card { + background: rgba(0, 0, 0, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); + } + + /* Gradient borders */ + .gradient-border { + position: relative; + border: none; + } + + .gradient-border::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: inherit; + padding: 2px; + background: linear-gradient( + 45deg, + hsl(190 100% 50%), + hsl(170 85% 45%), + hsl(190 100% 50%) + ); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + } +} + +.tabs-list { + @apply inline-flex h-12 items-center justify-center rounded-lg bg-white p-1 shadow-lg dark:bg-gray-800/50 backdrop-blur-sm; +} + +.tab { + @apply inline-flex items-center justify-center whitespace-nowrap rounded-md px-6 py-2.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300; +} + +.tab[data-state='active'] { + @apply bg-gradient-to-r from-cyan-500 to-blue-500 text-white shadow-sm; +} + +.tab[data-state='inactive'] { + @apply text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..8cfd6cd --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,45 @@ +import { Inter } from 'next/font/google'; +import './globals.css'; +import { ThemeProvider } from '@components/ThemeProvider'; +import type { Metadata } from 'next'; +import Header from '@components/Header'; +import { StateProvider } from '@contexts/state/StateProvider'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: `Blue ocean strategy tool by ${process.env.NEXT_PUBLIC_BRAND_NAME}`, + description: + 'Web application for visualizing and analyzing business strategies using the Blue Ocean Strategy framework', + keywords: 'blue ocean, strategy, business, visualization, analysis', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + +
+
+
+
+
{children}
+
+
+
+
+
+ + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..430ca66 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@components/ui/tabs'; +import StrategyCanvas from '@components/core/StrategyCanvas'; +import FourActions from '@components/core/FourActions'; +import SixPaths from '@components/core/SixPaths'; +import UtilityMap from '@components/core/UtilityMap'; +import PriceCorridor from '@components/core/PriceCorridor'; +import Validation from '@components/core/Validation'; +import BackupControls from '@components/BackupControls'; + +export default function Home() { + return ( + <> + + + + Strategy Canvas + Four Actions + Six Paths + Utility Map + Price Corridor + Validation + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..444605f --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/BackupControls.tsx b/components/BackupControls.tsx new file mode 100644 index 0000000..cf2d7fb --- /dev/null +++ b/components/BackupControls.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { Loader2, Copy, Check, RotateCcw } from 'lucide-react'; +import { Button } from '@components/ui/button'; +import { Input } from '@components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '@components/ui/card'; +import { Alert, AlertDescription } from '@components/ui/alert'; +import { useStorage } from '@utils/useStorage'; +import { useGlobalState } from '@contexts/state/StateContext'; + +export function LoadingSpinner() { + return ; +} + +export default function BackupControls() { + const [key, setKey] = useState(''); + const [backupKey, setBackupKey] = useState(''); + const [isBackingUp, setIsBackingUp] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const { state, dispatch, resetState } = useGlobalState(); + const { backupState, restoreState } = useStorage(); + + const handleBackup = async () => { + setIsBackingUp(true); + setError(null); + try { + const result = await backupState(state); + if (result?.key) { + setBackupKey(result.key); + } + } catch (error) { + console.error(error); + setError('Failed to backup data. Please try again.'); + } finally { + setIsBackingUp(false); + } + }; + + const handleRestore = async () => { + if (!key) { + setError('Please enter a key'); + return; + } + setIsRestoring(true); + setError(null); + try { + const restored = await restoreState(key); + if (restored) { + dispatch({ type: 'SET_STATE', payload: restored }); + setKey(''); + } else { + setError('No data found for this key'); + } + } catch (error) { + console.error(error); + setError('Failed to restore data. Please check your key and try again.'); + } finally { + setIsRestoring(false); + } + }; + + const copyKey = async () => { + try { + await navigator.clipboard.writeText(backupKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error(error); + setError('Failed to copy key to clipboard'); + } + }; + + return ( + + + Backup Controls + + + {error && ( + + {error} + + )} + +
+ + + {backupKey && ( +
+
+ Your backup key: + + {backupKey} + +
+ +
+ )} +
+ +
+ setKey(e.target.value)} + disabled={isRestoring} + /> + +
+
+
+ ); +} diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..c5ee08f --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { ThemeToggle } from '@components/ThemeToggle'; +import { WavesIcon } from 'lucide-react'; + +export default function Header() { + return ( +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+ +

+ Blue Ocean Strategy +

+
+

+ Navigate to new market spaces and create uncontested growth +

+
+
+
+ ); +} diff --git a/components/ThemeProvider.tsx b/components/ThemeProvider.tsx new file mode 100644 index 0000000..b1a7360 --- /dev/null +++ b/components/ThemeProvider.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; + +export function ThemeProvider({ + children, + ...props +}: { + children: React.ReactNode; + [key: string]: unknown; +}) { + return {children}; +} diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx new file mode 100644 index 0000000..9a85445 --- /dev/null +++ b/components/ThemeToggle.tsx @@ -0,0 +1,37 @@ +'use client'; + +import React from 'react'; +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { Button } from '@components/ui/button'; + +export function ThemeToggle() { + const { setTheme, resolvedTheme } = useTheme(); + + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); + } + + return ( + + ); +} diff --git a/components/core/FourActions.tsx b/components/core/FourActions.tsx new file mode 100644 index 0000000..32f7d90 --- /dev/null +++ b/components/core/FourActions.tsx @@ -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 + ) => ( + + + {title} + + +

{description}

+
+ + setNewItems((prev) => ({ ...prev, [actionType]: e.target.value })) + } + className="flex-1" + /> + +
+
+ {state.fourActions[actionType].map((item, index) => ( +
+ {item} + +
+ ))} +
+
+
+ ); + + return ( +
+ {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' + )} +
+ ); +}; + +export default FourActions; diff --git a/components/core/PriceCorridor.tsx b/components/core/PriceCorridor.tsx new file mode 100644 index 0000000..57a0a37 --- /dev/null +++ b/components/core/PriceCorridor.tsx @@ -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 ( +
+ + + Price Corridor of the Mass + + +
+ + + + + + + + {state.priceCorridor.targetPrice > 0 && ( + + )} + {chartData.length > 0 && ( + + )} + + +
+
+
+ +
+ + + Strategic Price + + +
+ {chartData.length > 0 && ( +
+

+ Suggested price corridor: {corridorLower.toFixed(2)} -{' '} + {corridorUpper.toFixed(2)} +

+

This range typically captures 70-80% of target buyers

+
+ )} +
+ setTargetPrice(Number(e.target.value))} + placeholder="Set target price..." + className="w-48" + /> + + Current target: {state.priceCorridor.targetPrice || 'Not set'} + +
+
+
+
+ + + + Add Alternative + + +
+ + setNewCompetitor((prev) => ({ + ...prev, + name: e.target.value, + })) + } + className="flex-1" + /> + + setNewCompetitor((prev) => ({ + ...prev, + price: Number(e.target.value), + })) + } + className="w-32" + /> + + +
+
+
+
+ + + + Price Alternatives Analysis + + +
+ {Object.entries(categorizedCompetitors).map( + ([category, competitors]) => ( +
+

+ {category.replace('-', ' ')} Alternatives +

+ {competitors.length > 0 ? ( +
+ {competitors.map((comp, index) => ( +
+ {comp.name} + {comp.price} + +
+ ))} +
+ ) : ( +

+ No alternatives added +

+ )} +
+ ) + )} +
+
+
+
+ ); +}; + +export default PriceCorridor; diff --git a/components/core/SixPaths.tsx b/components/core/SixPaths.tsx new file mode 100644 index 0000000..413cfa8 --- /dev/null +++ b/components/core/SixPaths.tsx @@ -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 + >({ + 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 ( + + + {title} + + +

{description}

+ +