Skip to content

Commit dbbb945

Browse files
authored
W-22262535: Add action hooks and script injection templates to scaffold-app skill (#26)
1 parent 4047ab4 commit dbbb945

1 file changed

Lines changed: 123 additions & 0 deletions

File tree

.claude/skills/scaffold-app/references/storefront-plugin-templates.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ Complete templates for the extension system. All files must use TypeScript (.ts/
2222
"path": "extensions/{{appName}}/providers/{{AppName}}Provider.tsx",
2323
"order": 0
2424
}
25+
],
26+
"actionHooks": [
27+
{
28+
"hookId": "sfcc.checkout.fraud.afterSubmitContactInfo",
29+
"handler": "extensions/{{appName}}/hooks/{{hookName}}.server.ts",
30+
"order": 0
31+
}
2532
]
2633
}
2734
```
@@ -31,6 +38,7 @@ Complete templates for the extension system. All files must use TypeScript (.ts/
3138
- `path`: Relative path from `src/` to the component file
3239
- `order`: Insertion order when multiple components target the same slot (lower = earlier)
3340
- `contextProviders`: Application-root level providers (injected after ComposeProviders)
41+
- `actionHooks`: Server-side handlers that run during storefront actions (see "Action Hook Handler" below)
3442
- `devOnly`: (optional) Set to `true` to exclude extension from production builds
3543

3644
**⚠️ IMPORTANT:** Use `target-config.json` (not `plugin-config.json`) and `targetId` (not `pluginId`)
@@ -260,6 +268,115 @@ export function use{{AppName}}Context(): {{AppName}}ContextType {
260268
- Use `useConfig<AppConfig>()` with TypeScript type for type-safe configuration access
261269
- Access config with direct property access: `appConfig.extension?.{{appName}}?.key || defaultValue`
262270

271+
## Injecting External SDK Scripts
272+
273+
Context providers can render `<script src="...">` tags to load external vendor SDKs (fraud beacons, analytics, payment libraries). React 19 automatically hoists these to `<head>` and deduplicates by `src`.
274+
275+
**File:** `storefront-next/src/extensions/{{appName}}/providers/{{AppName}}Provider.tsx`
276+
277+
```typescript
278+
'use client';
279+
import { useEffect, type ReactNode, type ReactElement } from 'react';
280+
import { useLocation } from 'react-router';
281+
import { useConfig } from '@salesforce/storefront-next-runtime/config';
282+
283+
interface AppConfig {
284+
extension?: {
285+
{{appName}}?: {
286+
siteId: string;
287+
enabled: boolean;
288+
};
289+
};
290+
}
291+
292+
export default function {{AppName}}Provider({ children }: { children: ReactNode }): ReactElement {
293+
const appConfig = useConfig<AppConfig>();
294+
const siteId = appConfig.extension?.{{appName}}?.siteId ?? '';
295+
const enabled = appConfig.extension?.{{appName}}?.enabled !== false;
296+
const location = useLocation();
297+
298+
useEffect(() => {
299+
if (!enabled || !siteId) return;
300+
// Re-notify vendor SDK on SPA navigation.
301+
// Replace with your vendor's page-notify API.
302+
const win = window as Window & { vendorSdk?: { notifyPageView?: () => void } };
303+
win.vendorSdk?.notifyPageView?.();
304+
}, [enabled, siteId, location.pathname]);
305+
306+
return (
307+
<>
308+
{enabled && siteId && (
309+
<script src={`https://cdn.example.com/sdk.js?id=${siteId}`} async />
310+
)}
311+
{children}
312+
</>
313+
);
314+
}
315+
```
316+
317+
**Key points:**
318+
- Use `async` for non-blocking SDKs (analytics, widgets). Omit `async` for SDKs that must load synchronously (fraud beacons).
319+
- Use `useLocation()` from `react-router` to detect SPA navigation and re-trigger vendor SDK logic.
320+
- For checkout-scoped SDKs (payment processors), use a `component` at `sfcc.checkout.page.before` instead of a global `contextProvider`.
321+
- Inline `<script>` blocks (code, not `src`) are NOT hoisted by React 19 — use `useEffect` for `window` object initialization.
322+
323+
## Action Hook Handler
324+
325+
**File:** `storefront-next/src/extensions/{{appName}}/hooks/{{hookName}}.server.ts`
326+
327+
Action hooks run server-side logic at specific points in the storefront flow. They are declared in `target-config.json` under `actionHooks` and execute in waterfall order with a 5-second timeout per handler.
328+
329+
**Available hook IDs:**
330+
331+
| Hook ID | Blocking | Purpose |
332+
| :------ | :------- | :------ |
333+
| `sfcc.checkout.fraud.afterSubmitContactInfo` | No | Fraud/identity checks after contact info submission |
334+
| `sfcc.checkout.addressVerification.afterSubmitShippingAddress` | No | Address validation and standardization |
335+
| `sfcc.checkout.shipping.afterMethodsFetch` | No | Enrich or filter shipping methods |
336+
| `sfcc.checkout.shipping.afterMethodSelect` | No | Post-processing after shipping method selection |
337+
| `sfcc.checkout.payments.afterSubmitPayment` | No | Post-payment processing (tokenization) |
338+
| `sfcc.checkout.fraud.beforePlace` | **Yes** | Final fraud gate — can block order creation |
339+
| `sfcc.checkout.payments.beforePlaceOrder` | **Yes** | Payment authorization gate — can block order creation |
340+
| `sfcc.checkout.payments.afterPlaceOrder` | No | Post-order processing (payment capture) |
341+
342+
**Blocking** hooks abort the action on any failure. **Non-blocking** hooks log errors and continue. Throwing `ActionHookError` always aborts with a user-facing error.
343+
344+
```typescript
345+
import type { ActionHookContext } from '@/targets/action-hook.server';
346+
import { ActionHookError } from '@/targets/action-hook.server';
347+
348+
export default async function {{hookName}}(
349+
context: ActionHookContext,
350+
): Promise<ActionHookContext | void> {
351+
const { data, actionContext } = context;
352+
353+
const response = await fetch('https://api.example.com/verify', {
354+
method: 'POST',
355+
headers: { 'Content-Type': 'application/json' },
356+
body: JSON.stringify({ address: data.shippingAddress }),
357+
});
358+
const result = await response.json();
359+
360+
if (!result.valid) {
361+
throw new ActionHookError(
362+
'Please check your information and try again.',
363+
'sfcc.checkout.addressVerification.afterSubmitShippingAddress',
364+
'shippingAddress',
365+
);
366+
}
367+
368+
// Return modified context for downstream handlers, or void to pass through unchanged.
369+
return { ...context, data: { ...data, shippingAddress: result.standardizedAddress } };
370+
}
371+
```
372+
373+
**Key points:**
374+
- Handler MUST be default export
375+
- `ActionHookContext` contains `data` (step-specific) and `actionContext` (React Router action context)
376+
- `ActionHookError(message, hookId, step)` returns a 400 response; `step` controls where the error displays
377+
- Return modified context to pass data downstream, or `void` to pass through unchanged
378+
- 5-second timeout per handler — keep external calls fast
379+
263380
## Custom Hook
264381

265382
**File:** `storefront-next/src/extensions/{{appName}}/hooks/use{{FeatureName}}.ts`
@@ -1042,6 +1159,12 @@ storefront-next/src/extensions/product-reviews/
10421159
- [ ] Configuration uses `useConfig()` from `@salesforce/storefront-next-runtime/config`
10431160
- [ ] Environment variables use PUBLIC__ prefix with double underscores (e.g., PUBLIC__app__extension__{{appName}}__key)
10441161

1162+
### Action Hooks
1163+
- [ ] Action hook handlers are default exports in `.server.ts` files
1164+
- [ ] Handlers registered in target-config.json under `actionHooks` with `hookId`, `handler`, `order`
1165+
- [ ] Handlers use `ActionHookError` for user-facing errors (not raw `throw`)
1166+
- [ ] External service calls complete within 5-second timeout
1167+
10451168
### Testing & Documentation
10461169
- [ ] Tests included for all components (.test.tsx)
10471170
- [ ] Components have data-testid attributes for testing

0 commit comments

Comments
 (0)