TorbatYar/frontend/modules/loyalty/services/loyalty-api.ts
Mortezakoohjani d579d0b142 feat(loyalty): add Loyalty Platform Frontend module
Add frontend/modules/loyalty with types, API client, design system, feature pages and thin App Router routes under app/loyalty/. BFF proxy at app/api/loyalty/. Include loyalty frontend docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:50:55 +03:30

558 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Loyalty Platform Service API client — real backend only (Phase 7.07.6).
* Browser → /api/loyalty BFF; SSR → direct upstream.
*/
import { getValidAccessToken, refreshSession } from "@/lib/auth";
import type {
AuditEntry,
Campaign,
CampaignApplication,
CampaignApplicationPage,
HealthResponse,
ListParams,
Member,
MembershipLifecycleEvent,
MembershipTier,
LoyaltyProgram,
PointAccount,
PointBalance,
PointLedgerEntry,
PointLedgerPage,
ReferralAttribution,
ReferralAttributionPage,
ReferralCode,
ReferralCodePage,
ReferralProgram,
Reward,
RewardRedemption,
RewardRedemptionPage,
WalletAccount,
WalletBalance,
WalletLedgerEntry,
WalletLedgerPage,
WalletTransferResult,
} from "@/modules/loyalty/types";
const LOYALTY_BASE =
typeof window !== "undefined"
? "/api/loyalty"
: process.env.LOYALTY_SERVICE_URL ||
process.env.NEXT_PUBLIC_LOYALTY_API_URL ||
"http://localhost:8004";
export class LoyaltyApiError extends Error {
constructor(
public status: number,
public code: string,
message: string
) {
super(message);
this.name = "LoyaltyApiError";
}
}
type RequestOptions = RequestInit & { tenantId: string; auth?: boolean };
async function request<T>(path: string, options: RequestOptions): Promise<T> {
const { tenantId, auth = true, headers: customHeaders, ...init } = options;
const headers = new Headers(customHeaders);
if (!headers.has("Content-Type") && init.body) {
headers.set("Content-Type", "application/json");
}
headers.set("X-Tenant-ID", tenantId);
if (auth) {
const token = await getValidAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
}
const url = path.startsWith("http")
? path
: `${LOYALTY_BASE.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
let res: Response;
try {
res = await fetch(url, { ...init, headers });
} catch (err) {
const detail = err instanceof Error ? err.message : "network_error";
throw new Error(`اتصال به سرویس وفاداری برقرار نشد (${url}) — ${detail}`);
}
if (res.status === 401 && auth) {
const refreshed = await refreshSession({ force: true });
if (refreshed) {
headers.set("Authorization", `Bearer ${refreshed}`);
res = await fetch(url, { ...init, headers });
}
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new LoyaltyApiError(
res.status,
body?.error?.code || body?.detail || "unknown_error",
body?.error?.message || body?.detail || res.statusText
);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
function qs(params?: Record<string, string | number | boolean | undefined | null>) {
if (!params) return "";
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const s = sp.toString();
return s ? `?${s}` : "";
}
function apiPath(segment: string) {
return `/api/v1${segment.startsWith("/") ? segment : `/${segment}`}`;
}
const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
export type LedgerListParams = {
offset?: number;
limit?: number;
entry_type?: string;
q?: string;
};
export type WalletLedgerListParams = {
offset?: number;
limit?: number;
entry_type?: string;
};
export const loyaltyApi = {
health: () =>
request<HealthResponse>("/health", { tenantId: ZERO_UUID, auth: false }),
programs: {
list: (tenantId: string, params?: ListParams) =>
request<LoyaltyProgram[]>(`${apiPath("/programs")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<LoyaltyProgram>(apiPath(`/programs/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<LoyaltyProgram>(apiPath("/programs"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<LoyaltyProgram>(apiPath(`/programs/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<LoyaltyProgram>(apiPath(`/programs/${id}/delete`), {
tenantId,
method: "POST",
}),
},
tiers: {
list: (tenantId: string, params?: ListParams) =>
request<MembershipTier[]>(`${apiPath("/tiers")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<MembershipTier>(apiPath(`/tiers/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<MembershipTier>(apiPath("/tiers"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<MembershipTier>(apiPath(`/tiers/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<MembershipTier>(apiPath(`/tiers/${id}/delete`), {
tenantId,
method: "POST",
}),
},
members: {
list: (tenantId: string, params?: ListParams) =>
request<Member[]>(`${apiPath("/members")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<Member>(apiPath(`/members/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Member>(apiPath("/members"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Member>(apiPath(`/members/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
enroll: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Member>(apiPath(`/members/${id}/enroll`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
activate: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Member>(apiPath(`/members/${id}/activate`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
renew: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Member>(apiPath(`/members/${id}/renew`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
freeze: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Member>(apiPath(`/members/${id}/freeze`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
resume: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Member>(apiPath(`/members/${id}/resume`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
cancel: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Member>(apiPath(`/members/${id}/cancel`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
expire: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Member>(apiPath(`/members/${id}/expire`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
transfer: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Member>(apiPath(`/members/${id}/transfer`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
lifecycle: (tenantId: string, id: string, limit?: number) =>
request<MembershipLifecycleEvent[]>(
`${apiPath(`/members/${id}/lifecycle`)}${qs({ limit })}`,
{ tenantId }
),
delete: (tenantId: string, id: string) =>
request<Member>(apiPath(`/members/${id}/delete`), { tenantId, method: "POST" }),
},
pointAccounts: {
list: (tenantId: string, params?: ListParams) =>
request<PointAccount[]>(`${apiPath("/point-accounts")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<PointAccount>(apiPath(`/point-accounts/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<PointAccount>(apiPath("/point-accounts"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<PointAccount>(apiPath(`/point-accounts/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<PointAccount>(apiPath(`/point-accounts/${id}/delete`), {
tenantId,
method: "POST",
}),
balance: (tenantId: string, id: string) =>
request<PointBalance>(apiPath(`/point-accounts/${id}/balance`), { tenantId }),
ledger: (tenantId: string, id: string, params?: LedgerListParams) =>
request<PointLedgerPage>(`${apiPath(`/point-accounts/${id}/ledger`)}${qs(params)}`, {
tenantId,
}),
earn: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<PointLedgerEntry>(apiPath(`/point-accounts/${id}/earn`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
redeem: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<PointLedgerEntry>(apiPath(`/point-accounts/${id}/redeem`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
adjust: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<PointLedgerEntry>(apiPath(`/point-accounts/${id}/adjust`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
expirePoints: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<PointLedgerEntry>(apiPath(`/point-accounts/${id}/expire-points`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
rewards: {
list: (tenantId: string, params?: ListParams) =>
request<Reward[]>(`${apiPath("/rewards")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<Reward>(apiPath(`/rewards/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Reward>(apiPath("/rewards"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Reward>(apiPath(`/rewards/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<Reward>(apiPath(`/rewards/${id}/delete`), { tenantId, method: "POST" }),
redeem: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<RewardRedemption>(apiPath(`/rewards/${id}/redeem`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
redemptions: (tenantId: string, id: string, params?: ListParams) =>
request<RewardRedemptionPage>(
`${apiPath(`/rewards/${id}/redemptions`)}${qs(params)}`,
{ tenantId }
),
},
redemptions: {
list: (tenantId: string, params?: ListParams) =>
request<RewardRedemptionPage>(`${apiPath("/redemptions")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<RewardRedemption>(apiPath(`/redemptions/${id}`), { tenantId }),
fulfill: (tenantId: string, id: string) =>
request<RewardRedemption>(apiPath(`/redemptions/${id}/fulfill`), {
tenantId,
method: "POST",
}),
cancel: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<RewardRedemption>(apiPath(`/redemptions/${id}/cancel`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
campaigns: {
list: (tenantId: string, params?: ListParams) =>
request<Campaign[]>(`${apiPath("/campaigns")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<Campaign>(apiPath(`/campaigns/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Campaign>(apiPath("/campaigns"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Campaign>(apiPath(`/campaigns/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<Campaign>(apiPath(`/campaigns/${id}/delete`), { tenantId, method: "POST" }),
schedule: (tenantId: string, id: string) =>
request<Campaign>(apiPath(`/campaigns/${id}/schedule`), { tenantId, method: "POST" }),
activate: (tenantId: string, id: string) =>
request<Campaign>(apiPath(`/campaigns/${id}/activate`), { tenantId, method: "POST" }),
pause: (tenantId: string, id: string) =>
request<Campaign>(apiPath(`/campaigns/${id}/pause`), { tenantId, method: "POST" }),
resume: (tenantId: string, id: string) =>
request<Campaign>(apiPath(`/campaigns/${id}/resume`), { tenantId, method: "POST" }),
expire: (tenantId: string, id: string) =>
request<Campaign>(apiPath(`/campaigns/${id}/expire`), { tenantId, method: "POST" }),
rules: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Campaign>(apiPath(`/campaigns/${id}/rules`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
apply: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<CampaignApplication>(apiPath(`/campaigns/${id}/apply`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
applications: (tenantId: string, id: string, params?: ListParams) =>
request<CampaignApplicationPage>(
`${apiPath(`/campaigns/${id}/applications`)}${qs(params)}`,
{ tenantId }
),
},
referralPrograms: {
list: (tenantId: string, params?: ListParams) =>
request<ReferralProgram[]>(`${apiPath("/referral-programs")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<ReferralProgram>(apiPath(`/referral-programs/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<ReferralProgram>(apiPath("/referral-programs"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<ReferralProgram>(apiPath(`/referral-programs/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<ReferralProgram>(apiPath(`/referral-programs/${id}/delete`), {
tenantId,
method: "POST",
}),
},
referralCodes: {
create: (tenantId: string, body: Record<string, unknown>) =>
request<ReferralCode>(apiPath("/referral-codes"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
list: (tenantId: string, params?: ListParams & { referral_program_id?: string }) =>
request<ReferralCodePage>(`${apiPath("/referral-codes")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<ReferralCode>(apiPath(`/referral-codes/${id}`), { tenantId }),
revoke: (tenantId: string, id: string) =>
request<ReferralCode>(apiPath(`/referral-codes/${id}/revoke`), {
tenantId,
method: "POST",
}),
},
referrals: {
attribute: (tenantId: string, body: Record<string, unknown>) =>
request<ReferralAttribution>(apiPath("/referrals/attribute"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
list: (tenantId: string, params?: ListParams & { referral_program_id?: string }) =>
request<ReferralAttributionPage>(`${apiPath("/referrals")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<ReferralAttribution>(apiPath(`/referrals/${id}`), { tenantId }),
convert: (tenantId: string, id: string) =>
request<ReferralAttribution>(apiPath(`/referrals/${id}/convert`), {
tenantId,
method: "POST",
}),
reward: (tenantId: string, id: string) =>
request<ReferralAttribution>(apiPath(`/referrals/${id}/reward`), {
tenantId,
method: "POST",
}),
cancel: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<ReferralAttribution>(apiPath(`/referrals/${id}/cancel`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
wallets: {
list: (tenantId: string, params?: ListParams) =>
request<WalletAccount[]>(`${apiPath("/wallets")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<WalletAccount>(apiPath(`/wallets/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<WalletAccount>(apiPath("/wallets"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<WalletAccount>(apiPath(`/wallets/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<WalletAccount>(apiPath(`/wallets/${id}/delete`), { tenantId, method: "POST" }),
balance: (tenantId: string, id: string) =>
request<WalletBalance>(apiPath(`/wallets/${id}/balance`), { tenantId }),
ledger: (tenantId: string, id: string, params?: WalletLedgerListParams) =>
request<WalletLedgerPage>(`${apiPath(`/wallets/${id}/ledger`)}${qs(params)}`, { tenantId }),
credit: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<WalletLedgerEntry>(apiPath(`/wallets/${id}/credit`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
debit: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<WalletLedgerEntry>(apiPath(`/wallets/${id}/debit`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
adjust: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<WalletLedgerEntry>(apiPath(`/wallets/${id}/adjust`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
transfer: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<WalletTransferResult>(apiPath(`/wallets/${id}/transfer`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
audit: {
list: (
tenantId: string,
entityType: string,
entityId: string,
params?: { limit?: number }
) =>
request<AuditEntry[]>(
`${apiPath("/audit")}${qs({ entity_type: entityType, entity_id: entityId, ...params })}`,
{ tenantId }
),
},
};
/** Fetch all pages client-side (backend has no total count on plain list endpoints). */
export async function loyaltyFetchAll<T>(
fetchPage: (params: ListParams) => Promise<T[]>,
pageSize = 500
): Promise<T[]> {
const all: T[] = [];
let page = 1;
for (;;) {
const batch = await fetchPage({ page, page_size: pageSize });
all.push(...batch);
if (batch.length < pageSize) break;
page += 1;
if (page > 50) break;
}
return all;
}