@contractspec/lib.example-shared-ui 0.0.0-canary-20260113170453

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.
@@ -0,0 +1,341 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { useContext, useMemo } from 'react';
5
+ import type { TemplateId } from './lib/types';
6
+
7
+ /**
8
+ * Overlay modification operation types (for OverlayContextProvider)
9
+ */
10
+ export type OverlayModificationOp =
11
+ | { op: 'hide' }
12
+ | { op: 'relabel'; label: string }
13
+ | { op: 'reorder'; position: number }
14
+ | { op: 'restyle'; className?: string; variant?: string }
15
+ | { op: 'set-default'; value: unknown };
16
+
17
+ /**
18
+ * Overlay spec for a field or component
19
+ */
20
+ export interface OverlaySpec {
21
+ id: string;
22
+ target: string; // path to the field/component
23
+ modifications: OverlayModificationOp[];
24
+ conditions?: {
25
+ role?: string[];
26
+ device?: 'mobile' | 'desktop' | 'any';
27
+ featureFlags?: string[];
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Context value for overlay engine
33
+ */
34
+ export interface OverlayContextValue {
35
+ /** Current overlays */
36
+ overlays: OverlaySpec[];
37
+ /** Apply overlays to a component */
38
+ applyOverlay: <T extends Record<string, unknown>>(
39
+ path: string,
40
+ target: T
41
+ ) => T;
42
+ /** Check if a field should be hidden */
43
+ isHidden: (path: string) => boolean;
44
+ /** Get relabeled text */
45
+ getLabel: (path: string, defaultLabel: string) => string;
46
+ /** Get position for reordering */
47
+ getPosition: (path: string, defaultPosition: number) => number;
48
+ /** Get style modifications */
49
+ getStyle: (path: string) => { className?: string; variant?: string };
50
+ /** Get default value */
51
+ getDefault: <T>(path: string, defaultValue: T) => T;
52
+ /** Current user role */
53
+ role: string;
54
+ /** Current device type */
55
+ device: 'mobile' | 'desktop';
56
+ }
57
+
58
+ const OverlayContext = React.createContext<OverlayContextValue | null>(null);
59
+
60
+ export interface OverlayContextProviderProps extends React.PropsWithChildren {
61
+ templateId: TemplateId;
62
+ role?: string;
63
+ device?: 'mobile' | 'desktop';
64
+ }
65
+
66
+ /**
67
+ * Provider for overlay engine context.
68
+ * Loads template-specific overlays and provides helper functions.
69
+ */
70
+ export function OverlayContextProvider({
71
+ templateId,
72
+ role = 'user',
73
+ device = 'desktop',
74
+ children,
75
+ }: OverlayContextProviderProps) {
76
+ // Load template-specific overlays
77
+ const overlays = useMemo(
78
+ () => getTemplateOverlays(templateId, role),
79
+ [templateId, role]
80
+ );
81
+
82
+ // Filter overlays based on current context
83
+ const activeOverlays = useMemo(() => {
84
+ return overlays.filter((overlay) => {
85
+ const conditions = overlay.conditions;
86
+ if (!conditions) return true;
87
+
88
+ if (conditions.role && !conditions.role.includes(role)) {
89
+ return false;
90
+ }
91
+
92
+ if (
93
+ conditions.device &&
94
+ conditions.device !== 'any' &&
95
+ conditions.device !== device
96
+ ) {
97
+ return false;
98
+ }
99
+
100
+ return true;
101
+ });
102
+ }, [overlays, role, device]);
103
+
104
+ // Create overlay map for quick lookups
105
+ const overlayMap = useMemo(() => {
106
+ const map = new Map<string, OverlaySpec>();
107
+ for (const overlay of activeOverlays) {
108
+ map.set(overlay.target, overlay);
109
+ }
110
+ return map;
111
+ }, [activeOverlays]);
112
+
113
+ // Apply overlay to a target object
114
+ const applyOverlay = useMemo(
115
+ () =>
116
+ <T extends Record<string, unknown>>(path: string, target: T): T => {
117
+ const overlay = overlayMap.get(path);
118
+ if (!overlay) return target;
119
+
120
+ let result = { ...target };
121
+ for (const mod of overlay.modifications) {
122
+ switch (mod.op) {
123
+ case 'hide':
124
+ result = { ...result, hidden: true };
125
+ break;
126
+ case 'relabel':
127
+ result = { ...result, label: mod.label };
128
+ break;
129
+ case 'reorder':
130
+ result = { ...result, position: mod.position };
131
+ break;
132
+ case 'restyle':
133
+ result = {
134
+ ...result,
135
+ className: mod.className,
136
+ variant: mod.variant,
137
+ };
138
+ break;
139
+ case 'set-default':
140
+ result = { ...result, defaultValue: mod.value };
141
+ break;
142
+ }
143
+ }
144
+ return result;
145
+ },
146
+ [overlayMap]
147
+ );
148
+
149
+ // Check if field is hidden
150
+ const isHidden = useMemo(
151
+ () =>
152
+ (path: string): boolean => {
153
+ const overlay = overlayMap.get(path);
154
+ return overlay?.modifications.some((m) => m.op === 'hide') ?? false;
155
+ },
156
+ [overlayMap]
157
+ );
158
+
159
+ // Get relabeled text
160
+ const getLabel = useMemo(
161
+ () =>
162
+ (path: string, defaultLabel: string): string => {
163
+ const overlay = overlayMap.get(path);
164
+ const relabel = overlay?.modifications.find((m) => m.op === 'relabel');
165
+ return relabel && relabel.op === 'relabel'
166
+ ? relabel.label
167
+ : defaultLabel;
168
+ },
169
+ [overlayMap]
170
+ );
171
+
172
+ // Get position for reordering
173
+ const getPosition = useMemo(
174
+ () =>
175
+ (path: string, defaultPosition: number): number => {
176
+ const overlay = overlayMap.get(path);
177
+ const reorder = overlay?.modifications.find((m) => m.op === 'reorder');
178
+ return reorder && reorder.op === 'reorder'
179
+ ? reorder.position
180
+ : defaultPosition;
181
+ },
182
+ [overlayMap]
183
+ );
184
+
185
+ // Get style modifications
186
+ const getStyle = useMemo(
187
+ () =>
188
+ (path: string): { className?: string; variant?: string } => {
189
+ const overlay = overlayMap.get(path);
190
+ const restyle = overlay?.modifications.find((m) => m.op === 'restyle');
191
+ if (restyle && restyle.op === 'restyle') {
192
+ return {
193
+ className: restyle.className,
194
+ variant: restyle.variant,
195
+ };
196
+ }
197
+ return {};
198
+ },
199
+ [overlayMap]
200
+ );
201
+
202
+ // Get default value
203
+ const getDefault = useMemo(
204
+ () =>
205
+ <T,>(path: string, defaultValue: T): T => {
206
+ const overlay = overlayMap.get(path);
207
+ const setDefault = overlay?.modifications.find(
208
+ (m) => m.op === 'set-default'
209
+ );
210
+ return setDefault && setDefault.op === 'set-default'
211
+ ? (setDefault.value as T)
212
+ : defaultValue;
213
+ },
214
+ [overlayMap]
215
+ );
216
+
217
+ const value = useMemo<OverlayContextValue>(
218
+ () => ({
219
+ overlays: activeOverlays,
220
+ applyOverlay,
221
+ isHidden,
222
+ getLabel,
223
+ getPosition,
224
+ getStyle,
225
+ getDefault,
226
+ role,
227
+ device,
228
+ }),
229
+ [
230
+ activeOverlays,
231
+ applyOverlay,
232
+ isHidden,
233
+ getLabel,
234
+ getPosition,
235
+ getStyle,
236
+ getDefault,
237
+ role,
238
+ device,
239
+ ]
240
+ );
241
+
242
+ return (
243
+ <OverlayContext.Provider value={value}>{children}</OverlayContext.Provider>
244
+ );
245
+ }
246
+
247
+ /**
248
+ * Hook to access overlay context
249
+ */
250
+ export function useOverlayContext(): OverlayContextValue {
251
+ const context = useContext(OverlayContext);
252
+ if (!context) {
253
+ throw new Error(
254
+ 'useOverlayContext must be used within an OverlayContextProvider'
255
+ );
256
+ }
257
+ return context;
258
+ }
259
+
260
+ /**
261
+ * Hook to check if within overlay context
262
+ */
263
+ export function useIsInOverlayContext(): boolean {
264
+ return useContext(OverlayContext) !== null;
265
+ }
266
+
267
+ /**
268
+ * Get template-specific overlays
269
+ */
270
+ function getTemplateOverlays(
271
+ templateId: TemplateId,
272
+ _role: string
273
+ ): OverlaySpec[] {
274
+ // Demo overlays for each template
275
+ const templateOverlays: Record<string, OverlaySpec[]> = {
276
+ 'crm-pipeline': [
277
+ {
278
+ id: 'crm-hide-internal-fields',
279
+ target: 'deal.internalNotes',
280
+ modifications: [{ op: 'hide' }],
281
+ conditions: { role: ['viewer', 'user'] },
282
+ },
283
+ {
284
+ id: 'crm-relabel-value',
285
+ target: 'deal.value',
286
+ modifications: [{ op: 'relabel', label: 'Deal Amount' }],
287
+ },
288
+ ],
289
+ 'saas-boilerplate': [
290
+ {
291
+ id: 'saas-hide-billing',
292
+ target: 'settings.billing',
293
+ modifications: [{ op: 'hide' }],
294
+ conditions: { role: ['viewer'] },
295
+ },
296
+ {
297
+ id: 'saas-restyle-plan',
298
+ target: 'settings.plan',
299
+ modifications: [{ op: 'restyle', variant: 'premium' }],
300
+ conditions: { role: ['admin'] },
301
+ },
302
+ ],
303
+ 'agent-console': [
304
+ {
305
+ id: 'agent-hide-cost',
306
+ target: 'run.cost',
307
+ modifications: [{ op: 'hide' }],
308
+ conditions: { role: ['viewer'] },
309
+ },
310
+ {
311
+ id: 'agent-relabel-tokens',
312
+ target: 'run.tokens',
313
+ modifications: [{ op: 'relabel', label: 'Token Usage' }],
314
+ },
315
+ ],
316
+ 'todos-app': [
317
+ {
318
+ id: 'todos-hide-assignee',
319
+ target: 'task.assignee',
320
+ modifications: [{ op: 'hide' }],
321
+ conditions: { device: 'mobile' },
322
+ },
323
+ ],
324
+ 'messaging-app': [
325
+ {
326
+ id: 'messaging-reorder-timestamp',
327
+ target: 'message.timestamp',
328
+ modifications: [{ op: 'reorder', position: 0 }],
329
+ },
330
+ ],
331
+ 'recipe-app-i18n': [
332
+ {
333
+ id: 'recipe-relabel-servings',
334
+ target: 'recipe.servings',
335
+ modifications: [{ op: 'relabel', label: 'Portions' }],
336
+ },
337
+ ],
338
+ };
339
+
340
+ return templateOverlays[templateId] ?? [];
341
+ }
@@ -0,0 +1,293 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useMemo, useState } from 'react';
4
+ import { Button } from '@contractspec/lib.design-system';
5
+ import { Card } from '@contractspec/lib.ui-kit-web/ui/card';
6
+ import { Badge } from '@contractspec/lib.ui-kit-web/ui/badge';
7
+ import type { TemplateId } from './lib/types';
8
+ import {
9
+ type BehaviorSummary,
10
+ useBehaviorTracking,
11
+ } from './hooks/useBehaviorTracking';
12
+
13
+ export interface PersonalizationInsightsProps {
14
+ templateId: TemplateId;
15
+ /** Whether to show in collapsed mode */
16
+ collapsed?: boolean;
17
+ /** Toggle collapsed state */
18
+ onToggle?: () => void;
19
+ }
20
+
21
+ /**
22
+ * Component showing personalization insights based on user behavior.
23
+ * Displays usage patterns, recommendations, and feature adoption.
24
+ */
25
+ export function PersonalizationInsights({
26
+ templateId,
27
+ collapsed = false,
28
+ onToggle,
29
+ }: PersonalizationInsightsProps) {
30
+ const { getSummary, eventCount, clear, sessionStart } =
31
+ useBehaviorTracking(templateId);
32
+
33
+ const [showDetails, setShowDetails] = useState(false);
34
+
35
+ const summary = useMemo(() => getSummary(), [getSummary]);
36
+
37
+ const formatDuration = useCallback((ms: number) => {
38
+ const seconds = Math.floor(ms / 1000);
39
+ const minutes = Math.floor(seconds / 60);
40
+ const hours = Math.floor(minutes / 60);
41
+
42
+ if (hours > 0) {
43
+ return `${hours}h ${minutes % 60}m`;
44
+ }
45
+ if (minutes > 0) {
46
+ return `${minutes}m ${seconds % 60}s`;
47
+ }
48
+ return `${seconds}s`;
49
+ }, []);
50
+
51
+ const handleClear = useCallback(() => {
52
+ clear();
53
+ }, [clear]);
54
+
55
+ // Collapsed view
56
+ if (collapsed) {
57
+ return (
58
+ <button
59
+ onClick={onToggle}
60
+ className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2 text-sm transition hover:bg-blue-500/20"
61
+ type="button"
62
+ >
63
+ <span>📊</span>
64
+ <span>Insights</span>
65
+ <Badge variant="secondary">{eventCount}</Badge>
66
+ </button>
67
+ );
68
+ }
69
+
70
+ return (
71
+ <Card className="overflow-hidden">
72
+ {/* Header */}
73
+ <div className="flex items-center justify-between border-b border-blue-500/20 bg-blue-500/5 px-4 py-3">
74
+ <div className="flex items-center gap-2">
75
+ <span>📊</span>
76
+ <span className="font-semibold">Personalization Insights</span>
77
+ </div>
78
+ <div className="flex items-center gap-2">
79
+ <Button
80
+ variant="ghost"
81
+ size="sm"
82
+ onPress={() => setShowDetails(!showDetails)}
83
+ >
84
+ {showDetails ? 'Hide Details' : 'Show Details'}
85
+ </Button>
86
+ {onToggle && (
87
+ <button
88
+ onClick={onToggle}
89
+ className="text-muted-foreground hover:text-foreground p-1"
90
+ type="button"
91
+ title="Collapse"
92
+ >
93
+
94
+ </button>
95
+ )}
96
+ </div>
97
+ </div>
98
+
99
+ <div className="p-4">
100
+ {/* Quick Stats */}
101
+ <div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
102
+ <StatCard
103
+ label="Session Time"
104
+ value={formatDuration(summary.sessionDuration)}
105
+ icon="⏱️"
106
+ />
107
+ <StatCard
108
+ label="Events Tracked"
109
+ value={summary.totalEvents.toString()}
110
+ icon="📈"
111
+ />
112
+ <StatCard
113
+ label="Features Used"
114
+ value={`${summary.featuresUsed.length}/${summary.featuresUsed.length + summary.unusedFeatures.length}`}
115
+ icon="✨"
116
+ />
117
+ <StatCard
118
+ label="Errors"
119
+ value={summary.errorCount.toString()}
120
+ icon="⚠️"
121
+ variant={summary.errorCount > 0 ? 'warning' : 'success'}
122
+ />
123
+ </div>
124
+
125
+ {/* Recommendations */}
126
+ {summary.recommendations.length > 0 && (
127
+ <div className="mb-4">
128
+ <h4 className="mb-2 text-xs font-semibold text-blue-400 uppercase">
129
+ Recommendations
130
+ </h4>
131
+ <ul className="space-y-1">
132
+ {summary.recommendations.map((rec, index) => (
133
+ <li key={index} className="flex items-start gap-2 text-sm">
134
+ <span className="text-blue-400">💡</span>
135
+ <span>{rec}</span>
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ </div>
140
+ )}
141
+
142
+ {/* Unused Features */}
143
+ {summary.unusedFeatures.length > 0 && (
144
+ <div className="mb-4">
145
+ <h4 className="mb-2 text-xs font-semibold text-blue-400 uppercase">
146
+ Try These Features
147
+ </h4>
148
+ <div className="flex flex-wrap gap-2">
149
+ {summary.unusedFeatures.slice(0, 5).map((feature) => (
150
+ <Badge key={feature} variant="secondary">
151
+ {formatFeatureName(feature)}
152
+ </Badge>
153
+ ))}
154
+ </div>
155
+ </div>
156
+ )}
157
+
158
+ {/* Detailed View */}
159
+ {showDetails && (
160
+ <DetailedInsights summary={summary} sessionStart={sessionStart} />
161
+ )}
162
+
163
+ {/* Footer Actions */}
164
+ <div className="mt-4 flex justify-end border-t border-blue-500/10 pt-4">
165
+ <Button variant="ghost" size="sm" onPress={handleClear}>
166
+ Clear Data
167
+ </Button>
168
+ </div>
169
+ </div>
170
+ </Card>
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Stat card component
176
+ */
177
+ function StatCard({
178
+ label,
179
+ value,
180
+ icon,
181
+ variant = 'default',
182
+ }: {
183
+ label: string;
184
+ value: string;
185
+ icon: string;
186
+ variant?: 'default' | 'warning' | 'success';
187
+ }) {
188
+ const bgColors = {
189
+ default: 'bg-blue-500/5 border-blue-500/20',
190
+ warning: 'bg-amber-500/5 border-amber-500/20',
191
+ success: 'bg-green-500/5 border-green-500/20',
192
+ };
193
+
194
+ return (
195
+ <div className={`rounded-lg border p-3 text-center ${bgColors[variant]}`}>
196
+ <div className="mb-1 text-lg">{icon}</div>
197
+ <div className="text-lg font-bold">{value}</div>
198
+ <div className="text-muted-foreground text-xs">{label}</div>
199
+ </div>
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Detailed insights section
205
+ */
206
+ function DetailedInsights({
207
+ summary,
208
+ sessionStart,
209
+ }: {
210
+ summary: BehaviorSummary;
211
+ sessionStart: Date;
212
+ }) {
213
+ return (
214
+ <div className="mt-4 space-y-4 border-t border-blue-500/10 pt-4">
215
+ {/* Most Used Templates */}
216
+ {summary.mostUsedTemplates.length > 0 && (
217
+ <div>
218
+ <h4 className="mb-2 text-xs font-semibold text-blue-400 uppercase">
219
+ Most Used Templates
220
+ </h4>
221
+ <div className="space-y-1">
222
+ {summary.mostUsedTemplates.map(({ templateId, count }) => (
223
+ <div
224
+ key={templateId}
225
+ className="flex items-center justify-between text-sm"
226
+ >
227
+ <span>{formatTemplateId(templateId)}</span>
228
+ <span className="text-muted-foreground">{count} events</span>
229
+ </div>
230
+ ))}
231
+ </div>
232
+ </div>
233
+ )}
234
+
235
+ {/* Most Used Modes */}
236
+ {summary.mostUsedModes.length > 0 && (
237
+ <div>
238
+ <h4 className="mb-2 text-xs font-semibold text-blue-400 uppercase">
239
+ Mode Usage
240
+ </h4>
241
+ <div className="space-y-1">
242
+ {summary.mostUsedModes.map(({ mode, count }) => (
243
+ <div
244
+ key={mode}
245
+ className="flex items-center justify-between text-sm"
246
+ >
247
+ <span>{formatFeatureName(mode)}</span>
248
+ <span className="text-muted-foreground">{count} switches</span>
249
+ </div>
250
+ ))}
251
+ </div>
252
+ </div>
253
+ )}
254
+
255
+ {/* Features Used */}
256
+ <div>
257
+ <h4 className="mb-2 text-xs font-semibold text-blue-400 uppercase">
258
+ Features Used
259
+ </h4>
260
+ <div className="flex flex-wrap gap-2">
261
+ {summary.featuresUsed.map((feature) => (
262
+ <Badge
263
+ key={feature}
264
+ variant="default"
265
+ className="border-green-500/30 bg-green-500/20 text-green-400"
266
+ >
267
+ ✓ {formatFeatureName(feature)}
268
+ </Badge>
269
+ ))}
270
+ </div>
271
+ </div>
272
+
273
+ {/* Session Info */}
274
+ <div className="text-muted-foreground text-xs">
275
+ Session started: {sessionStart.toLocaleString()}
276
+ </div>
277
+ </div>
278
+ );
279
+ }
280
+
281
+ /**
282
+ * Format feature name for display
283
+ */
284
+ function formatFeatureName(feature: string): string {
285
+ return feature.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
286
+ }
287
+
288
+ /**
289
+ * Format template ID for display
290
+ */
291
+ function formatTemplateId(id: string): string {
292
+ return id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
293
+ }
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Sparkles } from 'lucide-react';
5
+ import { useTemplateRuntime } from './lib/runtime-context';
6
+
7
+ export interface SaveToStudioButtonProps {
8
+ organizationId?: string;
9
+ projectName?: string;
10
+ endpoint?: string;
11
+ token?: string;
12
+ }
13
+
14
+ export function SaveToStudioButton({
15
+ organizationId = 'demo-org',
16
+ projectName,
17
+ endpoint,
18
+ token,
19
+ }: SaveToStudioButtonProps) {
20
+ const { installer, templateId, template } = useTemplateRuntime();
21
+ const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>(
22
+ 'idle'
23
+ );
24
+ const [error, setError] = useState<string | null>(null);
25
+
26
+ const handleSave = async () => {
27
+ setStatus('saving');
28
+ setError(null);
29
+ try {
30
+ await installer.saveToStudio({
31
+ templateId,
32
+ projectName: projectName ?? `${template.name} demo`,
33
+ organizationId,
34
+ endpoint,
35
+ token,
36
+ });
37
+ setStatus('saved');
38
+ setTimeout(() => setStatus('idle'), 3000);
39
+ } catch (err) {
40
+ setStatus('error');
41
+ setError(err instanceof Error ? err.message : 'Unknown error');
42
+ }
43
+ };
44
+
45
+ return (
46
+ <div className="flex flex-col items-end gap-1">
47
+ <button
48
+ type="button"
49
+ className="btn-primary inline-flex items-center gap-2 text-sm"
50
+ onClick={handleSave}
51
+ disabled={status === 'saving'}
52
+ >
53
+ <Sparkles className="h-4 w-4" />
54
+ {status === 'saving' ? 'Publishing…' : 'Save to Studio'}
55
+ </button>
56
+ {status === 'error' && error ? (
57
+ <p className="text-destructive text-xs">{error}</p>
58
+ ) : null}
59
+ {status === 'saved' ? (
60
+ <p className="text-xs text-emerald-400">Template sent to Studio.</p>
61
+ ) : null}
62
+ </div>
63
+ );
64
+ }