@jolibox/implement 1.2.8 → 1.2.9-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/package-deps_build.json +37 -27
- package/dist/common/rewards/registers/use-gem-only.d.ts +2 -1
- package/dist/common/rewards/registers/use-gem.d.ts +2 -1
- package/dist/common/rewards/registers/use-jolicoin-only.d.ts +2 -1
- package/dist/common/rewards/registers/use-jolicoin.d.ts +2 -1
- package/dist/common/rewards/registers/use-subscription.d.ts +2 -1
- package/dist/common/rewards/registers/utils/coins/jolicoin/jolicoin-handler.d.ts +1 -0
- package/dist/common/rewards/registers/utils/coins/joligem/gem-handler.d.ts +2 -1
- package/dist/common/rewards/registers/utils/common.d.ts +1 -0
- package/dist/common/rewards/registers/utils/subscription/sub-handler.d.ts +2 -1
- package/dist/common/rewards/reward-emitter.d.ts +8 -0
- package/dist/common/rewards/type.d.ts +1 -1
- package/dist/common/utils/index.d.ts +1 -1
- package/dist/index.js +25 -25
- package/dist/index.native.js +54 -54
- package/dist/native/payment/utils/__tests__/cache-with-storage.test.d.ts +1 -0
- package/dist/native/payment/utils/cache-with-storage.d.ts +7 -0
- package/dist/native/rewards/check-frequency.d.ts +8 -0
- package/dist/native/rewards/index.d.ts +1 -0
- package/dist/native/rewards/ui/subscription-modal.d.ts +1 -0
- package/dist/native/subscription/index.d.ts +3 -0
- package/dist/native/subscription/registers/base.d.ts +22 -0
- package/dist/native/subscription/registers/sub-app.d.ts +21 -0
- package/dist/native/subscription/registers/type.d.ts +10 -0
- package/dist/native/subscription/subscription-helper.d.ts +22 -0
- package/dist/native/subscription/subscription-service.d.ts +43 -0
- package/dist/native/subscription/type.d.ts +18 -0
- package/implement.build.log +2 -2
- package/package.json +5 -5
- package/src/common/context/index.ts +4 -1
- package/src/common/rewards/registers/use-gem-only.ts +5 -2
- package/src/common/rewards/registers/use-gem.ts +5 -2
- package/src/common/rewards/registers/use-jolicoin-only.ts +5 -2
- package/src/common/rewards/registers/use-jolicoin.ts +5 -2
- package/src/common/rewards/registers/use-subscription.ts +5 -2
- package/src/common/rewards/registers/utils/coins/jolicoin/jolicoin-handler.ts +33 -11
- package/src/common/rewards/registers/utils/coins/joligem/gem-handler.ts +34 -13
- package/src/common/rewards/registers/utils/common.ts +9 -0
- package/src/common/rewards/registers/utils/subscription/commands/use-subscription.ts +16 -0
- package/src/common/rewards/registers/utils/subscription/sub-handler.ts +22 -6
- package/src/common/rewards/reward-emitter.ts +8 -0
- package/src/common/rewards/type.ts +1 -1
- package/src/common/utils/index.ts +1 -1
- package/src/h5/api/ads.ts +6 -3
- package/src/h5/bootstrap/auth/__tests__/auth.test.ts +15 -9
- package/src/h5/bootstrap/auth/sub.ts +1 -1
- package/src/native/api/ads.ts +43 -6
- package/src/native/api/call-host-method.ts +5 -61
- package/src/native/api/login.ts +22 -7
- package/src/native/api/payment.ts +78 -3
- package/src/native/payment/__tests__/payment-service-simple.test.ts +14 -1
- package/src/native/payment/payment-service.ts +26 -27
- package/src/native/payment/utils/__tests__/cache-with-storage.test.ts +414 -0
- package/src/native/payment/utils/cache-with-storage.ts +112 -0
- package/src/native/rewards/check-frequency.ts +6 -0
- package/src/native/rewards/index.ts +1 -0
- package/src/native/rewards/ui/payment-modal.ts +2 -2
- package/src/native/rewards/ui/subscription-modal.ts +81 -0
- package/src/native/subscription/index.ts +12 -0
- package/src/native/subscription/registers/base.ts +88 -0
- package/src/native/subscription/registers/sub-app.ts +258 -0
- package/src/native/subscription/registers/type.ts +13 -0
- package/src/native/subscription/subscription-helper.ts +53 -0
- package/src/native/subscription/subscription-service.ts +339 -0
- package/src/native/subscription/type.ts +18 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { ISubscriptionPlanData } from './type';
|
|
2
|
+
import { ISubscriptionTierData } from '@jolibox/types';
|
|
3
|
+
import { Deferred, isUndefinedOrNull } from '@jolibox/common';
|
|
4
|
+
import { StandardResponse } from '@jolibox/types';
|
|
5
|
+
import { applyNative, onNative } from '@jolibox/native-bridge';
|
|
6
|
+
import { innerFetch as fetch } from '@/native/network';
|
|
7
|
+
import { RequestCacheService, RequestAdapter } from '@jolibox/common';
|
|
8
|
+
import { executeAndCache, executeWithPersistentCache } from '../payment/utils/cache-with-storage';
|
|
9
|
+
import { subscriptionHelper } from './subscription-helper';
|
|
10
|
+
import { invokeNative } from '@jolibox/native-bridge';
|
|
11
|
+
import { createSubscriptionInternalError } from './registers/base';
|
|
12
|
+
import { SubscriptionErrorCodeMap } from './registers/type';
|
|
13
|
+
import { ResponseType } from '@jolibox/types';
|
|
14
|
+
|
|
15
|
+
// Request/Response interfaces for RequestCacheService
|
|
16
|
+
type SubscriptionRequest = Record<string, never>;
|
|
17
|
+
|
|
18
|
+
type SubscriptionResponse = ISubscriptionTierData[];
|
|
19
|
+
|
|
20
|
+
interface SubscriptionCacheData {
|
|
21
|
+
subscriptionTiers: ISubscriptionTierData[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Utility function for merging native pricing data with subscription tiers
|
|
25
|
+
function mergeSubscriptionPriceData(
|
|
26
|
+
responseData: ISubscriptionTierData[],
|
|
27
|
+
nativePriceData: { [appStoreProductId: string]: ISubscriptionPlanData }
|
|
28
|
+
) {
|
|
29
|
+
Object.keys(nativePriceData).forEach((productId) => {
|
|
30
|
+
const tier = responseData.find((tier) => tier.appStoreProductId === productId);
|
|
31
|
+
if (tier) {
|
|
32
|
+
tier.amountStr = nativePriceData[productId].pricing.formattedPrice;
|
|
33
|
+
tier.originalAmountStr = nativePriceData[productId].pricing.originalPrice;
|
|
34
|
+
tier.discountPercentage = nativePriceData[productId].pricing.discountPercentage?.toString() ?? '';
|
|
35
|
+
tier.trialDay = nativePriceData[productId].trial?.duration ?? 0;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Filter out tiers without pricing information
|
|
40
|
+
responseData = responseData.filter((tier) => !isUndefinedOrNull(tier.amountStr)) as ISubscriptionTierData[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Subscription Request Adapter for RequestCacheService
|
|
44
|
+
class SubscriptionRequestAdapter
|
|
45
|
+
implements RequestAdapter<SubscriptionRequest, SubscriptionResponse, SubscriptionCacheData, never>
|
|
46
|
+
{
|
|
47
|
+
// Native price cache for subscription tiers
|
|
48
|
+
private static nativePriceCache = new Map<string, { [appStoreProductId: string]: ISubscriptionPlanData }>();
|
|
49
|
+
private static readonly NATIVE_PRICE_CACHE_DURATION = 1 * 24 * 60 * 60 * 1000; // 1 days
|
|
50
|
+
private static nativePriceCacheTimestamp = new Map<string, number>();
|
|
51
|
+
|
|
52
|
+
static getNativePriceCache(appStoreProductIds: string[]): {
|
|
53
|
+
[appStoreProductId: string]: ISubscriptionPlanData;
|
|
54
|
+
} {
|
|
55
|
+
const cacheKey = appStoreProductIds.sort().join(',');
|
|
56
|
+
return this.nativePriceCache.get(cacheKey) ?? {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async execute(endpoint: string, _options?: SubscriptionRequest): Promise<SubscriptionResponse> {
|
|
60
|
+
// 获取服务端订阅档位数据
|
|
61
|
+
const { response } = await fetch<StandardResponse<ISubscriptionTierData[]>>(endpoint, {
|
|
62
|
+
method: 'GET',
|
|
63
|
+
appendHostCookie: true,
|
|
64
|
+
responseType: 'json'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!response.data?.data) {
|
|
68
|
+
throw new Error('get subscription tiers failed');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const serverData = response.data.data;
|
|
72
|
+
|
|
73
|
+
const appStoreProductIds =
|
|
74
|
+
serverData
|
|
75
|
+
?.filter((tier: ISubscriptionTierData) => typeof tier.appStoreProductId === 'string')
|
|
76
|
+
.map((tier) => tier.appStoreProductId as string) ?? [];
|
|
77
|
+
|
|
78
|
+
// 如果有需要查询原生价格的产品,则获取价格信息
|
|
79
|
+
if (appStoreProductIds.length > 0) {
|
|
80
|
+
const nativePriceData = await this.getNativePriceData(appStoreProductIds);
|
|
81
|
+
if (nativePriceData) {
|
|
82
|
+
mergeSubscriptionPriceData(serverData, nativePriceData);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (serverData.length === 0) {
|
|
87
|
+
throw new Error('subscription tiers is empty');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return serverData;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async requestNativeSubscriptionPlans(
|
|
94
|
+
appStoreProductIds: string[],
|
|
95
|
+
cacheKey: string
|
|
96
|
+
): Promise<{ [appStoreProductId: string]: ISubscriptionPlanData }> {
|
|
97
|
+
try {
|
|
98
|
+
const { data } = await applyNative('requestSubscriptionPlansAsync', {
|
|
99
|
+
appStoreProductIds,
|
|
100
|
+
type: 'game'
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (data) {
|
|
104
|
+
SubscriptionRequestAdapter.nativePriceCache.set(cacheKey, data);
|
|
105
|
+
SubscriptionRequestAdapter.nativePriceCacheTimestamp.set(cacheKey, Date.now());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return data;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.warn('[SubscriptionService] Failed to fetch native subscription pricing:', error);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async getNativePriceData(
|
|
116
|
+
appStoreProductIds: string[],
|
|
117
|
+
forceRefresh = false
|
|
118
|
+
): Promise<{ [appStoreProductId: string]: ISubscriptionPlanData } | null> {
|
|
119
|
+
const cacheKey = appStoreProductIds.sort().join(',');
|
|
120
|
+
|
|
121
|
+
const executor = forceRefresh ? executeAndCache : executeWithPersistentCache;
|
|
122
|
+
return executor(cacheKey, () => this.requestNativeSubscriptionPlans(appStoreProductIds, cacheKey), {
|
|
123
|
+
storageKeyPrefix: 'joli_subscription_price_'
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
extractCacheableData(response: SubscriptionResponse): SubscriptionCacheData {
|
|
128
|
+
return {
|
|
129
|
+
subscriptionTiers: response
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
extractRealTimeData?: undefined;
|
|
134
|
+
|
|
135
|
+
mergeData(): never {
|
|
136
|
+
throw new Error('SubscriptionService does not support real-time data merging');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
processCachedData(cached: SubscriptionCacheData): SubscriptionResponse {
|
|
140
|
+
return cached.subscriptionTiers;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Update native price cache method
|
|
144
|
+
async updateNativePriceCache(appStoreProductIds: string[]): Promise<void> {
|
|
145
|
+
await this.getNativePriceData(appStoreProductIds, true);
|
|
146
|
+
console.log('[SubscriptionService] Updated native price cache for products:', appStoreProductIds);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Clear native price cache static method
|
|
150
|
+
static clearNativePriceCache(): void {
|
|
151
|
+
this.nativePriceCache.clear();
|
|
152
|
+
this.nativePriceCacheTimestamp.clear();
|
|
153
|
+
console.log('[SubscriptionService] Cleared native price cache');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Base Subscription Service class
|
|
158
|
+
class BaseSubscriptionService extends RequestCacheService<
|
|
159
|
+
SubscriptionRequest,
|
|
160
|
+
SubscriptionResponse,
|
|
161
|
+
SubscriptionCacheData,
|
|
162
|
+
never
|
|
163
|
+
> {
|
|
164
|
+
private pendingSubscriptions = new Map<
|
|
165
|
+
string,
|
|
166
|
+
Deferred<StandardResponse<{ result: 'SUCCESS' | 'FAILED'; subPlanId?: string }>>
|
|
167
|
+
>();
|
|
168
|
+
|
|
169
|
+
private subInfoCacheKeys: string[] = [];
|
|
170
|
+
constructor(private apiEndpoint: string) {
|
|
171
|
+
super(new SubscriptionRequestAdapter(), {
|
|
172
|
+
duration: 30 * 60 * 1000, // 30分钟缓存订阅档位
|
|
173
|
+
timeout: 3000 // 3秒超时
|
|
174
|
+
});
|
|
175
|
+
this.listenSubscriptionPanelStateChange();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async getSubInfo(): Promise<SubscriptionResponse | undefined> {
|
|
179
|
+
try {
|
|
180
|
+
const result = await this.request(this.apiEndpoint);
|
|
181
|
+
this.subInfoCacheKeys = result?.map((tier) => tier.appStoreProductId ?? '') ?? [];
|
|
182
|
+
return result;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.warn(`[SubscriptionService] getSubInfo failed for ${this.apiEndpoint}:`, error);
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 强制刷新订阅档位信息
|
|
190
|
+
async refreshSubInfo(): Promise<SubscriptionResponse | undefined> {
|
|
191
|
+
return this.forceRequest(this.apiEndpoint);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async updateNativePriceCache(appStoreProductIds: string[]): Promise<void> {
|
|
195
|
+
const adapter = this.requestAdapter as SubscriptionRequestAdapter;
|
|
196
|
+
await adapter.updateNativePriceCache(appStoreProductIds);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async updateAllNativePriceCache(): Promise<void> {
|
|
200
|
+
try {
|
|
201
|
+
if (this.subInfoCacheKeys.length > 0) {
|
|
202
|
+
await this.updateNativePriceCache(this.subInfoCacheKeys);
|
|
203
|
+
this.forceRequest(this.apiEndpoint);
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.warn('[SubscriptionService] Failed to update all native price cache:', error);
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// subscribe
|
|
212
|
+
async subscribe(params: {
|
|
213
|
+
basePlanId: string;
|
|
214
|
+
nativeProductId: string;
|
|
215
|
+
appStoreProductId: string;
|
|
216
|
+
}): Promise<{ subPlanId: string; message: string; result: 'SUCCESS' | 'FAILED' | 'FAILURE_SUBSCRIPTED' }> {
|
|
217
|
+
console.log('[SubscriptionService] subscribe params', subscriptionHelper);
|
|
218
|
+
const result = await subscriptionHelper.invokeSubscription('SUB_APP', {
|
|
219
|
+
productId: params.nativeProductId,
|
|
220
|
+
appStoreProductId: params.appStoreProductId,
|
|
221
|
+
planId: params.basePlanId
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
result: result.code as 'SUCCESS' | 'FAILED' | 'FAILURE_SUBSCRIPTED',
|
|
226
|
+
subPlanId: result.data?.subPlanId,
|
|
227
|
+
message: result.message
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async invokeSubscriptionPanel(): Promise<
|
|
232
|
+
StandardResponse<{ result: 'SUCCESS' | 'FAILED'; subPlanId?: string }>
|
|
233
|
+
> {
|
|
234
|
+
const deferred = new Deferred<StandardResponse<{ result: 'SUCCESS' | 'FAILED' }>>();
|
|
235
|
+
let targetOrderUUID: string | undefined;
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
console.log('[invokeSubscriptionPanel] requestSubscriptionSync params');
|
|
239
|
+
const response = invokeNative('invokeSubscriptionPanelSync', {
|
|
240
|
+
type: 'game'
|
|
241
|
+
});
|
|
242
|
+
targetOrderUUID = response.data?.subUUID;
|
|
243
|
+
|
|
244
|
+
if (!targetOrderUUID) {
|
|
245
|
+
throw createSubscriptionInternalError(
|
|
246
|
+
'orderUUID is null',
|
|
247
|
+
SubscriptionErrorCodeMap.SubscriptionFailed
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this.pendingSubscriptions.set(targetOrderUUID, deferred);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
throw createSubscriptionInternalError(
|
|
254
|
+
JSON.stringify(e) ?? 'subscription panel failed',
|
|
255
|
+
SubscriptionErrorCodeMap.SubscriptionFailed
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return deferred.promise;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// get tier by product id
|
|
263
|
+
async getTierByProductId(
|
|
264
|
+
productId: string
|
|
265
|
+
): Promise<{ nativeProductId: string; appStoreProductId: string; basePlanId: string } | undefined> {
|
|
266
|
+
const serverSubInfos = await this.getSubInfo();
|
|
267
|
+
const serverSubInfo = serverSubInfos?.find((tier) => tier.productId === productId);
|
|
268
|
+
|
|
269
|
+
if (!serverSubInfo) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
const nativeSubInfos = SubscriptionRequestAdapter.getNativePriceCache(
|
|
273
|
+
(serverSubInfos ?? []).map((tier) => tier.appStoreProductId ?? '')
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
if (!nativeSubInfos) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const nativeSubInfo = nativeSubInfos[serverSubInfo.appStoreProductId ?? ''];
|
|
280
|
+
if (!nativeSubInfo) {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
console.log('[SubscriptionService] getTierByProductId nativeSubInfo', nativeSubInfo);
|
|
285
|
+
return {
|
|
286
|
+
nativeProductId: nativeSubInfo.productId,
|
|
287
|
+
appStoreProductId: serverSubInfo.appStoreProductId!,
|
|
288
|
+
basePlanId: nativeSubInfo.basePlanId
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private listenSubscriptionPanelStateChange(): void {
|
|
293
|
+
onNative('onSubscriptionPanelStateChange', (data) => {
|
|
294
|
+
console.log('[onSubscriptionPanelStateChange] data', data);
|
|
295
|
+
|
|
296
|
+
const deferred = this.pendingSubscriptions.get(data.subUUID);
|
|
297
|
+
if (!deferred) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (data.status === 'SUCCESS') {
|
|
301
|
+
deferred.resolve({
|
|
302
|
+
code: 'SUCCESS' as ResponseType,
|
|
303
|
+
message: 'subscription panel success',
|
|
304
|
+
data: { result: 'SUCCESS', subPlanId: data.orderResponse?.subPlanId }
|
|
305
|
+
});
|
|
306
|
+
} else {
|
|
307
|
+
deferred.resolve({
|
|
308
|
+
code: 'FAILED' as ResponseType,
|
|
309
|
+
message: 'subscription panel failed',
|
|
310
|
+
data: { result: 'FAILED' }
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Subscription Service implementation
|
|
318
|
+
export class SubscriptionService extends BaseSubscriptionService {
|
|
319
|
+
constructor() {
|
|
320
|
+
super('/api/subs/pricing');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Service instance management
|
|
325
|
+
let subscriptionServiceInstance: SubscriptionService | null = null;
|
|
326
|
+
|
|
327
|
+
// Create subscription service instance
|
|
328
|
+
export const createSubscriptionService = (): SubscriptionService => {
|
|
329
|
+
if (subscriptionServiceInstance) {
|
|
330
|
+
return subscriptionServiceInstance;
|
|
331
|
+
}
|
|
332
|
+
subscriptionServiceInstance = new SubscriptionService();
|
|
333
|
+
return subscriptionServiceInstance;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Export utility methods for testing
|
|
337
|
+
export const clearSubscriptionNativePriceCache = (): void => {
|
|
338
|
+
SubscriptionRequestAdapter.clearNativePriceCache();
|
|
339
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ISubscriptionPlanData {
|
|
2
|
+
basePlanId: string;
|
|
3
|
+
productId: string;
|
|
4
|
+
pricing: {
|
|
5
|
+
formattedPrice: string;
|
|
6
|
+
originalPrice?: string;
|
|
7
|
+
discountPercentage?: number;
|
|
8
|
+
};
|
|
9
|
+
trial?: {
|
|
10
|
+
duration: number;
|
|
11
|
+
description?: string;
|
|
12
|
+
};
|
|
13
|
+
promotion?: {
|
|
14
|
+
type: 'new_user' | 'limited_time' | 'winback';
|
|
15
|
+
badge: string;
|
|
16
|
+
description: string;
|
|
17
|
+
};
|
|
18
|
+
}
|