@carrierllc/mcp 0.12.0 → 0.13.3
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 +4 -3
- package/dist/{chunk-J52VP7E7.js → chunk-2TVU2GRC.js} +1541 -1131
- package/dist/chunk-2TVU2GRC.js.map +1 -0
- package/dist/cli.js +445 -24
- package/dist/cli.js.map +1 -1
- package/dist/index.js +95 -47
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/plugin/carrier/README.md +2 -1
- package/plugin/carrier/skills/issue-esim/SKILL.md +92 -0
- package/plugin/carrier/skills/provision/SKILL.md +2 -0
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +79 -22
- package/templates/storefront/src/app/api/orders/[orderId]/email/route.ts +73 -0
- package/templates/storefront/src/lib/checkout-order-claim.ts +45 -4
- package/templates/storefront/src/lib/esim-install.ts +74 -0
- package/templates/storefront/src/lib/qr-delivery-email.ts +106 -0
- package/templates/storefront/src/vendor/email/qr-delivery.ts +254 -0
- package/templates/storefront/src/vendor/email/resend.ts +140 -0
- package/dist/chunk-J52VP7E7.js.map +0 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Your eSIM is ready" — the message a customer actually installs from.
|
|
3
|
+
*
|
|
4
|
+
* Written as a plain HTML string rather than React Email components, for the
|
|
5
|
+
* reason given in resend.ts: this file ships into somebody else's repository,
|
|
6
|
+
* and an operator re-wording their own delivery email should not first have to
|
|
7
|
+
* learn a component library. Inline styles and a single-column table are the
|
|
8
|
+
* only layout most mail clients render the same way twice.
|
|
9
|
+
*
|
|
10
|
+
* The layering is deliberate, worst case first:
|
|
11
|
+
*
|
|
12
|
+
* 1. A QR code, attached and referenced as `cid:` — never a `data:` URI,
|
|
13
|
+
* which Gmail strips.
|
|
14
|
+
* 2. A direct-install link, which needs no typing at all on iOS 17.4+.
|
|
15
|
+
* 3. The SM-DP+ address and the activation code as two separate values,
|
|
16
|
+
* because those are two separate fields on the phone. One opaque
|
|
17
|
+
* `LPA:1$…$…` blob is not something a person can act on.
|
|
18
|
+
* 4. The steps, in words, for iPhone and for Android.
|
|
19
|
+
*
|
|
20
|
+
* A QR code alone fails the commonest case there is: the customer is reading
|
|
21
|
+
* this on the phone the eSIM is for, and cannot photograph its own screen.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
ANDROID_INSTALL_STEPS,
|
|
25
|
+
IPHONE_INSTALL_STEPS,
|
|
26
|
+
directInstallUrl,
|
|
27
|
+
manualActivationCode,
|
|
28
|
+
} from "@/lib/esim-install";
|
|
29
|
+
import type { EmailAttachment, EmailMessage } from "./resend";
|
|
30
|
+
|
|
31
|
+
/** The QR image, already fetched and base64-encoded by the caller. */
|
|
32
|
+
export interface QrImage {
|
|
33
|
+
base64: string;
|
|
34
|
+
contentType: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface QrDeliveryBrand {
|
|
38
|
+
name: string;
|
|
39
|
+
supportEmail?: string;
|
|
40
|
+
supportUrl?: string;
|
|
41
|
+
appUrl?: string;
|
|
42
|
+
colors?: { accent?: string; text?: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface QrDeliveryOrder {
|
|
46
|
+
orderId: string;
|
|
47
|
+
planName?: string;
|
|
48
|
+
iccid?: string;
|
|
49
|
+
activationCode?: string;
|
|
50
|
+
smdpAddress?: string;
|
|
51
|
+
matchingId?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface QrDeliveryInput {
|
|
55
|
+
to: string;
|
|
56
|
+
order: QrDeliveryOrder;
|
|
57
|
+
brand: QrDeliveryBrand;
|
|
58
|
+
/** Absent when the operator's fulfilment produced no renderable QR. */
|
|
59
|
+
qr?: QrImage | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The content id the HTML points at. Any stable token works; this one is
|
|
64
|
+
* spelled out so the test can assert the HTML and the attachment agree.
|
|
65
|
+
*/
|
|
66
|
+
export const QR_CONTENT_ID = "esim-qr";
|
|
67
|
+
|
|
68
|
+
function escapeHtml(value: string): string {
|
|
69
|
+
return value
|
|
70
|
+
.replace(/&/g, "&")
|
|
71
|
+
.replace(/</g, "<")
|
|
72
|
+
.replace(/>/g, ">")
|
|
73
|
+
.replace(/"/g, """)
|
|
74
|
+
.replace(/'/g, "'");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function steps(title: string, items: readonly string[]): string {
|
|
78
|
+
return `
|
|
79
|
+
<p style="margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;">${escapeHtml(
|
|
80
|
+
title,
|
|
81
|
+
)}</p>
|
|
82
|
+
<ol style="margin:0 0 20px;padding-left:20px;font-size:14px;line-height:1.6;color:#374151;">
|
|
83
|
+
${items.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}
|
|
84
|
+
</ol>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One labelled credential, in a font a person can read a hyphen in. */
|
|
88
|
+
function credential(label: string, value: string): string {
|
|
89
|
+
return `
|
|
90
|
+
<tr>
|
|
91
|
+
<td style="padding:0 0 14px;">
|
|
92
|
+
<p style="margin:0 0 2px;font-size:12px;color:#6b7280;">${escapeHtml(label)}</p>
|
|
93
|
+
<p style="margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:14px;word-break:break-all;color:#111827;">${escapeHtml(
|
|
94
|
+
value,
|
|
95
|
+
)}</p>
|
|
96
|
+
</td>
|
|
97
|
+
</tr>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build the message. Pure: every input is an argument, so the shape of what
|
|
102
|
+
* goes out can be asserted without a network or an API key.
|
|
103
|
+
*/
|
|
104
|
+
export function buildQrDeliveryEmail(input: QrDeliveryInput): EmailMessage {
|
|
105
|
+
const { to, order, brand, qr } = input;
|
|
106
|
+
const accent = brand.colors?.accent ?? "#1a1a1a";
|
|
107
|
+
const lpa = manualActivationCode(order);
|
|
108
|
+
const attachments: EmailAttachment[] = [];
|
|
109
|
+
|
|
110
|
+
let qrBlock = "";
|
|
111
|
+
if (qr) {
|
|
112
|
+
attachments.push({
|
|
113
|
+
filename: "esim-qr.png",
|
|
114
|
+
content: qr.base64,
|
|
115
|
+
content_type: qr.contentType,
|
|
116
|
+
content_id: QR_CONTENT_ID,
|
|
117
|
+
});
|
|
118
|
+
qrBlock = `
|
|
119
|
+
<tr>
|
|
120
|
+
<td align="center" style="padding:8px 32px 24px;">
|
|
121
|
+
<img src="cid:${QR_CONTENT_ID}" width="200" height="200" alt="eSIM activation QR code" style="display:block;border:0;background:#ffffff;padding:8px;border-radius:12px;" />
|
|
122
|
+
<p style="margin:12px 0 0;font-size:13px;color:#6b7280;">Scan this from another device’s camera.</p>
|
|
123
|
+
</td>
|
|
124
|
+
</tr>`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const installButton = lpa
|
|
128
|
+
? `
|
|
129
|
+
<tr>
|
|
130
|
+
<td align="center" style="padding:0 32px 8px;">
|
|
131
|
+
<a href="${escapeHtml(directInstallUrl(lpa))}" style="display:inline-block;background:${escapeHtml(
|
|
132
|
+
accent,
|
|
133
|
+
)};color:#ffffff;font-size:16px;font-weight:600;padding:14px 32px;border-radius:999px;text-decoration:none;">Install on this iPhone</a>
|
|
134
|
+
<p style="margin:10px 0 0;font-size:12px;color:#6b7280;">One tap, no typing. iPhone on iOS 17.4 or later. On Android, use the steps below.</p>
|
|
135
|
+
</td>
|
|
136
|
+
</tr>`
|
|
137
|
+
: "";
|
|
138
|
+
|
|
139
|
+
const credentials = [
|
|
140
|
+
order.smdpAddress ? credential("SM-DP+ address", order.smdpAddress) : "",
|
|
141
|
+
order.matchingId ? credential("Activation code", order.matchingId) : "",
|
|
142
|
+
order.iccid ? credential("ICCID", order.iccid) : "",
|
|
143
|
+
].join("");
|
|
144
|
+
|
|
145
|
+
const manualBlock = credentials
|
|
146
|
+
? `
|
|
147
|
+
<tr>
|
|
148
|
+
<td style="padding:24px 32px 0;">
|
|
149
|
+
<div style="border:1px solid #e5e7eb;border-radius:16px;padding:20px;">
|
|
150
|
+
<p style="margin:0 0 14px;font-size:14px;font-weight:600;color:#111827;">Install manually</p>
|
|
151
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${credentials}</table>
|
|
152
|
+
<div style="border-top:1px solid #e5e7eb;padding-top:18px;margin-top:4px;">
|
|
153
|
+
${steps("On iPhone", IPHONE_INSTALL_STEPS)}
|
|
154
|
+
${steps("On Android", ANDROID_INSTALL_STEPS)}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</td>
|
|
158
|
+
</tr>`
|
|
159
|
+
: "";
|
|
160
|
+
|
|
161
|
+
const activateUrl = brand.appUrl
|
|
162
|
+
? `${brand.appUrl.replace(/\/+$/, "")}/activate/${encodeURIComponent(order.orderId)}`
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
const supportLine = brand.supportEmail
|
|
166
|
+
? `Questions? <a href="mailto:${escapeHtml(brand.supportEmail)}" style="color:${escapeHtml(
|
|
167
|
+
accent,
|
|
168
|
+
)};">${escapeHtml(brand.supportEmail)}</a>`
|
|
169
|
+
: brand.supportUrl
|
|
170
|
+
? `Questions? <a href="${escapeHtml(brand.supportUrl)}" style="color:${escapeHtml(accent)};">Contact support</a>`
|
|
171
|
+
: "";
|
|
172
|
+
|
|
173
|
+
const html = `<!DOCTYPE html>
|
|
174
|
+
<html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width" /><title>${escapeHtml(
|
|
175
|
+
brand.name,
|
|
176
|
+
)}</title></head>
|
|
177
|
+
<body style="margin:0;padding:0;background:#f6f6f4;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;">
|
|
178
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f6f6f4;">
|
|
179
|
+
<tr><td align="center" style="padding:24px 12px;">
|
|
180
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;background:#ffffff;border-radius:20px;overflow:hidden;">
|
|
181
|
+
<tr>
|
|
182
|
+
<td style="background:${escapeHtml(accent)};padding:20px 32px;">
|
|
183
|
+
<p style="margin:0;color:#ffffff;font-size:20px;font-weight:700;letter-spacing:-0.4px;">${escapeHtml(
|
|
184
|
+
brand.name,
|
|
185
|
+
)}</p>
|
|
186
|
+
</td>
|
|
187
|
+
</tr>
|
|
188
|
+
<tr>
|
|
189
|
+
<td style="padding:28px 32px 4px;">
|
|
190
|
+
<p style="margin:0 0 8px;font-size:24px;font-weight:700;color:#111827;">Your eSIM is ready.</p>
|
|
191
|
+
<p style="margin:0;font-size:15px;line-height:1.55;color:#4b5563;">Install it now and it stays dormant until you arrive. ${
|
|
192
|
+
order.planName ? `Your plan: <strong>${escapeHtml(order.planName)}</strong>.` : ""
|
|
193
|
+
}</p>
|
|
194
|
+
</td>
|
|
195
|
+
</tr>
|
|
196
|
+
${installButton}
|
|
197
|
+
${qrBlock}
|
|
198
|
+
${manualBlock}
|
|
199
|
+
<tr>
|
|
200
|
+
<td style="padding:24px 32px 28px;">
|
|
201
|
+
${
|
|
202
|
+
activateUrl
|
|
203
|
+
? `<p style="margin:0 0 10px;font-size:13px;color:#6b7280;">All of this is also on your <a href="${escapeHtml(
|
|
204
|
+
activateUrl,
|
|
205
|
+
)}" style="color:${escapeHtml(accent)};">activation page</a>.</p>`
|
|
206
|
+
: ""
|
|
207
|
+
}
|
|
208
|
+
<p style="margin:0 0 6px;font-size:13px;color:#6b7280;">${supportLine}</p>
|
|
209
|
+
<p style="margin:0;font-size:12px;color:#9ca3af;">Order ${escapeHtml(
|
|
210
|
+
order.orderId,
|
|
211
|
+
)} · You are receiving this because you bought a plan from ${escapeHtml(
|
|
212
|
+
brand.name,
|
|
213
|
+
)}.</p>
|
|
214
|
+
</td>
|
|
215
|
+
</tr>
|
|
216
|
+
</table>
|
|
217
|
+
</td></tr>
|
|
218
|
+
</table>
|
|
219
|
+
</body></html>`;
|
|
220
|
+
|
|
221
|
+
// null drops a line that does not apply. "" is a blank line on purpose:
|
|
222
|
+
// filtering every empty string turns the five install routes into one paragraph.
|
|
223
|
+
const text = [
|
|
224
|
+
"Your eSIM is ready.",
|
|
225
|
+
order.planName ? `Plan: ${order.planName}` : null,
|
|
226
|
+
"",
|
|
227
|
+
lpa ? `Install on iPhone (iOS 17.4+), one tap: ${directInstallUrl(lpa)}` : null,
|
|
228
|
+
"",
|
|
229
|
+
"Install manually:",
|
|
230
|
+
order.smdpAddress ? ` SM-DP+ address: ${order.smdpAddress}` : null,
|
|
231
|
+
order.matchingId ? ` Activation code: ${order.matchingId}` : null,
|
|
232
|
+
order.iccid ? ` ICCID: ${order.iccid}` : null,
|
|
233
|
+
"",
|
|
234
|
+
"On iPhone:",
|
|
235
|
+
...IPHONE_INSTALL_STEPS.map((s, i) => ` ${i + 1}. ${s}`),
|
|
236
|
+
"",
|
|
237
|
+
"On Android:",
|
|
238
|
+
...ANDROID_INSTALL_STEPS.map((s, i) => ` ${i + 1}. ${s}`),
|
|
239
|
+
"",
|
|
240
|
+
activateUrl ? `Activation page: ${activateUrl}` : null,
|
|
241
|
+
brand.supportEmail ? `Questions? ${brand.supportEmail}` : null,
|
|
242
|
+
`Order ${order.orderId}`,
|
|
243
|
+
]
|
|
244
|
+
.filter((line) => line !== null)
|
|
245
|
+
.join("\n");
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
to,
|
|
249
|
+
subject: `Your ${brand.name} eSIM is ready`,
|
|
250
|
+
html,
|
|
251
|
+
text,
|
|
252
|
+
attachments,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactional email over the Resend HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately no SDK. This template is copied into other people's
|
|
5
|
+
* repositories, so every dependency added here is one the operator inherits,
|
|
6
|
+
* has to audit, and has to keep patched. Sending one message is a single POST
|
|
7
|
+
* with a JSON body; `resend` and `@react-email/components` would buy us
|
|
8
|
+
* nothing that `fetch` does not already do, and `@react-email/components`
|
|
9
|
+
* would additionally drag a server-side React render into a Cloudflare Worker
|
|
10
|
+
* request path. The same reasoning put Stripe.js on a CDN rather than in
|
|
11
|
+
* package.json.
|
|
12
|
+
*
|
|
13
|
+
* The cost of that choice is honest: no typed client, and the field names
|
|
14
|
+
* below are the raw API's snake_case (`content_id`, not the SDK's
|
|
15
|
+
* `contentId`). They are pinned by tests.
|
|
16
|
+
*/
|
|
17
|
+
import brand from "@/brand.config";
|
|
18
|
+
|
|
19
|
+
const RESEND_API = "https://api.resend.com";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resend rejects any request without a User-Agent with a 403 that looks
|
|
23
|
+
* exactly like a bad key. Workers do not set one, so we always do.
|
|
24
|
+
*/
|
|
25
|
+
const USER_AGENT = "carrier-storefront/1.0";
|
|
26
|
+
|
|
27
|
+
/** One file travelling with the message. Inline when `content_id` is set. */
|
|
28
|
+
export interface EmailAttachment {
|
|
29
|
+
filename: string;
|
|
30
|
+
/** Base64, no data: prefix. */
|
|
31
|
+
content: string;
|
|
32
|
+
content_type?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Makes this attachment addressable from the HTML as `cid:<content_id>`.
|
|
35
|
+
*
|
|
36
|
+
* This is the whole reason the QR is an attachment rather than a `data:`
|
|
37
|
+
* URI in an `<img src>`. Gmail strips `data:` image sources, so a `data:`
|
|
38
|
+
* QR renders as a broken image for a large share of buyers — and a broken
|
|
39
|
+
* QR in a "your eSIM is ready" email is a support ticket, every time.
|
|
40
|
+
*/
|
|
41
|
+
content_id?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EmailMessage {
|
|
45
|
+
to: string;
|
|
46
|
+
subject: string;
|
|
47
|
+
html: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
attachments?: EmailAttachment[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Why mail cannot be sent, when it cannot.
|
|
54
|
+
*
|
|
55
|
+
* "unconfigured" is a normal state, not a fault: a storefront with no Resend
|
|
56
|
+
* key still sells eSIMs, and every caller must treat this as a thing to
|
|
57
|
+
* mention rather than a thing to fail on.
|
|
58
|
+
*/
|
|
59
|
+
export type EmailUnavailable = "unconfigured" | "no-from-address";
|
|
60
|
+
|
|
61
|
+
export type EmailSendResult =
|
|
62
|
+
| { ok: true; id?: string }
|
|
63
|
+
| { ok: false; reason: EmailUnavailable | "rejected" | "network-error"; detail?: string };
|
|
64
|
+
|
|
65
|
+
/** Server-side only. Never reached from a client bundle, so never inlined. */
|
|
66
|
+
function apiKey(): string {
|
|
67
|
+
return process.env.RESEND_API_KEY?.trim() ?? "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function emailConfigured(): boolean {
|
|
71
|
+
return apiKey().length > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The address this storefront sends from, composed from the operator's own
|
|
76
|
+
* mailbox and their own domain.
|
|
77
|
+
*
|
|
78
|
+
* It is composed rather than stored whole on purpose. A literal from-address
|
|
79
|
+
* in a white-label template drifts onto a domain the operator does not own the
|
|
80
|
+
* first time somebody copies the file, and mail from a domain you cannot add
|
|
81
|
+
* SPF and DKIM records to does not arrive. There is no default: if the brand
|
|
82
|
+
* has no mailbox or no domain, this returns null and the caller says email is
|
|
83
|
+
* not configured rather than sending from somebody else's domain.
|
|
84
|
+
*/
|
|
85
|
+
export function fromAddress(
|
|
86
|
+
config: { emailFromName?: string; emailFromMailbox?: string; domain?: string } = brand,
|
|
87
|
+
): string | null {
|
|
88
|
+
const mailbox = config.emailFromMailbox?.trim();
|
|
89
|
+
const domain = config.domain?.trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
90
|
+
if (!mailbox || !domain) return null;
|
|
91
|
+
|
|
92
|
+
const address = `${mailbox}@${domain}`;
|
|
93
|
+
const name = config.emailFromName?.trim();
|
|
94
|
+
return name ? `${name} <${address}>` : address;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send one message. Never throws: a storefront that cannot send an email must
|
|
99
|
+
* still be able to finish taking an order.
|
|
100
|
+
*/
|
|
101
|
+
export async function sendEmail(message: EmailMessage): Promise<EmailSendResult> {
|
|
102
|
+
const key = apiKey();
|
|
103
|
+
if (!key) return { ok: false, reason: "unconfigured" };
|
|
104
|
+
|
|
105
|
+
const from = fromAddress();
|
|
106
|
+
if (!from) return { ok: false, reason: "no-from-address" };
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(`${RESEND_API}/emails`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
Authorization: `Bearer ${key}`,
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
"User-Agent": USER_AGENT,
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
from,
|
|
118
|
+
to: [message.to],
|
|
119
|
+
subject: message.subject,
|
|
120
|
+
html: message.html,
|
|
121
|
+
...(message.text ? { text: message.text } : {}),
|
|
122
|
+
...(message.attachments?.length ? { attachments: message.attachments } : {}),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const detail = (await res.text().catch(() => "")).slice(0, 300);
|
|
128
|
+
return { ok: false, reason: "rejected", detail: `HTTP ${res.status} ${detail}`.trim() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = (await res.json().catch(() => ({}))) as { id?: string };
|
|
132
|
+
return { ok: true, id: body.id };
|
|
133
|
+
} catch (e: unknown) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: "network-error",
|
|
137
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|