@akad/loyalty-modals
v0.2.0
Published
Drop-in React modals for the Akad loyalty program. It fetches the broker's loyalty status and renders the right modal at the right time:
Keywords
Readme
@akad/loyalty-modals
Drop-in React modals for the Akad loyalty program. It fetches the broker's loyalty status and renders the right modal at the right time:
- Invite — prompts eligible brokers to join the program.
- Level unlocked — celebrates a newly unlocked level and links to the club.
The host app owns data fetching transport (via an injected client) and the
QueryClientProvider; this package owns the decision logic and UI.
Install
Install the package from npm:
"@akad/loyalty-modals": "^0.0.5"See loyalty-modals-release.md for the release flow.
Local playground
Run the package-local modal playground with:
yarn workspace @akad/loyalty-modals dev:playgroundThe playground lives under packages/loyalty-modals/playground and injects a
fake LoyaltyApiClient directly into LoyaltyModalsProvider. It is not exported
from src/index.ts, is not included in the published package files, and does
not use the dashboard SDK, routes, mock headers, or fixture switcher.
Peer dependencies
react, react-dom, @tanstack/react-query, @akad/sdk, and
@akad/design-system must be provided by the host.
Usage
import {
createLoyaltyClient,
LoyaltyModals,
LoyaltyModalsProvider,
} from '@akad/loyalty-modals';
import '@akad/loyalty-modals/style.css';
const client = createLoyaltyClient(sdkBase); // any { request } requester
function App() {
return (
<LoyaltyModalsProvider
client={client}
clubUrl="https://club.akad.com.br"
onAccessClub={() => navigate('/club')} // optional, see below
tracking={{
onEvent: (event) => trackLoyaltyModal(event),
}}
>
<LoyaltyModals onError={(error, traceId) => report(error, traceId)} />
</LoyaltyModalsProvider>
);
}Mount LoyaltyModalsProvider inside your existing QueryClientProvider.
The host SDK instance must already be configured for the Loyalty Program API:
sdkInitialize({
baseUrl: '<APIM gateway base URL>',
productCode: 'loyalty-program',
apiVersion: 'v1',
});createLoyaltyClient sends relative endpoints (/status, /accept, and
/mark-unlocked-viewed) through that requester. Do not configure the host SDK
base URL with a Loyalty path suffix; the SDK adds product and version routing.
API
LoyaltyModalsProvider
| Prop | Type | Required | Description |
| -------------- | ----------------------- | -------- | ----------------------------------------------------------- |
| client | LoyaltyApiClient | yes | Reads status and records accept / view actions. |
| clubUrl | string | yes | Destination used for the default hard redirect to the club. |
| onAccessClub | () => void | no | Navigation override (see below). |
| tracking | LoyaltyModalsTracking | no | Optional modal interaction callback API owned by the host. |
LoyaltyModals
| Prop | Type | Default | Description |
| --------- | -------------------------------------------- | ------- | -------------------------------------------------------- |
| enabled | boolean | true | When false, skips the status request and renders null. |
| onError | (error: unknown, traceId?: string) => void | — | Called for status and mutation failures. |
createLoyaltyClient(base)
Builds a LoyaltyApiClient from a requester exposing
request(endpoint, { method, data }) (e.g. the @akad/sdk base), targeting the
loyalty-program endpoints.
The mutation requests include a tracking config so hosts using
@akad/sdk 2.x + @akad/data-owl 2.x emit Request Completed events through
the SDK tracking pipeline. Each declares a stable, normalized resource
(/loyalty/program/accept, /loyalty/program/mark-unlocked-viewed); the
required event context (product, productCategory, squadId, source,
businessFlow) is inherited from the host SDK instance's defaultTracking.
They also include business props — accepted for /accept; action and
levelId for /mark-unlocked-viewed — which data-owl passes through as
snake_case properties (levelId → level_id). The read-only status request
(GET /status) is not tracked.
No analytics package is imported by @akad/loyalty-modals.
Navigation
Both the invite accept button and the level-unlocked access club button navigate to the club on success:
- If
onAccessClubis provided, it is called (use this for SPA routing). - Otherwise the package does a hard redirect via
window.location.assign(clubUrl).
Navigation only runs after the underlying call succeeds; failures are surfaced
through onError and (for invite accept) an inline retry message.
Tracking
The package does not import analytics libraries. To track modal interactions,
pass tracking.onEvent and map the semantic event to your host app analytics
contract.
With the @akad/data-owl 2.x taxonomy, the modal actions become
Element Clicked events and statusLoaded becomes a Request Completed one.
There are no impression events: the modals are blocking, so every display ends
in one of the tracked actions — the buttons, the Esc key and a backdrop click
all emit the secondary action. Request failures are not emitted either; the SDK
already reports them as Request Completed with success: false.
The host app owns the full payload (fixed context + page context):
import {
ElementType,
EventHandler,
HttpMethod,
pushClickEvent,
pushRequestEvent,
} from '@akad/data-owl';
import { LOYALTY_TRACKING_CONTEXT } from '@/services/tracking';
const CLICKED_ELEMENTS = {
inviteAccepted: {
elementName: 'clube_convite_aceitar',
elementType: ElementType.Button,
},
inviteDeferred: {
elementName: 'clube_convite_recusar',
elementType: ElementType.Button,
},
levelUnlockedAccessedClub: {
elementName: 'clube_nivel_acessar_clube',
elementType: ElementType.Button,
},
levelUnlockedClosed: {
elementName: 'clube_nivel_fechar',
elementType: ElementType.Button,
},
} as const;
<LoyaltyModalsProvider
client={client}
clubUrl="https://club.akad.com.br"
tracking={{
onEvent: (event) => {
if (event.type === 'statusLoaded') {
pushRequestEvent(`${LOYALTY_API_URL}/status`, {
...LOYALTY_TRACKING_CONTEXT,
durationMs: event.durationMs,
handler: EventHandler.DataOwl,
loyaltyStatus: event.status, // emitted as loyalty_status
method: HttpMethod.Get,
resource: '/loyalty/program/status',
statusCode: 200, // only successful loads reach here
success: true,
});
return;
}
const element = CLICKED_ELEMENTS[event.type];
pushClickEvent({
...LOYALTY_TRACKING_CONTEXT, // product, productCategory, squadId, source/customSource, businessFlow
...element,
elementSection: 'loyalty_modal',
handler: EventHandler.DataOwl,
pagePath: window.location.pathname,
pageReferrer: document.referrer,
pageTitle: document.title,
pageUrl: window.location.href,
...('levelId' in event ? { levelId: event.levelId } : {}), // emitted as level_id
});
},
}}
>
<LoyaltyModals />
</LoyaltyModalsProvider>;tracking.onEvent receives this union:
type LoyaltyModalEvent =
| {
durationMs?: number;
status: 'initial' | 'accepted';
type: 'statusLoaded';
}
| { type: 'inviteAccepted' }
| { type: 'inviteDeferred' }
| { levelId: string; levelName: string; type: 'levelUnlockedAccessedClub' }
| { levelId: string; levelName: string; type: 'levelUnlockedClosed' };statusLoaded fires once per successful status load, including the refetches
after accept / view actions, and carries the request latency in durationMs. Only successful loads emit it: the endpoint answers
403 for brokers outside the loyalty group, so an event means the broker is in
the group, and status says whether they had already accepted the invite
(initial before accepting, accepted after) — expect an initial event
followed by an accepted one when a broker accepts.
The action events fire when the user invokes the corresponding modal action —
inviteDeferred and levelUnlockedClosed also cover Esc and backdrop
dismissals — except inviteAccepted, which fires only after the accept request
succeeds.
