spine-framework-portal 0.2.16 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/functions/custom_anonymous-sessions.ts +342 -0
- package/functions/custom_claim-instance.ts +201 -0
- package/functions/custom_funnel-scoring.ts +256 -0
- package/functions/custom_funnel-signal.ts +717 -0
- package/functions/custom_funnel-timers.ts +435 -0
- package/functions/custom_portal-community-escalation.ts +65 -43
- package/functions/custom_stitch-identity.ts +210 -0
- package/functions/custom_support-triage.ts +517 -0
- package/package.json +1 -1
- package/pages/team/TeamPage.tsx +2 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Funnel Scoring Engine
|
|
2
|
+
// Pure calculation utilities - NO database access
|
|
3
|
+
// All functions are deterministic and testable
|
|
4
|
+
|
|
5
|
+
// ============================================
|
|
6
|
+
// TYPES
|
|
7
|
+
// ============================================
|
|
8
|
+
|
|
9
|
+
export interface EngagementResult {
|
|
10
|
+
type: 1 | 2 | 5
|
|
11
|
+
context: 'first_visit' | 'deep_session' | 'return_visit'
|
|
12
|
+
session_depth: number
|
|
13
|
+
prior_session_count?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RecencyResult {
|
|
17
|
+
divisor: 1 | 2 | 5 | null
|
|
18
|
+
age_days: number
|
|
19
|
+
window: 'fresh' | 'cooling' | 'stale' | 'expired'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RawScoreResult {
|
|
23
|
+
calculated: number
|
|
24
|
+
max_possible: number
|
|
25
|
+
rating: 1 | 2 | 3 | 4 | 5
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StageConfig {
|
|
29
|
+
max_lookback_days: number
|
|
30
|
+
fresh_days: number
|
|
31
|
+
cooling_days: number
|
|
32
|
+
stale_days: number
|
|
33
|
+
deep_engagement_action_count: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Stage configurations per plan
|
|
37
|
+
const STAGE_CONFIGS: Record<string, StageConfig> = {
|
|
38
|
+
anonymous: {
|
|
39
|
+
max_lookback_days: 90,
|
|
40
|
+
fresh_days: 7,
|
|
41
|
+
cooling_days: 30,
|
|
42
|
+
stale_days: 90,
|
|
43
|
+
deep_engagement_action_count: 4
|
|
44
|
+
},
|
|
45
|
+
identified: {
|
|
46
|
+
max_lookback_days: 120,
|
|
47
|
+
fresh_days: 14,
|
|
48
|
+
cooling_days: 45,
|
|
49
|
+
stale_days: 90,
|
|
50
|
+
deep_engagement_action_count: 3
|
|
51
|
+
},
|
|
52
|
+
installed: {
|
|
53
|
+
max_lookback_days: 90,
|
|
54
|
+
fresh_days: 7,
|
|
55
|
+
cooling_days: 21,
|
|
56
|
+
stale_days: 45,
|
|
57
|
+
deep_engagement_action_count: 3
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================
|
|
62
|
+
// ENGAGEMENT CALCULATION
|
|
63
|
+
// ============================================
|
|
64
|
+
|
|
65
|
+
export function calculateEngagement(
|
|
66
|
+
priorSignals: Array<{ session_id: string; occurred_at: string }>,
|
|
67
|
+
currentSessionId: string,
|
|
68
|
+
currentOccurredAt: string,
|
|
69
|
+
stage: string
|
|
70
|
+
): EngagementResult {
|
|
71
|
+
const config = STAGE_CONFIGS[stage] || STAGE_CONFIGS.anonymous
|
|
72
|
+
|
|
73
|
+
// First visit - no prior signals
|
|
74
|
+
if (priorSignals.length === 0) {
|
|
75
|
+
return { type: 1, context: 'first_visit', session_depth: 1 }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check for return visit
|
|
79
|
+
const lastSignal = priorSignals[priorSignals.length - 1]
|
|
80
|
+
const hoursSinceLast = differenceInHours(
|
|
81
|
+
new Date(currentOccurredAt),
|
|
82
|
+
new Date(lastSignal.occurred_at)
|
|
83
|
+
)
|
|
84
|
+
const isNewSession = currentSessionId !== lastSignal.session_id
|
|
85
|
+
|
|
86
|
+
if (isNewSession || hoursSinceLast >= 4) {
|
|
87
|
+
const uniqueSessions = new Set(priorSignals.map(s => s.session_id)).size
|
|
88
|
+
return {
|
|
89
|
+
type: 5,
|
|
90
|
+
context: 'return_visit',
|
|
91
|
+
session_depth: 1,
|
|
92
|
+
prior_session_count: uniqueSessions
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Same session - check depth
|
|
97
|
+
const sameSessionSignals = priorSignals.filter(s =>
|
|
98
|
+
s.session_id === currentSessionId &&
|
|
99
|
+
isSameDay(new Date(s.occurred_at), new Date(currentOccurredAt))
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const sessionDepth = sameSessionSignals.length + 1
|
|
103
|
+
|
|
104
|
+
if (sessionDepth >= config.deep_engagement_action_count) {
|
|
105
|
+
return { type: 2, context: 'deep_session', session_depth: sessionDepth }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { type: 1, context: 'first_visit', session_depth: sessionDepth }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================
|
|
112
|
+
// RECENCY CALCULATION
|
|
113
|
+
// ============================================
|
|
114
|
+
|
|
115
|
+
export function calculateRecency(
|
|
116
|
+
occurredAt: Date,
|
|
117
|
+
now: Date = new Date(),
|
|
118
|
+
stage: string = 'anonymous'
|
|
119
|
+
): RecencyResult {
|
|
120
|
+
const config = STAGE_CONFIGS[stage] || STAGE_CONFIGS.anonymous
|
|
121
|
+
const ageDays = differenceInDays(now, occurredAt)
|
|
122
|
+
|
|
123
|
+
if (ageDays > config.max_lookback_days) {
|
|
124
|
+
return { divisor: null, age_days: ageDays, window: 'expired' }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (ageDays <= config.fresh_days) {
|
|
128
|
+
return { divisor: 1, age_days: ageDays, window: 'fresh' }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (ageDays <= config.cooling_days) {
|
|
132
|
+
return { divisor: 2, age_days: ageDays, window: 'cooling' }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { divisor: 5, age_days: ageDays, window: 'stale' }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ============================================
|
|
139
|
+
// RAW SCORE CALCULATION
|
|
140
|
+
// ============================================
|
|
141
|
+
|
|
142
|
+
export function calculateRawScore(
|
|
143
|
+
actionValue: 1 | 2 | 5,
|
|
144
|
+
engagementType: 1 | 2 | 5,
|
|
145
|
+
recencyDivisor: 1 | 2 | 5
|
|
146
|
+
): RawScoreResult {
|
|
147
|
+
const calculated = (actionValue * engagementType) / recencyDivisor
|
|
148
|
+
|
|
149
|
+
let rating: 1 | 2 | 3 | 4 | 5
|
|
150
|
+
if (calculated <= 1) rating = 1
|
|
151
|
+
else if (calculated <= 4) rating = 2
|
|
152
|
+
else if (calculated <= 8) rating = 3
|
|
153
|
+
else if (calculated <= 15) rating = 4
|
|
154
|
+
else rating = 5
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
calculated,
|
|
158
|
+
max_possible: 25, // 5 * 5 / 1
|
|
159
|
+
rating
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================
|
|
164
|
+
// BEST-SIGNAL-WINS CALCULATION
|
|
165
|
+
// ============================================
|
|
166
|
+
|
|
167
|
+
export function findBestSignal<T extends { scoring_components?: { raw_score?: { calculated?: number; rating?: number } } }>(
|
|
168
|
+
signals: T[]
|
|
169
|
+
): { signal: T | null; rating: number; raw_score: number } {
|
|
170
|
+
if (signals.length === 0) {
|
|
171
|
+
return { signal: null, rating: 0, raw_score: 0 }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let bestSignal = signals[0]
|
|
175
|
+
let bestScore = signals[0]?.scoring_components?.raw_score?.calculated || 0
|
|
176
|
+
|
|
177
|
+
for (const signal of signals) {
|
|
178
|
+
const score = signal?.scoring_components?.raw_score?.calculated || 0
|
|
179
|
+
if (score > bestScore) {
|
|
180
|
+
bestScore = score
|
|
181
|
+
bestSignal = signal
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
signal: bestSignal,
|
|
187
|
+
rating: bestSignal?.scoring_components?.raw_score?.rating || 0,
|
|
188
|
+
raw_score: bestScore
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ============================================
|
|
193
|
+
// UTILITY FUNCTIONS
|
|
194
|
+
// ============================================
|
|
195
|
+
|
|
196
|
+
function differenceInHours(date1: Date, date2: Date): number {
|
|
197
|
+
const msPerHour = 1000 * 60 * 60
|
|
198
|
+
return Math.abs(date1.getTime() - date2.getTime()) / msPerHour
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function differenceInDays(date1: Date, date2: Date): number {
|
|
202
|
+
return Math.floor(differenceInHours(date1, date2) / 24)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isSameDay(date1: Date, date2: Date): boolean {
|
|
206
|
+
return date1.toDateString() === date2.toDateString()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================
|
|
210
|
+
// REFERRER CATEGORIZATION
|
|
211
|
+
// ============================================
|
|
212
|
+
|
|
213
|
+
export function categorizeReferrer(referrerDomain: string): string {
|
|
214
|
+
const social = ['linkedin.com', 'twitter.com', 'x.com', 'facebook.com', 'instagram.com']
|
|
215
|
+
const search = ['google.com', 'bing.com', 'duckduckgo.com']
|
|
216
|
+
|
|
217
|
+
const domain = referrerDomain.toLowerCase()
|
|
218
|
+
|
|
219
|
+
if (social.some(s => domain.includes(s))) return 'social'
|
|
220
|
+
if (search.some(s => domain.includes(s))) return 'search'
|
|
221
|
+
if (!domain || domain === 'direct') return 'direct'
|
|
222
|
+
|
|
223
|
+
return 'referral'
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================================
|
|
227
|
+
// OPPORTUNITY TYPE INFERENCE
|
|
228
|
+
// ============================================
|
|
229
|
+
|
|
230
|
+
export function inferOpportunityType(
|
|
231
|
+
signals: Array<{ action?: { action_type: string } }>,
|
|
232
|
+
stage: string,
|
|
233
|
+
rating: number
|
|
234
|
+
): { type: string; confidence: 'low' | 'medium' | 'high' } {
|
|
235
|
+
const actionTypes = signals.map(s => s.action?.action_type || '').join(' ')
|
|
236
|
+
|
|
237
|
+
// High-value signals indicate specific opportunities
|
|
238
|
+
if (actionTypes.includes('pricing') && rating >= 4) {
|
|
239
|
+
return { type: 'advanced_portal', confidence: 'high' }
|
|
240
|
+
}
|
|
241
|
+
if (actionTypes.includes('health_ping') && actionTypes.includes('production')) {
|
|
242
|
+
return { type: 'managed_services', confidence: 'high' }
|
|
243
|
+
}
|
|
244
|
+
if (actionTypes.includes('support_ticket') && rating >= 3) {
|
|
245
|
+
return { type: 'support_plan', confidence: 'medium' }
|
|
246
|
+
}
|
|
247
|
+
if (stage === 'installed' && rating >= 4) {
|
|
248
|
+
return { type: 'expansion', confidence: 'medium' }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Default based on stage
|
|
252
|
+
if (stage === 'anonymous') return { type: 'implementation', confidence: 'low' }
|
|
253
|
+
if (stage === 'identified') return { type: 'advanced_portal', confidence: 'low' }
|
|
254
|
+
|
|
255
|
+
return { type: 'advocate', confidence: 'low' }
|
|
256
|
+
}
|