@superlikers/microsites-api
v0.2.0
Published
Typed Angular services for the Superlikers microsites backend
Maintainers
Readme
@superlikers/microsites-api
Typed Angular services for the Superlikers microsites backend (session, participant, entries, prizes, goals, ranking…).
Pre-1.0: the API may still change between minor versions (
0.1→0.2). Pin a minor range (~0.1.0) and check the changelog before upgrading.
Compatibility
| @superlikers/microsites-api | Angular | RxJS |
| ----------------------------- | ------- | ------ |
| 0.x | ^22.0 | ^7.8 |
Installation
pnpm add @superlikers/microsites-api
# or: npm install @superlikers/microsites-apiSetup
Register the library once in app.config.ts:
import { ApplicationConfig, inject } from '@angular/core'
import { Router } from '@angular/router'
import { provideMicrositesApi } from '@superlikers/microsites-api'
import { environment } from '../environments/environment'
export const appConfig: ApplicationConfig = {
providers: [
provideMicrositesApi({
baseUrl: environment.baseUrl, // e.g. https://4x.superlikerslabs.com
onUnauthorized: () => inject(Router).navigate(['/login']) // optional
})
]
}| Option | Required | Description |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| baseUrl | yes | Absolute http(s) URL of the microsite backend. A trailing slash is removed. An invalid value throws on startup. |
| onUnauthorized | no | Called on every 401 response, in an injection context (inject() works). The error is still thrown to the caller. |
HttpClient needs no extra setup. Every request is sent with withCredentials: true (session cookie) and Accept: application/json.
Usage
Inject a service and subscribe (or convert to a signal / promise). Every method returns a cold Observable, so nothing is sent until you subscribe.
import { Component, inject, signal } from '@angular/core'
import { isApiError, SessionService } from '@superlikers/microsites-api'
@Component({ selector: 'app-login', templateUrl: './login.html' })
export class Login {
readonly #session = inject(SessionService)
protected readonly error = signal('')
protected login(email: string, password: string) {
this.#session.login({ email, password }).subscribe({
next: () => this.error.set(''),
error: (error: unknown) => this.error.set(isApiError(error) ? error.message : 'Unexpected error')
})
}
}Services
| Service | Methods |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| SessionService | getLoginForm, login, logout, getSsoLoginUrl, checkLoginRequirements |
| RegistrationsService | getSignupForm, signup, updateParticipant, sendEmailVerificationToken, validateEmailToken, confirmEmail |
| PasswordService | changePassword, validateChannel, sendResetToken, resetPassword |
| ParticipantService | getCurrent, getRank, list, getByUid, savePushSubscription, removePushSubscriptions |
| EntriesService | listExternals, listRedemptions, listPhotos, listDocuments, listPurchases, list, search, createExternal |
| ExternalFormsService | listForms, getFormFields, submit, listSubmissions, getSubmission, updateSubmission |
| ActivitiesService | uploadPhoto, uploadDocument |
| PrizesService | list, checkRedeem, redeem, getWishlist, toggleWishlist, getExternalCouponData |
| GoalsService | listParticipantGoals, list, accept, decline |
| AchievementsService | list |
| BadgesService | list, get, getStats |
| RankingService | getSegment |
| MetricsService | list, calculate |
| BoardsService | list, listReports, executeReport |
| BlogsService | list, get, create |
| CommentsService | list, create |
| VotesService | get, create, update, delete |
| TeamsService | list, get, create, removeMember |
| ReferralsService | changeCode |
Each method documents its endpoint and parameters in its JSDoc (visible on hover in the editor). A method whose campaign module is disabled fails with an ApiError (see Errors).
Campaign-specific fields
Models hold the fields every campaign has; a field some campaigns don't return, such as email in campaigns
that don't ask for one, is optional. Campaigns add their own fields (participant properties, goal data, external action data…), so the services that return them take a type parameter. Extend the Base* model and pass your full interface:
import { BaseCurrentParticipant, ParticipantService } from '@superlikers/microsites-api'
interface CurrentParticipant extends BaseCurrentParticipant {
distributor_code: string
region: string
}
inject(ParticipantService)
.getCurrent<CurrentParticipant>()
.subscribe(({ participation }) => console.log(participation.region))| Type parameter of | Default |
| ---------------------------------------------------------- | ------------------------- |
| ParticipantService.getCurrent | BaseCurrentParticipant |
| ParticipantService.list / getByUid | BaseParticipant |
| ParticipantService.getRank | BaseParticipantRankData |
| GoalsService.list / listParticipantGoals (goal_data) | GoalData |
| EntriesService.listExternals (data) | EntryData |
| ExternalFormsService.listSubmissions / getSubmission | EntryData |
| BlogsService / CommentsService (embedded participant) | ActivityParticipant |
Entries
EntriesService has one method per common entry type (listExternals, listRedemptions, listPhotos, listDocuments, listPurchases), each typed with its own model. For other types, or several at once, use list() and narrow each item with isEntryType:
import { EntriesService, isEntryType } from '@superlikers/microsites-api'
inject(EntriesService)
.list({ _type: 'ExtraPoints' })
.subscribe(({ data }) => {
for (const entry of data) {
if (isEntryType(entry, 'ExtraPoints')) console.log(entry.concept)
}
})Every entries method uses the backend's detailed show view, where the external action slug arrives in atype.
Entries of a type the library doesn't model yet are returned as UnknownEntry: the common fields are typed and the rest are unknown.
Query parameters
Parameters are serialized the way the backend expects: null and undefined are skipped, arrays are sent as key[]=a&key[]=b, and objects as key[sub]=value, for example date_filter[start]=….
Errors
Every failure is thrown as an ApiError. This includes the failures the backend reports with HTTP 200: the ones
with state: 'error', and a rejected redemption (success: false). So every error is handled in the error
callback, and a response that reaches the next callback is a success.
Its message is meant to be shown to the participant: it is the backend's message, or a Spanish default when the
backend sends none.
| Property | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------- |
| message | Backend message, or a default one, ready to show to the participant. |
| status | HTTP status. 0 = the request never reached the server. |
| code | Backend error code (code_error), or null. |
| fieldErrors | Validation errors by field, e.g. { email: 'is invalid' }. Empty object when there are none. |
| url | URL of the failed request. |
| isNetworkError | status === 0 (offline, CORS, DNS…). |
| isUnauthorized | status === 401. |
| isInvalidResponse | The server answered with something that is not JSON, usually because the module is disabled for the campaign. |
| cause | The original HttpErrorResponse, whose error holds the backend's body. |
Use isApiError(error) to narrow an unknown error.
A rejected redemption (not enough points, over the limit…) arrives as an ApiError whose message is the
backend's missing_message; the whole body, typed by RedeemFailure, is in cause:
prizes.redeem(prize._id).subscribe({
next: redemption => this.code.set(redemption.code),
error: (error: unknown) => {
if (!isApiError(error)) return
this.message.set(error.message) // e.g. "Te faltan 500 puntos"
const body = (error.cause as HttpErrorResponse).error as RedeemFailure
this.missingPoints.set(body.missing.points)
}
})Testing (@superlikers/microsites-api/testing)
Helpers for your app's unit tests. Import them only from spec files.
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { SessionService } from '@superlikers/microsites-api'
import { MICROSITES_API_TEST_BASE_URL, provideMicrositesApiTesting } from '@superlikers/microsites-api/testing'
TestBed.configureTestingModule({ providers: [provideMicrositesApiTesting()] })
const http = TestBed.inject(HttpTestingController)
TestBed.inject(SessionService).logout().subscribe()
http.expectOne(`${MICROSITES_API_TEST_BASE_URL}/sessions/logout`).flush({ state: 'success', message: 'ok' })| Export | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| provideMicrositesApiTesting | Library config + HttpClient + HttpTestingController. Accepts config overrides (e.g. onUnauthorized). |
| MICROSITES_API_TEST_BASE_URL | The baseUrl used in tests, to match requests. |
| createApiError | Builds an ApiError as the backend would, to simulate failures with mocked services. |
import { throwError } from 'rxjs'
import { createApiError } from '@superlikers/microsites-api/testing'
{ provide: SessionService, useValue: { login: () => throwError(() => createApiError({ code: 141, message: 'Wrong password' })) } }Forms (@superlikers/microsites-api/forms)
Helpers for Angular reactive forms. This entry point requires @angular/forms, an optional peer dependency that Angular apps usually already have.
channelValidator(channel, options?) is an async validator that checks, against the backend, that a participant exists for an email or phone number. It is meant for "forgot password" forms. Call it in an injection context, such as a field initializer.
import { channelValidator } from '@superlikers/microsites-api/forms'
readonly form = inject(FormBuilder).nonNullable.group({
email: ['', [Validators.required, Validators.email], [channelValidator('email')]]
})@if (form.controls.email.errors?.['channel']; as error) {
<p>{{ error.message }}</p>
}It waits 500 ms after the last change before calling the backend ({ debounceMs } to change it) and leaves empty values to Validators.required.
Versioning
The package follows semver, independent of Angular's version. While in 0.x:
0.MINOR.0: may contain breaking changes.0.x.PATCH: new features and fixes, no breaking changes.
Every release is documented in the changelog.
