@voyantjs/extras-contracts 0.90.0
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/README.md +30 -0
- package/dist/content-shape.d.ts +120 -0
- package/dist/content-shape.d.ts.map +1 -0
- package/dist/content-shape.js +91 -0
- package/dist/content-shape.test.d.ts +2 -0
- package/dist/content-shape.test.d.ts.map +1 -0
- package/dist/content-shape.test.js +38 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @voyantjs/extras-contracts
|
|
2
|
+
|
|
3
|
+
Pure extras content contracts for adapter implementers and external consumers
|
|
4
|
+
that need to validate `extras/v1` rich content payloads without installing the
|
|
5
|
+
full extras runtime package.
|
|
6
|
+
|
|
7
|
+
Use this package for `EXTRAS_CONTENT_SCHEMA_VERSION`, `extraContentSchema`,
|
|
8
|
+
`ExtraContent`, nested content types, and `validateExtraContent`. Use
|
|
9
|
+
`@voyantjs/extras` when you also need Drizzle schema, routes, services, booking
|
|
10
|
+
integration, catalog projection, or runtime content resolution (including the
|
|
11
|
+
`mergeOverlaysIntoExtraContent` overlay composition).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @voyantjs/extras-contracts zod
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
EXTRAS_CONTENT_SCHEMA_VERSION,
|
|
24
|
+
extraContentSchema,
|
|
25
|
+
type ExtraContent,
|
|
26
|
+
} from "@voyantjs/extras-contracts"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Existing `@voyantjs/extras/content-shape` imports remain available for
|
|
30
|
+
applications that already depend on the full runtime package.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extras content shape — the rich detail-page content shape returned
|
|
3
|
+
* by `getContent` for sourced extras (excursions, transfers, add-on
|
|
4
|
+
* services).
|
|
5
|
+
*
|
|
6
|
+
* The extras content aggregate is `{ extra, options[], media[],
|
|
7
|
+
* policies[] }` — one payload returned by a single `getContent`.
|
|
8
|
+
* Pricing stays out (volatile-live, flows through `liveResolve`).
|
|
9
|
+
*
|
|
10
|
+
* Extras are simpler than the other verticals because they're add-ons,
|
|
11
|
+
* not standalone products. There's no day-by-day itinerary, no
|
|
12
|
+
* room-type / cabin-category map, no ship spec — just an extra
|
|
13
|
+
* description, optional sub-options (e.g. "half-day vs full-day"),
|
|
14
|
+
* media, and the operational/cancellation policies.
|
|
15
|
+
*
|
|
16
|
+
* This module is the pure content contract: schemas, types, version, and
|
|
17
|
+
* the validator. The `mergeOverlaysIntoExtraContent` overlay composition
|
|
18
|
+
* stays in the `@voyantjs/extras` runtime package.
|
|
19
|
+
*
|
|
20
|
+
* See `docs/architecture/catalog-sourced-content.md` §3.2, §3.5.4, §3.6.
|
|
21
|
+
*/
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
export declare const EXTRAS_CONTENT_SCHEMA_VERSION = "extras/v1";
|
|
24
|
+
export declare const extraSummarySchema: z.ZodObject<{
|
|
25
|
+
id: z.ZodString;
|
|
26
|
+
name: z.ZodString;
|
|
27
|
+
status: z.ZodOptional<z.ZodString>;
|
|
28
|
+
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
29
|
+
selection_type: z.ZodOptional<z.ZodString>;
|
|
30
|
+
pricing_mode: z.ZodOptional<z.ZodString>;
|
|
31
|
+
priced_per_person: z.ZodOptional<z.ZodBoolean>;
|
|
32
|
+
category: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
33
|
+
hero_image_url: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
34
|
+
highlights: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
35
|
+
supplier: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
36
|
+
duration_minutes: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
37
|
+
requirements_summary: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
38
|
+
}, z.core.$strip>;
|
|
39
|
+
export declare const extraOptionSchema: z.ZodObject<{
|
|
40
|
+
id: z.ZodString;
|
|
41
|
+
name: z.ZodString;
|
|
42
|
+
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
43
|
+
default_selected: z.ZodOptional<z.ZodBoolean>;
|
|
44
|
+
}, z.core.$strip>;
|
|
45
|
+
export declare const extraMediaItemSchema: z.ZodObject<{
|
|
46
|
+
url: z.ZodString;
|
|
47
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
48
|
+
image: "image";
|
|
49
|
+
video: "video";
|
|
50
|
+
document: "document";
|
|
51
|
+
}>>;
|
|
52
|
+
caption: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
53
|
+
alt: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
54
|
+
}, z.core.$strip>;
|
|
55
|
+
export declare const extraPolicySchema: z.ZodObject<{
|
|
56
|
+
kind: z.ZodEnum<{
|
|
57
|
+
cancellation: "cancellation";
|
|
58
|
+
payment: "payment";
|
|
59
|
+
supplier_notes: "supplier_notes";
|
|
60
|
+
requirements: "requirements";
|
|
61
|
+
}>;
|
|
62
|
+
body: z.ZodString;
|
|
63
|
+
rules: z.ZodOptional<z.ZodUnknown>;
|
|
64
|
+
}, z.core.$strip>;
|
|
65
|
+
export declare const extraContentSchema: z.ZodObject<{
|
|
66
|
+
extra: z.ZodObject<{
|
|
67
|
+
id: z.ZodString;
|
|
68
|
+
name: z.ZodString;
|
|
69
|
+
status: z.ZodOptional<z.ZodString>;
|
|
70
|
+
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
71
|
+
selection_type: z.ZodOptional<z.ZodString>;
|
|
72
|
+
pricing_mode: z.ZodOptional<z.ZodString>;
|
|
73
|
+
priced_per_person: z.ZodOptional<z.ZodBoolean>;
|
|
74
|
+
category: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
75
|
+
hero_image_url: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
76
|
+
highlights: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
77
|
+
supplier: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
78
|
+
duration_minutes: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
79
|
+
requirements_summary: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
80
|
+
}, z.core.$strip>;
|
|
81
|
+
options: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
82
|
+
id: z.ZodString;
|
|
83
|
+
name: z.ZodString;
|
|
84
|
+
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
85
|
+
default_selected: z.ZodOptional<z.ZodBoolean>;
|
|
86
|
+
}, z.core.$strip>>>;
|
|
87
|
+
media: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
88
|
+
url: z.ZodString;
|
|
89
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
90
|
+
image: "image";
|
|
91
|
+
video: "video";
|
|
92
|
+
document: "document";
|
|
93
|
+
}>>;
|
|
94
|
+
caption: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
95
|
+
alt: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
96
|
+
}, z.core.$strip>>>;
|
|
97
|
+
policies: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
98
|
+
kind: z.ZodEnum<{
|
|
99
|
+
cancellation: "cancellation";
|
|
100
|
+
payment: "payment";
|
|
101
|
+
supplier_notes: "supplier_notes";
|
|
102
|
+
requirements: "requirements";
|
|
103
|
+
}>;
|
|
104
|
+
body: z.ZodString;
|
|
105
|
+
rules: z.ZodOptional<z.ZodUnknown>;
|
|
106
|
+
}, z.core.$strip>>>;
|
|
107
|
+
}, z.core.$strip>;
|
|
108
|
+
export type ExtraContent = z.infer<typeof extraContentSchema>;
|
|
109
|
+
export type ExtraSummary = z.infer<typeof extraSummarySchema>;
|
|
110
|
+
export type ExtraOption = z.infer<typeof extraOptionSchema>;
|
|
111
|
+
export type ExtraMediaItem = z.infer<typeof extraMediaItemSchema>;
|
|
112
|
+
export type ExtraPolicy = z.infer<typeof extraPolicySchema>;
|
|
113
|
+
export declare function validateExtraContent(payload: unknown): {
|
|
114
|
+
valid: true;
|
|
115
|
+
content: ExtraContent;
|
|
116
|
+
} | {
|
|
117
|
+
valid: false;
|
|
118
|
+
reason: string;
|
|
119
|
+
};
|
|
120
|
+
//# sourceMappingURL=content-shape.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-shape.d.ts","sourceRoot":"","sources":["../src/content-shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,eAAO,MAAM,6BAA6B,cAAc,CAAA;AAExD,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;iBAgC7B,CAAA;AAEF,eAAO,MAAM,iBAAiB;;;;;iBAM5B,CAAA;AAEF,eAAO,MAAM,oBAAoB;;;;;;;;;iBAK/B,CAAA;AAEF,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAA;AAEF,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAK7B,CAAA;AAEF,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAC7D,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAC7D,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAC3D,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAA;AACjE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAE3D,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,OAAO,GACf;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,YAAY,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAU3E"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extras content shape — the rich detail-page content shape returned
|
|
3
|
+
* by `getContent` for sourced extras (excursions, transfers, add-on
|
|
4
|
+
* services).
|
|
5
|
+
*
|
|
6
|
+
* The extras content aggregate is `{ extra, options[], media[],
|
|
7
|
+
* policies[] }` — one payload returned by a single `getContent`.
|
|
8
|
+
* Pricing stays out (volatile-live, flows through `liveResolve`).
|
|
9
|
+
*
|
|
10
|
+
* Extras are simpler than the other verticals because they're add-ons,
|
|
11
|
+
* not standalone products. There's no day-by-day itinerary, no
|
|
12
|
+
* room-type / cabin-category map, no ship spec — just an extra
|
|
13
|
+
* description, optional sub-options (e.g. "half-day vs full-day"),
|
|
14
|
+
* media, and the operational/cancellation policies.
|
|
15
|
+
*
|
|
16
|
+
* This module is the pure content contract: schemas, types, version, and
|
|
17
|
+
* the validator. The `mergeOverlaysIntoExtraContent` overlay composition
|
|
18
|
+
* stays in the `@voyantjs/extras` runtime package.
|
|
19
|
+
*
|
|
20
|
+
* See `docs/architecture/catalog-sourced-content.md` §3.2, §3.5.4, §3.6.
|
|
21
|
+
*/
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
export const EXTRAS_CONTENT_SCHEMA_VERSION = "extras/v1";
|
|
24
|
+
export const extraSummarySchema = z.object({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
name: z.string(),
|
|
27
|
+
status: z.string().optional(),
|
|
28
|
+
description: z.string().nullable().optional(),
|
|
29
|
+
/**
|
|
30
|
+
* Selection type — mirrors the owned `extra_selection_type` enum:
|
|
31
|
+
* "optional" | "required" | "default_selected" | "unavailable".
|
|
32
|
+
* Sourced adapters set what they support; thin synthesis defaults to
|
|
33
|
+
* "optional".
|
|
34
|
+
*/
|
|
35
|
+
selection_type: z.string().optional(),
|
|
36
|
+
/**
|
|
37
|
+
* Pricing mode — mirrors the owned `extra_pricing_mode` enum:
|
|
38
|
+
* "included" | "per_person" | "per_booking" | "quantity_based" |
|
|
39
|
+
* "on_request" | "free". Captures the structural pricing model the
|
|
40
|
+
* upstream advertises; actual prices come through `liveResolve`.
|
|
41
|
+
*/
|
|
42
|
+
pricing_mode: z.string().optional(),
|
|
43
|
+
/** Hint — true when the extra is priced per traveler, not per booking. */
|
|
44
|
+
priced_per_person: z.boolean().optional(),
|
|
45
|
+
/** Service category (e.g. "transfer", "excursion", "insurance", "spa"). */
|
|
46
|
+
category: z.string().nullable().optional(),
|
|
47
|
+
/** Hero media URL. */
|
|
48
|
+
hero_image_url: z.string().nullable().optional(),
|
|
49
|
+
highlights: z.array(z.string()).optional(),
|
|
50
|
+
/** Free-form supplier hint surfaced to ops (not customer-facing). */
|
|
51
|
+
supplier: z.string().nullable().optional(),
|
|
52
|
+
/** Estimated duration in minutes for time-bound extras (excursions). */
|
|
53
|
+
duration_minutes: z.number().int().nonnegative().nullable().optional(),
|
|
54
|
+
/** Constraints / requirements summary surfaced on the booking flow. */
|
|
55
|
+
requirements_summary: z.string().nullable().optional(),
|
|
56
|
+
});
|
|
57
|
+
export const extraOptionSchema = z.object({
|
|
58
|
+
id: z.string(),
|
|
59
|
+
name: z.string(),
|
|
60
|
+
description: z.string().nullable().optional(),
|
|
61
|
+
/** Whether this option auto-selects when the extra is selected. */
|
|
62
|
+
default_selected: z.boolean().optional(),
|
|
63
|
+
});
|
|
64
|
+
export const extraMediaItemSchema = z.object({
|
|
65
|
+
url: z.string(),
|
|
66
|
+
type: z.enum(["image", "video", "document"]).default("image"),
|
|
67
|
+
caption: z.string().nullable().optional(),
|
|
68
|
+
alt: z.string().nullable().optional(),
|
|
69
|
+
});
|
|
70
|
+
export const extraPolicySchema = z.object({
|
|
71
|
+
kind: z.enum(["cancellation", "payment", "supplier_notes", "requirements"]),
|
|
72
|
+
body: z.string(),
|
|
73
|
+
rules: z.unknown().optional(),
|
|
74
|
+
});
|
|
75
|
+
export const extraContentSchema = z.object({
|
|
76
|
+
extra: extraSummarySchema,
|
|
77
|
+
options: z.array(extraOptionSchema).default([]),
|
|
78
|
+
media: z.array(extraMediaItemSchema).default([]),
|
|
79
|
+
policies: z.array(extraPolicySchema).default([]),
|
|
80
|
+
});
|
|
81
|
+
export function validateExtraContent(payload) {
|
|
82
|
+
const result = extraContentSchema.safeParse(payload);
|
|
83
|
+
if (result.success) {
|
|
84
|
+
return { valid: true, content: result.data };
|
|
85
|
+
}
|
|
86
|
+
const issue = result.error.issues[0];
|
|
87
|
+
return {
|
|
88
|
+
valid: false,
|
|
89
|
+
reason: issue ? `${issue.path.join(".")}: ${issue.message}` : "validation failed",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-shape.test.d.ts","sourceRoot":"","sources":["../src/content-shape.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { EXTRAS_CONTENT_SCHEMA_VERSION, extraContentSchema, validateExtraContent, } from "./index.js";
|
|
3
|
+
describe("@voyantjs/extras-contracts content shape", () => {
|
|
4
|
+
it("validates the extras/v1 rich content payload", () => {
|
|
5
|
+
const content = extraContentSchema.parse({
|
|
6
|
+
extra: { id: "prx_abc", name: "Airport Transfer", category: "transfer" },
|
|
7
|
+
options: [{ id: "opt_private", name: "Private Vehicle" }],
|
|
8
|
+
media: [{ url: "https://cdn.example.com/transfer.jpg" }],
|
|
9
|
+
policies: [{ kind: "cancellation", body: "Free up to 24h before." }],
|
|
10
|
+
});
|
|
11
|
+
expect(EXTRAS_CONTENT_SCHEMA_VERSION).toBe("extras/v1");
|
|
12
|
+
expect(validateExtraContent(content)).toMatchObject({ valid: true });
|
|
13
|
+
expect(content.options).toHaveLength(1);
|
|
14
|
+
expect(content.media[0]?.type).toBe("image");
|
|
15
|
+
});
|
|
16
|
+
it("defaults the options, media, and policies arrays to empty", () => {
|
|
17
|
+
const content = extraContentSchema.parse({
|
|
18
|
+
extra: { id: "prx_abc", name: "Spa Day Pass" },
|
|
19
|
+
});
|
|
20
|
+
expect(content.options).toEqual([]);
|
|
21
|
+
expect(content.media).toEqual([]);
|
|
22
|
+
expect(content.policies).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
it("rejects payloads missing the required extra summary", () => {
|
|
25
|
+
expect(validateExtraContent({ options: [] })).toMatchObject({ valid: false });
|
|
26
|
+
expect(validateExtraContent({ extra: { id: "x" } })).toMatchObject({ valid: false });
|
|
27
|
+
});
|
|
28
|
+
it("rejects unknown policy kinds and media types", () => {
|
|
29
|
+
expect(validateExtraContent({
|
|
30
|
+
extra: { id: "prx_abc", name: "Airport Transfer" },
|
|
31
|
+
policies: [{ kind: "loyalty", body: "x" }],
|
|
32
|
+
})).toMatchObject({ valid: false });
|
|
33
|
+
expect(validateExtraContent({
|
|
34
|
+
extra: { id: "prx_abc", name: "Airport Transfer" },
|
|
35
|
+
media: [{ url: "https://cdn.example.com/x.bin", type: "audio" }],
|
|
36
|
+
})).toMatchObject({ valid: false });
|
|
37
|
+
});
|
|
38
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./content-shape.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voyantjs/extras-contracts",
|
|
3
|
+
"version": "0.90.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./content-shape": "./src/content-shape.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"lint": "biome check src/",
|
|
13
|
+
"test": "vitest run --passWithNoTests",
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
16
|
+
"prepack": "pnpm run build"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./content-shape": {
|
|
30
|
+
"types": "./dist/content-shape.d.ts",
|
|
31
|
+
"import": "./dist/content-shape.js",
|
|
32
|
+
"default": "./dist/content-shape.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"main": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"zod": "^4.3.6"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@voyantjs/voyant-typescript-config": "workspace:*",
|
|
43
|
+
"typescript": "^6.0.2",
|
|
44
|
+
"vitest": "^4.1.2"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/voyantjs/voyant.git",
|
|
49
|
+
"directory": "packages/extras-contracts"
|
|
50
|
+
}
|
|
51
|
+
}
|