feat: first draft
This commit is contained in:
6
utils/cn.ts
Normal file
6
utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
18
utils/redis.ts
Normal file
18
utils/redis.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
|
||||
if (!process.env.KV_REST_API_URL) {
|
||||
throw new Error('KV_REST_API_URL is not defined');
|
||||
}
|
||||
|
||||
if (!process.env.KV_REST_API_TOKEN) {
|
||||
throw new Error('KV_REST_API_TOKEN is not defined');
|
||||
}
|
||||
|
||||
export const redis = new Redis({
|
||||
url: process.env.KV_REST_API_URL,
|
||||
token: process.env.KV_REST_API_TOKEN,
|
||||
retry: {
|
||||
retries: 3,
|
||||
backoff: (retryCount) => Math.min(retryCount * 100, 3000),
|
||||
},
|
||||
});
|
||||
149
utils/stateReducer.ts
Normal file
149
utils/stateReducer.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { GlobalState, PathType } from './types';
|
||||
import { Action } from '@contexts/state/StateContext';
|
||||
import { initialState } from './validateState';
|
||||
|
||||
export function stateReducer(state: GlobalState, action: Action): GlobalState {
|
||||
switch (action.type) {
|
||||
case 'SET_STATE':
|
||||
return action.payload;
|
||||
case 'RESET_STATE':
|
||||
return initialState;
|
||||
case 'ADD_FACTOR':
|
||||
return {
|
||||
...state,
|
||||
strategyCanvas: {
|
||||
...state.strategyCanvas,
|
||||
factors: [...state.strategyCanvas.factors, action.payload],
|
||||
},
|
||||
};
|
||||
case 'ADD_ACTION': {
|
||||
const { actionType, value } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
fourActions: {
|
||||
...state.fourActions,
|
||||
[actionType]: [...state.fourActions[actionType], value],
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'ADD_OPPORTUNITY': {
|
||||
const { pathType, value: oppValue } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
sixPaths: {
|
||||
...state.sixPaths,
|
||||
[pathType]: {
|
||||
...state.sixPaths[pathType],
|
||||
opportunities: [
|
||||
...state.sixPaths[pathType].opportunities,
|
||||
oppValue,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'UPDATE_PATH_NOTES': {
|
||||
const { pathType, notes } = action.payload as {
|
||||
pathType: PathType;
|
||||
notes: string;
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
sixPaths: {
|
||||
...state.sixPaths,
|
||||
[pathType]: {
|
||||
...state.sixPaths[pathType],
|
||||
notes,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'TOGGLE_UTILITY': {
|
||||
const { key } = action.payload;
|
||||
const currentValue = state.utilityMap[key]?.value ?? false;
|
||||
return {
|
||||
...state,
|
||||
utilityMap: {
|
||||
...state.utilityMap,
|
||||
[key]: {
|
||||
...state.utilityMap[key],
|
||||
value: !currentValue,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'UPDATE_UTILITY_NOTES':
|
||||
return {
|
||||
...state,
|
||||
utilityMap: {
|
||||
...state.utilityMap,
|
||||
[action.payload.key]: {
|
||||
...state.utilityMap[action.payload.key],
|
||||
notes: action.payload.notes,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'UPDATE_TARGET_PRICE':
|
||||
return {
|
||||
...state,
|
||||
priceCorridor: {
|
||||
...state.priceCorridor,
|
||||
targetPrice: action.payload,
|
||||
},
|
||||
};
|
||||
case 'ADD_COMPETITOR':
|
||||
return {
|
||||
...state,
|
||||
priceCorridor: {
|
||||
...state.priceCorridor,
|
||||
competitors: [...state.priceCorridor.competitors, action.payload],
|
||||
},
|
||||
};
|
||||
case 'UPDATE_COMPETITOR': {
|
||||
const { index, field, value: compValue } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
priceCorridor: {
|
||||
...state.priceCorridor,
|
||||
competitors: state.priceCorridor.competitors.map((comp, i) =>
|
||||
i === index ? { ...comp, [field]: compValue } : comp
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'TOGGLE_NON_CUSTOMER':
|
||||
return {
|
||||
...state,
|
||||
validation: {
|
||||
...state.validation,
|
||||
nonCustomers: {
|
||||
...state.validation.nonCustomers,
|
||||
[action.payload.key]:
|
||||
!state.validation.nonCustomers[action.payload.key],
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'TOGGLE_SEQUENCE':
|
||||
return {
|
||||
...state,
|
||||
validation: {
|
||||
...state.validation,
|
||||
sequence: {
|
||||
...state.validation.sequence,
|
||||
[action.payload.key]:
|
||||
!state.validation.sequence[action.payload.key],
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'UPDATE_VALIDATION_NOTES':
|
||||
return {
|
||||
...state,
|
||||
validation: {
|
||||
...state.validation,
|
||||
notes: action.payload,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
52
utils/types.ts
Normal file
52
utils/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface GlobalState {
|
||||
strategyCanvas: {
|
||||
factors: {
|
||||
id: string;
|
||||
name: string;
|
||||
marketScore: number;
|
||||
ideaScore: number;
|
||||
}[];
|
||||
notes: Record<string, string>;
|
||||
};
|
||||
fourActions: {
|
||||
eliminate: string[];
|
||||
reduce: string[];
|
||||
raise: string[];
|
||||
create: string[];
|
||||
};
|
||||
sixPaths: Record<
|
||||
PathType,
|
||||
{
|
||||
notes: string;
|
||||
opportunities: string[];
|
||||
}
|
||||
>;
|
||||
utilityMap: Record<
|
||||
string,
|
||||
{
|
||||
value: boolean;
|
||||
notes: string;
|
||||
}
|
||||
>;
|
||||
priceCorridor: {
|
||||
targetPrice: number;
|
||||
competitors: {
|
||||
name: string;
|
||||
price: number;
|
||||
category: 'same-form' | 'different-form' | 'different-function';
|
||||
}[];
|
||||
};
|
||||
validation: {
|
||||
nonCustomers: Record<string, boolean>;
|
||||
sequence: Record<string, boolean>;
|
||||
notes: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PathType =
|
||||
| 'industries'
|
||||
| 'groups'
|
||||
| 'buyers'
|
||||
| 'complementary'
|
||||
| 'functional'
|
||||
| 'trends';
|
||||
80
utils/useStorage.ts
Normal file
80
utils/useStorage.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { GlobalState } from './types';
|
||||
import { useCallback } from 'react';
|
||||
import _ from 'lodash';
|
||||
import { initialState, validateState } from './validateState';
|
||||
|
||||
export const useStorage = () => {
|
||||
const saveState = useCallback(
|
||||
_.debounce(async (state: GlobalState) => {
|
||||
try {
|
||||
const validState = validateState(state);
|
||||
localStorage.setItem('state', JSON.stringify(validState));
|
||||
} catch (error) {
|
||||
console.error('Error saving state:', error);
|
||||
throw error;
|
||||
}
|
||||
}, 1000),
|
||||
[]
|
||||
);
|
||||
|
||||
const loadState = useCallback(async (): Promise<GlobalState | null> => {
|
||||
try {
|
||||
const localState = localStorage.getItem('state');
|
||||
return localState ? JSON.parse(localState) : null;
|
||||
} catch (error) {
|
||||
console.error('Error loading state:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const backupState = async (
|
||||
state: GlobalState
|
||||
): Promise<{ key?: string; error?: string }> => {
|
||||
try {
|
||||
const validState = validateState(state);
|
||||
const res = await fetch('/api/backup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state: validState }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Backup request failed');
|
||||
}
|
||||
|
||||
return { key: data.key };
|
||||
} catch (error) {
|
||||
console.error('Backup failed:', error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const restoreState = async (key: string): Promise<GlobalState> => {
|
||||
try {
|
||||
if (!key) {
|
||||
throw new Error('No key provided');
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/restore?key=${encodeURIComponent(key)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
data.error || `Restore failed with status ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return validateState(data.data);
|
||||
} catch (error) {
|
||||
console.error('Restore failed:', error);
|
||||
return initialState;
|
||||
}
|
||||
};
|
||||
|
||||
return { saveState, loadState, backupState, restoreState };
|
||||
};
|
||||
109
utils/validateState.ts
Normal file
109
utils/validateState.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { GlobalState } from './types';
|
||||
|
||||
export const initialState: GlobalState = {
|
||||
strategyCanvas: {
|
||||
factors: [],
|
||||
notes: {},
|
||||
},
|
||||
fourActions: {
|
||||
eliminate: [],
|
||||
reduce: [],
|
||||
raise: [],
|
||||
create: [],
|
||||
},
|
||||
sixPaths: {
|
||||
industries: { notes: '', opportunities: [] },
|
||||
groups: { notes: '', opportunities: [] },
|
||||
buyers: { notes: '', opportunities: [] },
|
||||
complementary: { notes: '', opportunities: [] },
|
||||
functional: { notes: '', opportunities: [] },
|
||||
trends: { notes: '', opportunities: [] },
|
||||
},
|
||||
utilityMap: {},
|
||||
priceCorridor: {
|
||||
targetPrice: 0,
|
||||
competitors: [] as Array<{
|
||||
name: string;
|
||||
price: number;
|
||||
category: 'same-form' | 'different-form' | 'different-function';
|
||||
}>,
|
||||
},
|
||||
validation: {
|
||||
nonCustomers: {},
|
||||
sequence: {},
|
||||
notes: '',
|
||||
},
|
||||
};
|
||||
|
||||
export function validateState(state: any): GlobalState {
|
||||
if (!state || typeof state !== 'object') {
|
||||
return initialState;
|
||||
}
|
||||
|
||||
return {
|
||||
strategyCanvas: {
|
||||
factors: Array.isArray(state?.strategyCanvas?.factors)
|
||||
? state.strategyCanvas.factors
|
||||
: [],
|
||||
notes: state?.strategyCanvas?.notes || {},
|
||||
},
|
||||
fourActions: {
|
||||
eliminate: Array.isArray(state?.fourActions?.eliminate)
|
||||
? state.fourActions.eliminate
|
||||
: [],
|
||||
reduce: Array.isArray(state?.fourActions?.reduce)
|
||||
? state.fourActions.reduce
|
||||
: [],
|
||||
raise: Array.isArray(state?.fourActions?.raise)
|
||||
? state.fourActions.raise
|
||||
: [],
|
||||
create: Array.isArray(state?.fourActions?.create)
|
||||
? state.fourActions.create
|
||||
: [],
|
||||
},
|
||||
sixPaths: {
|
||||
industries: validatePathSection(state?.sixPaths?.industries),
|
||||
groups: validatePathSection(state?.sixPaths?.groups),
|
||||
buyers: validatePathSection(state?.sixPaths?.buyers),
|
||||
complementary: validatePathSection(state?.sixPaths?.complementary),
|
||||
functional: validatePathSection(state?.sixPaths?.functional),
|
||||
trends: validatePathSection(state?.sixPaths?.trends),
|
||||
},
|
||||
utilityMap: state?.utilityMap || {},
|
||||
priceCorridor: {
|
||||
targetPrice: Number(state?.priceCorridor?.targetPrice) || 0,
|
||||
competitors: Array.isArray(state?.priceCorridor?.competitors)
|
||||
? state.priceCorridor.competitors.map(
|
||||
(comp: { name?: string; price?: number; category?: string }) => ({
|
||||
name: comp.name || '',
|
||||
price: Number(comp.price) || 0,
|
||||
category: [
|
||||
'same-form',
|
||||
'different-form',
|
||||
'different-function',
|
||||
].includes(comp.category || '')
|
||||
? (comp.category as
|
||||
| 'same-form'
|
||||
| 'different-form'
|
||||
| 'different-function')
|
||||
: 'same-form',
|
||||
})
|
||||
)
|
||||
: [],
|
||||
},
|
||||
validation: {
|
||||
nonCustomers: state?.validation?.nonCustomers || {},
|
||||
sequence: state?.validation?.sequence || {},
|
||||
notes: state?.validation?.notes || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validatePathSection(section: any) {
|
||||
return {
|
||||
notes: typeof section?.notes === 'string' ? section.notes : '',
|
||||
opportunities: Array.isArray(section?.opportunities)
|
||||
? section.opportunities
|
||||
: [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user