Skip to main content

Customization

The Loom Loyalty Hub supports custom components for extending the UI. You can create custom widgets for form fields, custom entity views, and dashboard components.

Custom Component System​

Custom components are loaded at runtime from a bundled JavaScript file. The system consists of:

  1. Component Manifest - JSON file describing available components
  2. Component Bundle - JavaScript bundle containing the components
  3. Component Registry - Runtime registry that matches components to UI slots

Creating Custom Components​

1. Set Up a Component Package​

Create a new package for your custom components:

mkdir my-hub-components
cd my-hub-components
npm init -y
npm install react @loom-loyalty/core
npm install -D typescript vite @vitejs/plugin-react

2. Create a Widget Component​

// src/widgets/custom-points-widget.tsx
import type { WidgetProps } from "@loom-loyalty/core";

export function CustomPointsWidget({ value, fieldName, onChange }: WidgetProps) {
const points = typeof value === "number" ? value : 0;

return (
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-primary">{points.toLocaleString()}</span>
<span className="text-muted-foreground">pts</span>
</div>
);
}

3. Create an Entity View Component​

// src/views/custom-member-list.tsx
import type { EntityViewProps } from "@loom-loyalty/core";

export function CustomMemberListView({
data,
entityCode,
isLoading,
}: EntityViewProps) {
if (isLoading) {
return <div>Loading members...</div>;
}

return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.map((member: any) => (
<div
key={member.id}
className="p-4 border rounded-lg hover:shadow-md transition-shadow"
>
<div className="font-semibold">{member.name}</div>
<div className="text-sm text-muted-foreground">{member.email}</div>
<div className="mt-2 text-xs">
Tier: <span className="font-medium">{member.tier}</span>
</div>
</div>
))}
</div>
);
}

4. Create the Component Manifest​

// src/manifest.ts
import type { ComponentManifest } from "@loom-loyalty/core";

export const manifest: ComponentManifest = {
version: "1.0.0",
buildDate: new Date().toISOString(),
bundleUrl: "/components/bundle.js",
components: {
"custom-points-widget": {
type: "widget",
displayName: "Custom Points Display",
description: "A custom widget for displaying loyalty points",
fieldMatch: {
types: ["integer", "number"],
namePattern: "points|balance",
},
},
"custom-member-list": {
type: "entity-view",
displayName: "Custom Member List",
description: "A card-based view for members",
entityName: "customer",
viewType: "list",
},
},
};

5. Create the Entry Point​

// src/index.ts
import { CustomPointsWidget } from "./widgets/custom-points-widget";
import { CustomMemberListView } from "./views/custom-member-list";

// Export components for the loader
window.__LOOM_CUSTOM_COMPONENTS__ = {
"custom-points-widget": CustomPointsWidget,
"custom-member-list": CustomMemberListView,
};

6. Bundle the Components​

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: "src/index.ts",
name: "LoomCustomComponents",
fileName: "bundle",
formats: ["iife"],
},
rollupOptions: {
external: ["react", "react-dom"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
},
});

Build the bundle:

npm run build

7. Deploy Components​

Upload your built files to a CDN or static hosting:

https://cdn.example.com/components/
├── manifest.json
└── bundle.js

8. Configure the Hub​

Add the component configuration to your tenant config:

{
"customComponents": {
"manifestUrl": "https://cdn.example.com/components/manifest.json",
"version": "1.0.0"
}
}

Component Types​

Widgets​

Widgets are form field components that render specific field types.

interface WidgetProps {
value: unknown;
fieldName: string;
fieldType: string;
onChange?: (value: unknown) => void;
disabled?: boolean;
readOnly?: boolean;
}

Field Matching​

Widgets are matched to fields using the fieldMatch configuration:

{
fieldMatch: {
// Match fields with these schema types
types: ["string", "integer"],

// Match fields whose name matches this pattern
namePattern: "^(points|balance|credits)$"
}
}

Entity Views​

Entity views replace the default list, detail, or edit views for specific entities.

interface EntityViewProps {
data: unknown[];
entityCode: string;
viewType: "list" | "detail" | "edit";
isLoading?: boolean;
}

View Types​

  • list - Replaces the entity list view
  • detail - Replaces the entity detail view
  • edit - Replaces the entity edit form

Dashboard Components​

Dashboard components can be added to the home page:

{
type: "dashboard",
displayName: "Revenue Chart",
description: "Displays revenue trends"
}

Error Handling​

Custom components are wrapped in error boundaries to prevent crashes:

// If a custom component throws an error, the Hub shows a fallback UI
<ComponentErrorBoundary componentId="custom-widget">
<CustomWidget {...props} />
</ComponentErrorBoundary>

Error States​

When a component fails:

  1. The error is caught by the boundary
  2. A fallback UI is displayed
  3. The error is logged (with sanitized stack traces in production)
  4. Other components continue to work

Best Practices​

1. Use Type-Safe Props​

Always use the provided type interfaces:

import type { WidgetProps, EntityViewProps } from "@loom-loyalty/core";

2. Handle Loading States​

Check for loading states in entity views:

if (isLoading) {
return <Skeleton className="h-40" />;
}

3. Support Dark Mode​

Use Tailwind's dark mode classes:

<div className="bg-white dark:bg-gray-900">
<span className="text-gray-900 dark:text-gray-100">{value}</span>
</div>

4. Keep Bundles Small​

  • Code-split large components
  • Use tree-shaking
  • Externalize React and common dependencies

5. Version Your Components​

Always update the version when making changes:

{
"customComponents": {
"manifestUrl": "https://cdn.example.com/components/manifest.json",
"version": "1.0.1"
}
}

Using Custom Hooks​

Access Hub context in your custom components:

import { useConfig } from "@/hooks/use-config";
import { useBranding } from "@/hooks/use-branding";
import { useFeatures } from "@/hooks/use-features";

function MyCustomComponent() {
const config = useConfig();
const { primaryColor } = useBranding();
const { isEnabled } = useFeatures();

// Use Hub context in your component
}

Testing Custom Components​

Unit Testing​

import { render, screen } from "@testing-library/react";
import { CustomPointsWidget } from "./custom-points-widget";

describe("CustomPointsWidget", () => {
it("displays formatted points", () => {
render(
<CustomPointsWidget
value={1000}
fieldName="points"
fieldType="integer"
/>
);

expect(screen.getByText("1,000")).toBeInTheDocument();
expect(screen.getByText("pts")).toBeInTheDocument();
});
});

Integration Testing​

Test your components in the Hub context using Storybook or the Hub dev server.

Next Steps​