@sam-media-2/ouisys-dcb-widget
v2.0.0
Published
Standalone carrier-billing subscription-flow widget (PIN, MO, MO-redirect, one-click, click-to-SMS, USSD). Ships as a self-contained UMD bundle or importable React component.
Readme
@sam-media-2/ouisys-dcb-widget
A standalone, embeddable React widget that runs carrier-billing (DCB) subscription flows on mobile landing pages. Covers PIN, MO, MO-redirect, one-click (header enrichment), click-to-SMS, USSD, and operator-selection flows. Ships as a self-contained UMD bundle or importable React component.
Install
npm install @sam-media-2/ouisys-dcb-widget
# or
yarn add @sam-media-2/ouisys-dcb-widgetCDN (unpkg)
<!-- Latest -->
<script src="https://unpkg.com/@sam-media-2/ouisys-dcb-widget/embed/ouisys-dcb-widget.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@sam-media-2/ouisys-dcb-widget/embed/widget-base.css" />
<!-- Pinned version (recommended for production) -->
<script src="https://unpkg.com/@sam-media-2/[email protected]/embed/ouisys-dcb-widget.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@sam-media-2/[email protected]/embed/widget-base.css" />Hosted on staging.mouisys.com
<script src="https://staging.mouisys.com/os-ui/static/eamobi/dcb/ouisys-dcb-widget.js"></script>
<link rel="stylesheet" href="https://staging.mouisys.com/os-ui/static/eamobi/dcb/widget-base.css" />What it does
The widget owns exactly one thing: the subscription flow UI. It handles phone entry, PIN confirmation, MO SMS, one-click subscription, and the success terminal. Everything else — layout, design, copy, consent checkbox, surrounding page content — is the host page's responsibility.
The flow logic lives entirely inside ouisys-engine (a separate versioned package). The widget is its React UI layer: it renders the steps, hooks up the consent gate, fires events, and calls onSuccess when the flow completes.
Flows supported
| Flow | How it works | | --- | --- | | PIN | User enters phone → receives SMS PIN → enters PIN → subscribed | | MO | User enters phone → widget triggers MO SMS send → subscribed | | MO-redirect | User enters phone → redirected to payment URL → subscribed | | One-click | Carrier injects MSISDN via header enrichment → single button press → subscribed | | Click-to-SMS | User taps a button that opens the SMS app pre-filled with a keyword → subscribed | | USSD | User enters phone → USSD code displayed → dials to subscribe | | Operator selection | Widget shows a list of operators → user picks one → routes to that operator's flow |
Quick start
Plain HTML (most common)
<div id="subscribe"></div>
<link rel="stylesheet" href="https://unpkg.com/@sam-media-2/ouisys-dcb-widget/embed/widget-base.css" />
<script src="https://unpkg.com/@sam-media-2/ouisys-dcb-widget/embed/ouisys-dcb-widget.js"></script>
<script>
window.OUISYS_COUNTRY = 'sa';
window.pac_analytics = { visitor: { rockmanId: 'abc123', ip_range_name: 'sa', legals: [], chainRedirectUrl: '' } };
window.configJson = {
strategy: 'pin',
country: 'sa',
strategyConfigs: {
default: { flow: 'pin', flowConfig: { host: '', slug: 'sa-mobily-gamezones', device: 'smart', country: 'SA', service: 'gamezones' } },
operators: {},
isDecryptMsisdnByApi: false,
isExpectMsisdnInLocalHeaders: false
},
pageConfigs: { serviceName: 'GameZones', isShowConsentCheckBox: false, isDisableButtonIfInvalid: true, pin: { shortCodes: ['4242'] }, plan: { trialPrice: '2', isLocalCurrency: false } }
};
var cb = document.getElementById('consent');
function p() {
return {
config: window.configJson, locale: 'en',
consentValid: cb.checked,
onEvent: function(name, payload) { analytics.track(name, payload); },
onSuccess: function(url) { window.location.href = url; }
};
}
var handle = OuisysSubscribe.mount('#subscribe', p());
cb.addEventListener('change', function() { handle.update(p()); });
</script>React
import { SubscriptionWidget } from '@sam-media-2/ouisys-dcb-widget/src';
<SubscriptionWidget
config={configJson}
locale="en"
consentValid={consented}
onEvent={(name, payload) => analytics.track(name, payload)}
onSuccess={(url) => router.push(url)}
/>Required globals
Three globals must be set on window before the widget loads. If any are missing the widget will silently fail or crash.
window.OUISYS_COUNTRY = 'sa'; // lowercase ISO2 country code
window.pac_analytics = {
visitor: {
rockmanId: 'abc123', // any string — analytics/session ID
ip_range_name: 'sa', // drives IP-based operator detection
legals: [], // ILegals[] — rendered in price points
chainRedirectUrl: '', // optional post-subscription redirect override
// For one-click flows — the carrier injects this; mock it in dev:
heMsisdnResult: { msisdn: '966512345678' }
}
};
window.configJson = { ... }; // see Configuration belowConfiguration — window.configJson
window.configJson = {
strategy: 'pin', // which flow to run — see strategy table below
country: 'sa', // lowercase ISO2
strategyConfigs: {
default: {
flow: 'pin', // camelCase flow name — DIFFERENT from strategy string
flowConfig: {
host: '', // API host override; leave '' to use window.DEV_BASE_URL
slug: 'sa-actel-mobily-gamezones',
device: 'smart', // 'smart' | 'feature'
country: 'SA', // UPPERCASE ISO2
service: 'gamezones'
}
},
operators: {}, // operator key → config (for ask-operator strategy)
isDecryptMsisdnByApi: false,
isExpectMsisdnInLocalHeaders: false
},
pageConfigs: {
serviceName: 'GameZones',
isShowConsentCheckBox: false, // true = widget renders its own checkbox (unusual)
isDisableButtonIfInvalid: true,
pin: {
shortCodes: ['4242'], // shortcode shown in PIN step instructions
blockedPin: ['0000', '1234'] // pins that open an alert dialog
},
plan: { trialPrice: '2', isLocalCurrency: false },
hasMoButton: false
}
};Strategy name mapping
config.strategy and strategyConfigs.default.flow use different naming conventions.
| Intent | config.strategy | strategyConfigs.default.flow |
| --- | --- | --- |
| PIN flow | 'pin' | 'pin' |
| MO flow | 'mo' | 'mo' |
| MO redirect | 'mo-redir' | 'moRedir' |
| One-click / header enrichment | 'header-enrichment' | 'oneClick' |
| Click-to-SMS | 'click2sms' | 'click2sms' |
| USSD | 'ussd' | 'ussd' |
| Operator selection | 'ask-operator' | (per-operator in operators) |
| Detect by IP | 'detect-operator-by-ip' | target flow (e.g. 'pin') |
⚠️ Never use camelCase in
config.strategy— the engine matches hyphen-case strings exactly.
Widget props
| Prop | Type | Default | Purpose |
| --- | --- | --- | --- |
| config | WidgetConfig | window.configJson | Configuration object (see above) |
| locale | string | 'en' | Locale for i18n |
| messages | Record<string, string> | {} | Override any English label by message ID |
| previewFlow | FlowName | — | Force a flow for design/QA (no backend needed) |
| consentValid | boolean | undefined | false = widget buffers submit and emits ouisys.consent.required |
| onConsentRequired | () => void | — | Legacy callback — fires alongside the event (useful for shake animations) |
| onEvent | (name, payload) => void | — | Receives all widget events (analytics, custom consent, etc.) |
| redact | boolean | false | Strip msisdn from all event payloads |
| onSuccess | (productUrl: string) => void | — | Called at terminal success |
| slots | Slots | — | Replace any step's markup with custom JSX |
| className | string | — | Extra class on the widget root element |
MountHandle API (standalone embed)
OuisysSubscribe.mount(target, props) returns a handle with three methods:
| Method | Purpose |
| --- | --- |
| handle.update(props) | Re-render with new props — call on consent checkbox change |
| handle.submit() | Replay the buffered pending action — call after granting consent |
| handle.unmount() | Tear down the widget |
Host event system
The widget emits typed events at every meaningful moment through two simultaneous channels.
Channel 1 — onEvent callback
OuisysSubscribe.mount('#subscribe', {
config: window.configJson,
onEvent: function(name, payload) {
myAnalytics.track(name, payload);
}
});Channel 2 — DOM CustomEvent
Events bubble up from the mount container with composed: true, so they work across shadow DOM boundaries.
document.querySelector('#subscribe').addEventListener('ouisys.subscription.success', function(e) {
console.log(e.detail); // { msisdn: '...', productUrl: '...', flow: 'pin' }
});MSISDN redaction
OuisysSubscribe.mount('#subscribe', { redact: true, onEvent: ... });Strips msisdn from all payloads before they reach either channel.
Full event catalog
| Event | Payload | Fired when |
| --- | --- | --- |
| ouisys.widget.ready | flow | Strategy identified, first step visible |
| ouisys.flow.start | — | Widget mounts, strategy identification begins |
| ouisys.flow.error | error | Strategy identification failed |
| ouisys.phone.submit | msisdn? | User submitted phone number |
| ouisys.phone.success | msisdn? | Phone accepted, advancing to next step |
| ouisys.phone.error | msisdn?, error | Phone rejected |
| ouisys.pin.submit | msisdn? | User submitted PIN |
| ouisys.pin.success | msisdn? | PIN verified |
| ouisys.pin.error | msisdn?, error | PIN rejected (API failure or re-entry guard) |
| ouisys.mo.submit | msisdn? | MO SMS triggered |
| ouisys.mo.success | msisdn? | MO verified |
| ouisys.mo.error | msisdn?, error | MO failed |
| ouisys.oneclick.view | msisdn? | One-click button rendered |
| ouisys.oneclick.submit | msisdn? | One-click button pressed |
| ouisys.oneclick.success | msisdn?, productUrl | One-click subscription confirmed |
| ouisys.oneclick.error | msisdn?, error | One-click failed |
| ouisys.click2sms.view | keyword, shortcode | Click-to-SMS instructions rendered |
| ouisys.click2sms.submit | keyword, shortcode, msisdn? | SMS button pressed |
| ouisys.ussd.submit | msisdn? | USSD phone-entry submitted |
| ouisys.ussd.success | msisdn?, keyword, shortcode | USSD code displayed |
| ouisys.ussd.error | msisdn?, error | USSD failed |
| ouisys.operator.select | operator | Operator chosen on selection screen |
| ouisys.consent.required | pendingAction | Submit attempted while consentValid is false |
| ouisys.subscription.success | msisdn?, productUrl, flow | Terminal success on any flow |
| ouisys.subscription.error | msisdn?, error, flow | Terminal error on any flow |
msisdn? is omitted when redact: true. error is always a string. flow is camelCase: 'pin', 'mo', 'moRedir', 'oneClick', 'click2sms', 'ussd'.
Dev-mode console logger
In NODE_ENV=development, every event is automatically logged via console.groupCollapsed — no configuration needed. Respects redact: true. Stripped from the production bundle entirely.
Consent handling
The page owns the consent UI. The widget enforces it.
Standard pattern — checkbox + shake animation
var cb = document.getElementById('consent');
var row = document.getElementById('consent-row');
function props() {
return {
config: window.configJson, locale: 'en',
consentValid: cb.checked,
onConsentRequired: function() {
row.classList.remove('shake');
void row.offsetWidth; // reflow to restart animation
row.classList.add('shake');
},
onSuccess: function(url) { window.location.href = url; }
};
}
var handle = OuisysSubscribe.mount('#subscribe', props());
cb.addEventListener('change', function() { handle.update(props()); });Advanced pattern — consent modal with handle.submit()
Use when consent requires a modal, async T&C acceptance, or multi-step flow. The widget buffers the pending submit (phone or PIN) and replays it after consent is granted.
var handle = OuisysSubscribe.mount('#subscribe', {
config: window.configJson,
locale: 'en',
consentValid: false,
onEvent: function(name, payload) {
if (name === 'ouisys.consent.required') {
// payload.pendingAction → 'phone' | 'pin' | 'mo' | 'oneclick'
showConsentModal({
onAccept: function() {
handle.update({ config: window.configJson, consentValid: true });
handle.submit(); // replays the buffered action seamlessly
}
});
}
},
onSuccess: function(url) { window.location.href = url; }
});Both patterns can coexist — onConsentRequired still fires alongside ouisys.consent.required.
Customization
Level 1 — CSS token override (fastest)
Load the base stylesheet and override variables on the mount container:
<link rel="stylesheet" href="widget-base.css" />
<style>
#subscribe {
--ow-accent: #6d28d9;
--ow-accent-hover: #5b21b6;
--ow-radius: 14px;
--ow-font: "Inter", sans-serif;
}
</style>| Token | Purpose | Default |
| --- | --- | --- |
| --ow-accent | Primary CTA background | #1f7a4d |
| --ow-accent-hover | CTA hover state | #155e3a |
| --ow-accent-contrast | Text on CTA | #ffffff |
| --ow-bg | Widget surface background | transparent |
| --ow-text | Body text | #1a1a1a |
| --ow-muted | Secondary text | #667085 |
| --ow-border | Input borders | #cfd8e0 |
| --ow-error | Error text | #c0392b |
| --ow-radius | Corner radius | 10px |
| --ow-gap | Vertical rhythm | 12px |
| --ow-font | Font stack | system-ui |
| --ow-font-size | Base font size | 16px |
| --ow-control-height | Input/button height | 48px |
| --ow-disabled | Disabled button background | #aebfcb |
Level 2 — Class hook CSS
Skip widget-base.css and write your own CSS against the widget's stable class hooks.
Key hooks:
.ouisys-subscription-widget widget root
.flow-pin / .flow-mo / .flow-moredir / .flow-oneclick / .flow-click2sms / .flow-ussd
.phone-entry-step .text-input .btn .flag .country-code
.pin-entry-form .pin-input .pinEntryLabel .wrongPinMessage .link-try-again
.mo__instruction .mo-link-verify-sms
.tq-step .error-msg .labelLevel 3 — Slot overrides
Replace any step's entire markup with your own JSX:
<SubscriptionWidget
config={config}
slots={{
phone: ({ submitMsisdn }) => <MyPhoneForm onSubmit={submitMsisdn} />,
thankYou: ({ finalUrl }) => <MySuccessScreen href={finalUrl} />,
}}
/>Available slots: phone, pin, mo, moRedir, oneClick, click2sms, ussd, operatorSelect, loader, thankYou.
Localization
Override any English default by passing messages:
OuisysSubscribe.mount('#subscribe', {
config: window.configJson,
locale: 'ar',
messages: {
msisdnLabel: 'أدخل رقم هاتفك',
msisdnButton: 'متابعة',
pinLabel: 'أدخل رمز PIN',
pinButton: 'تأكيد',
congratsTitle: 'تهانينا!',
congratsText: 'لقد اشتركت بنجاح في الخدمة.'
}
});All message IDs are in src/messages.ts.
Preview and QA
Preview any flow without a backend — no carrier or API needed.
yarn demo
# Open http://localhost:8090/?ui_flow=pinAvailable ui_flow values: pin, mo, moRedir, oneClick, click2sms, ussd, operatorSelect, loader.
Add &ui_step=phone or &ui_step=thankyou to jump to a specific step.
Or force a flow in code:
<SubscriptionWidget config={config} previewFlow="oneClick" />When a flow is forced, the engine's strategy identification is skipped entirely.
Build and publish
yarn dev # live-reload dev server with real engine and backend
yarn demo # all flows, no backend
yarn build:widget # → embed/ouisys-dcb-widget.js (UMD, self-contained)
yarn test # Jest suite
yarn test:watch # watch modePublishing to npm
# Log in to npm under the @sam-media-2 org
npm login
# Dry run — see exactly what will be published
npm publish --dry-run
# Publish
npm publishprepublishOnly runs yarn build:widget automatically before every publish, so the bundle is always up to date.
Deploying the hosted version (S3 / staging.mouisys.com)
yarn deploy:embed # build:widget then upload to S3
yarn upload:embed # upload only (skips build — uses existing dist/)Requires osui_aws_access_key_id and osui_secret_access_key in the environment.
Uploads to s3://mobirun/os-ui/static/eamobi/dcb/ → https://staging.mouisys.com/os-ui/static/eamobi/dcb/.
Every deploy publishes three JS artifacts:
| File | Cache | Purpose |
| --- | --- | --- |
| ouisys-dcb-widget.js | max-age=60 | Stable URL — safe to hard-code in landing pages |
| ouisys-dcb-widget.<hash>.js | immutable | Content-hashed — guaranteed exact build |
| embed-manifest.json | no-store | Always-fresh JSON pointer to the current hashed URL |
Using embed-manifest.json
fetch('https://staging.mouisys.com/os-ui/static/eamobi/dcb/embed-manifest.json')
.then(r => r.json())
.then(function(manifest) {
var s = document.createElement('script');
s.src = manifest.src; // immutable hashed URL — zero CDN lag
document.head.appendChild(s);
});Testing
Tests live in src/__tests__/. The entire ouisys-engine is replaced with a controllable mock (src/test/ouisysEngineMock.tsx) — all widget providers, context, event emitter, and UI render for real.
import { __setFlowState, __setStrategyState, rdsNotAsked, rdsFailure } from '../test/ouisysEngineMock';
// Put the widget in PIN entry step
__setFlowState('pin', { __branch: 'pinEntry', rds: rdsNotAsked(), nextData: { operator: 'SA_MOBILY' } });
// Assert events
const onEvent = jest.fn();
render(<SubscriptionWidget config={config} onEvent={onEvent} />);
expect(onEvent).toHaveBeenCalledWith('ouisys.phone.success', { msisdn: '966512345678' });Source layout
src/
├── SubscriptionWidget.tsx root component — strategy switch + lifecycle events
├── embed.tsx UMD entry — OuisysSubscribe.mount / update / submit / unmount
├── index.tsx React package entry
├── types.ts WidgetConfig, Slots, SubscriptionWidgetProps, PendingAction
├── events.ts createEmitter(el, onEvent?, redact?) → EmitFn; WidgetEventName
├── messages.ts English default labels + all message IDs
├── slots.ts resolveSlot() helper
├── providers/
│ ├── WidgetContext.tsx context: config, msisdn, consent, emit, pendingAction/Replay
│ ├── store.ts Redux store (engine reducers)
│ └── intl.tsx react-intl provider with message merging
├── flows/ one component per flow; each owns consent intercept + event emission
├── steps/ PhoneEntryStep, PINEntryStep, ThankYouStep, etc.
├── ui/ Button, PhoneInput, PinEntryForm, PricePoint, etc.
├── demo/
│ ├── main.tsx yarn dev entry (real engine, live backend)
│ └── demoEngine.tsx yarn demo mock (all flows, no backend)
└── __tests__/ Jest + RTL tests for every flow and stepCommon gotchas
Unexpected state type: undefined — setConfig race condition. WidgetContext.tsx uses useLayoutEffect (not useEffect) to guarantee config is set before identifyStrategy reads it. Never change this back to useEffect.
Strategy not supported — config.strategy is in camelCase (e.g. 'oneClick'). The engine only accepts hyphen-case. See the strategy mapping table above.
handle.submit() does nothing — submit() replays a buffered pending action. There is no buffered action until the user presses submit with consentValid: false. Only call submit() from your consent modal's accept handler, after ouisys.consent.required has fired.
PIN error not fired on re-entry — Re-submitting a previously-failed PIN triggers a local re-entry guard in PinEntryForm before the engine is involved. The event fires as ouisys.pin.error with error: 'WrongPinReEntry'.
One-click button never appears — pac_analytics.visitor.heMsisdnResult.msisdn must be set. The carrier injects this on a live data connection. In dev, mock it manually.
Try-again link not visible after PIN failure — PINEntryStep renders .link-try-again in both nothingYet and failure RDS states. It is hidden only during loading and after success.
