@schandlergarcia/sf-web-components 2.0.0 → 2.1.0

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,288 @@
1
+ /**
2
+ * useEvaAgent — React hook for the Agentforce Agent REST API
3
+ *
4
+ * Handles the full lifecycle:
5
+ * 1. OAuth client-credentials → access_token
6
+ * 2. POST …/sessions → sessionId + message href
7
+ * 3. POST …/messages → agent response text
8
+ * 4. DELETE …/sessions → cleanup on unmount
9
+ *
10
+ * All HTTP calls go through the Vite proxy (see vite.config.ts):
11
+ * /sf-oauth/* → myDomainUrl
12
+ * /sf-agent/* → agentApiBaseUrl
13
+ */
14
+
15
+ import { useCallback, useEffect, useRef, useState } from "react";
16
+ import { AGENT_API_CONFIG } from "@/config/agentApi";
17
+
18
+ export interface AgentMessage {
19
+ role: "user" | "agent";
20
+ text: string;
21
+ id: string;
22
+ timestamp: string;
23
+ }
24
+
25
+ interface SessionState {
26
+ accessToken: string;
27
+ sessionId: string;
28
+ messagesHref: string;
29
+ endHref: string;
30
+ }
31
+
32
+ export default function useEvaAgent() {
33
+ const [messages, setMessages] = useState<AgentMessage[]>([]);
34
+ const [isConnecting, setIsConnecting] = useState(false);
35
+ const [isReady, setIsReady] = useState(false);
36
+ const [isSending, setIsSending] = useState(false);
37
+ const [error, setError] = useState<string | null>(null);
38
+
39
+ const sessionRef = useRef<SessionState | null>(null);
40
+ const sequenceRef = useRef(1);
41
+ const abortRef = useRef<AbortController | null>(null);
42
+
43
+ /* ── Step 1: OAuth token ──────────────────────────────── */
44
+ async function authenticate(signal: AbortSignal): Promise<string> {
45
+ const { clientId, clientSecret } = AGENT_API_CONFIG;
46
+
47
+ const body = new URLSearchParams({
48
+ grant_type: "client_credentials",
49
+ client_id: clientId,
50
+ client_secret: clientSecret,
51
+ });
52
+
53
+ const res = await fetch("/sf-oauth/services/oauth2/token", {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
56
+ body,
57
+ signal,
58
+ });
59
+
60
+ if (!res.ok) {
61
+ const text = await res.text();
62
+ throw new Error(`OAuth failed (${res.status}): ${text}`);
63
+ }
64
+
65
+ const data = await res.json();
66
+ return data.access_token;
67
+ }
68
+
69
+ /* ── Step 2: Create session ───────────────────────────── */
70
+ async function createSession(
71
+ accessToken: string,
72
+ signal: AbortSignal
73
+ ): Promise<Omit<SessionState, "accessToken">> {
74
+ const { agentId, bypassUser, demoTraveler, myDomainUrl } =
75
+ AGENT_API_CONFIG;
76
+
77
+ const payload = {
78
+ externalSessionKey: crypto.randomUUID(),
79
+ instanceConfig: { endpoint: myDomainUrl },
80
+ streamingCapabilities: { chunkTypes: ["Text"] },
81
+ bypassUser,
82
+ variables: [
83
+ {
84
+ name: "$Context.EndUserId",
85
+ type: "Text",
86
+ value: demoTraveler.contactId,
87
+ },
88
+ {
89
+ name: "$Context.EndUserEmail",
90
+ type: "Text",
91
+ value: demoTraveler.email,
92
+ },
93
+ {
94
+ name: "$Context.EndUserFirstName",
95
+ type: "Text",
96
+ value: demoTraveler.firstName,
97
+ },
98
+ {
99
+ name: "$Context.EndUserLastName",
100
+ type: "Text",
101
+ value: demoTraveler.lastName,
102
+ },
103
+ ],
104
+ };
105
+
106
+ const res = await fetch(
107
+ `/sf-agent/einstein/ai-agent/v1/agents/${agentId}/sessions`,
108
+ {
109
+ method: "POST",
110
+ headers: {
111
+ Authorization: `Bearer ${accessToken}`,
112
+ "Content-Type": "application/json",
113
+ },
114
+ body: JSON.stringify(payload),
115
+ signal,
116
+ }
117
+ );
118
+
119
+ if (!res.ok) {
120
+ const text = await res.text();
121
+ throw new Error(`Session creation failed (${res.status}): ${text}`);
122
+ }
123
+
124
+ const data = await res.json();
125
+ return {
126
+ sessionId: data.sessionId,
127
+ messagesHref: data._links?.messages?.href ?? "",
128
+ endHref: data._links?.end?.href ?? "",
129
+ };
130
+ }
131
+
132
+ /* ── Step 3: Send message ─────────────────────────────── */
133
+ const sendMessage = useCallback(async (text: string) => {
134
+ const session = sessionRef.current;
135
+ if (!session) return;
136
+
137
+ const userMsg: AgentMessage = {
138
+ role: "user",
139
+ text,
140
+ id: `user-${Date.now()}`,
141
+ timestamp: new Date().toISOString(),
142
+ };
143
+ setMessages((prev) => [...prev, userMsg]);
144
+ setIsSending(true);
145
+
146
+ try {
147
+ const seqId = sequenceRef.current++;
148
+
149
+ const messagesUrl = session.messagesHref.startsWith("http")
150
+ ? `/sf-agent${new URL(session.messagesHref).pathname}`
151
+ : `/sf-agent${session.messagesHref}`;
152
+
153
+ const res = await fetch(messagesUrl, {
154
+ method: "POST",
155
+ headers: {
156
+ Authorization: `Bearer ${session.accessToken}`,
157
+ "Content-Type": "application/json",
158
+ Accept: "application/json",
159
+ },
160
+ body: JSON.stringify({
161
+ message: { sequenceId: seqId, type: "Text", text },
162
+ }),
163
+ });
164
+
165
+ if (!res.ok) {
166
+ const errText = await res.text();
167
+ throw new Error(`Agent message failed (${res.status}): ${errText}`);
168
+ }
169
+
170
+ const data = await res.json();
171
+
172
+ const agentTexts: string[] = [];
173
+ if (Array.isArray(data.messages)) {
174
+ for (const m of data.messages) {
175
+ if (m.type === "Inform" && m.message) {
176
+ agentTexts.push(m.message);
177
+ } else if (m.type === "Text" && m.message) {
178
+ agentTexts.push(m.message);
179
+ }
180
+ }
181
+ }
182
+
183
+ const responseText =
184
+ agentTexts.length > 0
185
+ ? agentTexts.join("\n\n")
186
+ : data.message ?? "No response from agent.";
187
+
188
+ const agentMsg: AgentMessage = {
189
+ role: "agent",
190
+ text: responseText,
191
+ id: `agent-${Date.now()}`,
192
+ timestamp: new Date().toISOString(),
193
+ };
194
+ setMessages((prev) => [...prev, agentMsg]);
195
+ } catch (err: unknown) {
196
+ const msg = err instanceof Error ? err.message : "Unknown error";
197
+ setError(msg);
198
+ const errMsg: AgentMessage = {
199
+ role: "agent",
200
+ text: `Error: ${msg}`,
201
+ id: `error-${Date.now()}`,
202
+ timestamp: new Date().toISOString(),
203
+ };
204
+ setMessages((prev) => [...prev, errMsg]);
205
+ } finally {
206
+ setIsSending(false);
207
+ }
208
+ }, []);
209
+
210
+ /* ── Step 4: End session ──────────────────────────────── */
211
+ const endSession = useCallback(async () => {
212
+ const session = sessionRef.current;
213
+ if (!session) return;
214
+
215
+ try {
216
+ const endUrl = session.endHref.startsWith("http")
217
+ ? `/sf-agent${new URL(session.endHref).pathname}`
218
+ : `/sf-agent${session.endHref}`;
219
+
220
+ await fetch(endUrl, {
221
+ method: "DELETE",
222
+ headers: { Authorization: `Bearer ${session.accessToken}` },
223
+ });
224
+ } catch {
225
+ /* best-effort cleanup */
226
+ }
227
+
228
+ sessionRef.current = null;
229
+ setIsReady(false);
230
+ }, []);
231
+
232
+ /* ── Init: authenticate + create session on mount ─────── */
233
+ const connect = useCallback(async () => {
234
+ if (sessionRef.current) return;
235
+
236
+ abortRef.current = new AbortController();
237
+ const { signal } = abortRef.current;
238
+
239
+ setIsConnecting(true);
240
+ setError(null);
241
+
242
+ try {
243
+ const accessToken = await authenticate(signal);
244
+ const session = await createSession(accessToken, signal);
245
+
246
+ sessionRef.current = { accessToken, ...session };
247
+ sequenceRef.current = 1;
248
+ setIsReady(true);
249
+ } catch (err: unknown) {
250
+ if ((err as Error).name !== "AbortError") {
251
+ const msg = err instanceof Error ? err.message : "Connection failed";
252
+ setError(msg);
253
+ }
254
+ } finally {
255
+ setIsConnecting(false);
256
+ }
257
+ }, []);
258
+
259
+ /* ── Cleanup on unmount ───────────────────────────────── */
260
+ useEffect(() => {
261
+ return () => {
262
+ abortRef.current?.abort();
263
+ if (sessionRef.current) {
264
+ const session = sessionRef.current;
265
+ const endUrl = session.endHref.startsWith("http")
266
+ ? `/sf-agent${new URL(session.endHref).pathname}`
267
+ : `/sf-agent${session.endHref}`;
268
+
269
+ fetch(endUrl, {
270
+ method: "DELETE",
271
+ headers: { Authorization: `Bearer ${session.accessToken}` },
272
+ }).catch(() => {});
273
+ sessionRef.current = null;
274
+ }
275
+ };
276
+ }, []);
277
+
278
+ return {
279
+ messages,
280
+ isConnecting,
281
+ isReady,
282
+ isSending,
283
+ error,
284
+ connect,
285
+ sendMessage,
286
+ endSession,
287
+ };
288
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schandlergarcia/sf-web-components",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Reusable Salesforce web components library with Tailwind CSS v4 and shadcn/ui",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,6 +27,7 @@
27
27
  "files": [
28
28
  "dist",
29
29
  "scripts",
30
+ "brands",
30
31
  "src/templates",
31
32
  "src/components",
32
33
  "src/lib",
@@ -50,7 +51,10 @@
50
51
  "postinstall": "node scripts/postinstall.mjs",
51
52
  "reset:command-center": "bash scripts/reset-command-center.sh",
52
53
  "validate:dashboard": "bash scripts/validate-dashboard.sh",
53
- "graphql:schema": "node scripts/get-graphql-schema.mjs"
54
+ "graphql:schema": "node scripts/get-graphql-schema.mjs",
55
+ "brand:engine": "node node_modules/@schandlergarcia/sf-web-components/scripts/apply-brand.mjs engine",
56
+ "brand:reset": "node node_modules/@schandlergarcia/sf-web-components/scripts/apply-brand.mjs --reset",
57
+ "brand:list": "node node_modules/@schandlergarcia/sf-web-components/scripts/apply-brand.mjs --list"
54
58
  },
55
59
  "peerDependencies": {
56
60
  "@heroicons/react": "^2.0.0",
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * apply-brand.mjs — Apply a brand theme to the project.
5
+ *
6
+ * Usage:
7
+ * node scripts/apply-brand.mjs engine # Apply Engine brand
8
+ * node scripts/apply-brand.mjs --list # List available brands
9
+ * node scripts/apply-brand.mjs --reset # Remove brand, restore neutral theme
10
+ *
11
+ * When run from a consuming project (via npm run brand:engine), the script
12
+ * detects the package location automatically.
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import { fileURLToPath } from 'url';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = path.dirname(__filename);
21
+
22
+ const arg = process.argv[2];
23
+
24
+ if (!arg) {
25
+ console.error('Usage: node scripts/apply-brand.mjs <brand-name|--list|--reset>');
26
+ process.exit(1);
27
+ }
28
+
29
+ const cwd = process.cwd();
30
+ const PACKAGE_NAME = '@schandlergarcia/sf-web-components';
31
+
32
+ function findPackageRoot() {
33
+ const fromNodeModules = path.join(cwd, 'node_modules', PACKAGE_NAME);
34
+ if (fs.existsSync(fromNodeModules)) return fromNodeModules;
35
+
36
+ const fromScript = path.resolve(__dirname, '..');
37
+ if (fs.existsSync(path.join(fromScript, 'brands'))) return fromScript;
38
+
39
+ return null;
40
+ }
41
+
42
+ const packageRoot = findPackageRoot();
43
+ if (!packageRoot) {
44
+ console.error('Could not find package root. Run from the project directory.');
45
+ process.exit(1);
46
+ }
47
+
48
+ const brandsDir = path.join(packageRoot, 'brands');
49
+
50
+ if (arg === '--list') {
51
+ if (!fs.existsSync(brandsDir)) {
52
+ console.log('No brands available.');
53
+ process.exit(0);
54
+ }
55
+ const brands = fs.readdirSync(brandsDir).filter(d =>
56
+ fs.statSync(path.join(brandsDir, d)).isDirectory()
57
+ );
58
+ console.log(`Available brands: ${brands.join(', ') || '(none)'}`);
59
+ process.exit(0);
60
+ }
61
+
62
+ if (arg === '--reset') {
63
+ const neutralCSS = path.join(packageRoot, 'src/styles/global.css');
64
+ const targetCSS = path.join(cwd, 'src/styles/global.css');
65
+ if (fs.existsSync(neutralCSS)) {
66
+ fs.copyFileSync(neutralCSS, targetCSS);
67
+ console.log(' ✓ Restored neutral theme (global.css)');
68
+ }
69
+ const brandMarker = path.join(cwd, '.brand');
70
+ if (fs.existsSync(brandMarker)) fs.unlinkSync(brandMarker);
71
+ console.log('\n✅ Brand reset to neutral.\n');
72
+ process.exit(0);
73
+ }
74
+
75
+ const brandName = arg;
76
+ const brandDir = path.join(brandsDir, brandName);
77
+
78
+ if (!fs.existsSync(brandDir)) {
79
+ console.error(`Brand "${brandName}" not found in ${brandsDir}`);
80
+ const available = fs.existsSync(brandsDir)
81
+ ? fs.readdirSync(brandsDir).filter(d => fs.statSync(path.join(brandsDir, d)).isDirectory())
82
+ : [];
83
+ if (available.length) console.error(`Available: ${available.join(', ')}`);
84
+ process.exit(1);
85
+ }
86
+
87
+ console.log(`\n🎨 Applying "${brandName}" brand...\n`);
88
+
89
+ let installed = 0;
90
+
91
+ // 1. global.css → src/styles/global.css (always overwrite)
92
+ const brandCSS = path.join(brandDir, 'global.css');
93
+ const targetCSS = path.join(cwd, 'src/styles/global.css');
94
+ if (fs.existsSync(brandCSS)) {
95
+ fs.mkdirSync(path.dirname(targetCSS), { recursive: true });
96
+ fs.copyFileSync(brandCSS, targetCSS);
97
+ console.log(' ✓ Brand theme applied (global.css)');
98
+ installed++;
99
+ }
100
+
101
+ // 2. Sample data files → src/data/
102
+ const dataFiles = [
103
+ { src: 'engine-sample-data.js', dst: 'src/data/engine-sample-data.js' },
104
+ { src: 'engine-live-data.js', dst: 'src/data/engine-live-data.js' },
105
+ ];
106
+ for (const { src, dst } of dataFiles) {
107
+ const srcPath = path.join(brandDir, src);
108
+ const dstPath = path.join(cwd, dst);
109
+ if (fs.existsSync(srcPath)) {
110
+ fs.mkdirSync(path.dirname(dstPath), { recursive: true });
111
+ fs.copyFileSync(srcPath, dstPath);
112
+ console.log(` ✓ Installed ${dst}`);
113
+ installed++;
114
+ }
115
+ }
116
+
117
+ // 3. Hooks → src/hooks/
118
+ const hooks = [
119
+ { src: 'useEngineLiveData.ts', dst: 'src/hooks/useEngineLiveData.ts' },
120
+ { src: 'useEvaAgent.ts', dst: 'src/hooks/useEvaAgent.ts' },
121
+ ];
122
+ for (const { src, dst } of hooks) {
123
+ const srcPath = path.join(brandDir, src);
124
+ const dstPath = path.join(cwd, dst);
125
+ if (fs.existsSync(srcPath)) {
126
+ fs.mkdirSync(path.dirname(dstPath), { recursive: true });
127
+ fs.copyFileSync(srcPath, dstPath);
128
+ console.log(` ✓ Installed ${dst}`);
129
+ installed++;
130
+ }
131
+ }
132
+
133
+ // 4. Config files → src/config/
134
+ const configs = [
135
+ { src: 'agentApiConfig.ts', dst: 'src/config/agentApi.ts' },
136
+ ];
137
+ for (const { src, dst } of configs) {
138
+ const srcPath = path.join(brandDir, src);
139
+ const dstPath = path.join(cwd, dst);
140
+ if (fs.existsSync(srcPath)) {
141
+ fs.mkdirSync(path.dirname(dstPath), { recursive: true });
142
+ fs.copyFileSync(srcPath, dstPath);
143
+ console.log(` ✓ Installed ${dst}`);
144
+ installed++;
145
+ }
146
+ }
147
+
148
+ // 5. PRD → project root
149
+ const prd = path.join(brandDir, 'engine-command-center-prd.md');
150
+ if (fs.existsSync(prd)) {
151
+ fs.copyFileSync(prd, path.join(cwd, 'engine-command-center-prd.md'));
152
+ console.log(' ✓ Installed engine-command-center-prd.md');
153
+ installed++;
154
+ }
155
+
156
+ // 6. Schema → project root
157
+ const schema = path.join(brandDir, 'schema.graphql');
158
+ if (fs.existsSync(schema)) {
159
+ fs.copyFileSync(schema, path.join(cwd, 'schema.graphql'));
160
+ console.log(' ✓ Installed schema.graphql');
161
+ installed++;
162
+ }
163
+
164
+ // 7. Logo → src/assets/images/
165
+ const logo = path.join(brandDir, 'engine_logo.png');
166
+ if (fs.existsSync(logo)) {
167
+ const logoTarget = path.join(cwd, 'src/assets/images/engine_logo.png');
168
+ fs.mkdirSync(path.dirname(logoTarget), { recursive: true });
169
+ fs.copyFileSync(logo, logoTarget);
170
+ console.log(' ✓ Installed engine_logo.png');
171
+ installed++;
172
+ }
173
+
174
+ // 8. Write .brand marker so reset script knows which brand is active
175
+ fs.writeFileSync(path.join(cwd, '.brand'), brandName + '\n', 'utf-8');
176
+
177
+ console.log(`\n✅ "${brandName}" brand applied (${installed} files installed).`);
178
+ console.log(' Run "npm run brand:reset" to revert to neutral theme.\n');
@@ -335,6 +335,20 @@ if (fs.existsSync(packageJsonPath)) {
335
335
  scriptsAdded.push('validate:dashboard');
336
336
  }
337
337
 
338
+ // Add brand scripts
339
+ if (!packageJson.scripts['brand:engine']) {
340
+ packageJson.scripts['brand:engine'] = 'node node_modules/@schandlergarcia/sf-web-components/scripts/apply-brand.mjs engine';
341
+ scriptsAdded.push('brand:engine');
342
+ }
343
+ if (!packageJson.scripts['brand:reset']) {
344
+ packageJson.scripts['brand:reset'] = 'node node_modules/@schandlergarcia/sf-web-components/scripts/apply-brand.mjs --reset';
345
+ scriptsAdded.push('brand:reset');
346
+ }
347
+ if (!packageJson.scripts['brand:list']) {
348
+ packageJson.scripts['brand:list'] = 'node node_modules/@schandlergarcia/sf-web-components/scripts/apply-brand.mjs --list';
349
+ scriptsAdded.push('brand:list');
350
+ }
351
+
338
352
  if (scriptsAdded.length > 0) {
339
353
  fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8');
340
354
  scriptsAdded.forEach(script => {
@@ -398,24 +398,45 @@ echo "→ Cleaning caches…"
398
398
  rm -rf node_modules/.vite 2>/dev/null && echo " ✓ Cleared Vite cache" || true
399
399
  echo ""
400
400
 
401
- # ── 9. Restore neutral global.css ────────────────────────────────────────────
401
+ # ── 9. Restore global.css (brand-aware) ──────────────────────────────────────
402
402
 
403
403
  mkdir -p src/styles
404
404
 
405
405
  GLOBAL_CSS="src/styles/global.css"
406
406
  echo "→ Restoring ${GLOBAL_CSS}..."
407
407
 
408
- # Detect package location for global.css
408
+ # Check if a brand is active
409
+ ACTIVE_BRAND=""
410
+ if [ -f ".brand" ]; then
411
+ ACTIVE_BRAND="$(cat .brand | tr -d '[:space:]')"
412
+ fi
413
+
409
414
  PACKAGE_CSS=""
410
- if [ -f "node_modules/@schandlergarcia/sf-web-components/src/styles/global.css" ]; then
411
- PACKAGE_CSS="node_modules/@schandlergarcia/sf-web-components/src/styles/global.css"
412
- elif [ -f "$SCRIPT_DIR/../src/styles/global.css" ]; then
413
- PACKAGE_CSS="$SCRIPT_DIR/../src/styles/global.css"
415
+ if [ -n "$ACTIVE_BRAND" ]; then
416
+ # Use the brand's global.css
417
+ if [ -f "node_modules/@schandlergarcia/sf-web-components/brands/$ACTIVE_BRAND/global.css" ]; then
418
+ PACKAGE_CSS="node_modules/@schandlergarcia/sf-web-components/brands/$ACTIVE_BRAND/global.css"
419
+ elif [ -f "$SCRIPT_DIR/../brands/$ACTIVE_BRAND/global.css" ]; then
420
+ PACKAGE_CSS="$SCRIPT_DIR/../brands/$ACTIVE_BRAND/global.css"
421
+ fi
422
+ fi
423
+
424
+ # Fall back to neutral if no brand or brand CSS not found
425
+ if [ -z "$PACKAGE_CSS" ]; then
426
+ if [ -f "node_modules/@schandlergarcia/sf-web-components/src/styles/global.css" ]; then
427
+ PACKAGE_CSS="node_modules/@schandlergarcia/sf-web-components/src/styles/global.css"
428
+ elif [ -f "$SCRIPT_DIR/../src/styles/global.css" ]; then
429
+ PACKAGE_CSS="$SCRIPT_DIR/../src/styles/global.css"
430
+ fi
414
431
  fi
415
432
 
416
433
  if [ -n "$PACKAGE_CSS" ] && [ -f "$PACKAGE_CSS" ]; then
417
434
  cp "$PACKAGE_CSS" "$GLOBAL_CSS"
418
- echo " ✓ Theme restored from package"
435
+ if [ -n "$ACTIVE_BRAND" ]; then
436
+ echo " ✓ Theme restored (brand: $ACTIVE_BRAND)"
437
+ else
438
+ echo " ✓ Theme restored (neutral)"
439
+ fi
419
440
  else
420
441
  echo " ⚠ Skipped global.css (package CSS not found)"
421
442
  fi