diff --git a/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx b/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx new file mode 100644 index 0000000..0d26742 --- /dev/null +++ b/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx @@ -0,0 +1,350 @@ +'use client' + +import { use, useEffect, useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { + describeCampaignStatus, + formatCampaignProgress, +} from '@/lib/campaignStatus' +import { + describeQueueStatus, + formatRelativeTime, + summarizeError, +} from '@/lib/queueRowSummary' + +type QueueEntry = { + id: string + recipientEmail: string + recipientName: string | null + status: string + attemptCount: number + maxAttempts: number + scheduledFor: string | null + lastAttemptAt: string | null + sentAt: string | null + errorMessage: string | null +} + +type QueueState = + | { kind: 'loading' } + | { kind: 'ready'; entries: QueueEntry[]; hasMore: boolean } + | { kind: 'error'; message: string } + +type CampaignDetail = { + id: string + name: string + status: string + totalRecipients: number + sentCount: number + openCount: number + clickCount: number + replyCount: number + bounceCount: number + unsubscribeCount: number + createdAt: string + updatedAt: string + queueStats?: { + pending?: number + sent?: number + failed?: number + bounced?: number + } +} + +type LoadState = + | { kind: 'loading' } + | { kind: 'ready'; campaign: CampaignDetail } + | { kind: 'error'; status?: number; message: string } + +export default function CampaignDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = use(params) + const router = useRouter() + const [state, setState] = useState({ kind: 'loading' }) + const [queue, setQueue] = useState({ kind: 'loading' }) + const [deleting, setDeleting] = useState(false) + + useEffect(() => { + let cancelled = false + fetch(`/api/campaigns/${id}`) + .then(async (res) => { + const data = await res.json().catch(() => ({})) + if (!res.ok || !data?.success) { + const msg = data?.error || `Failed (${res.status})` + throw Object.assign(new Error(msg), { status: res.status }) + } + return data.campaign as CampaignDetail + }) + .then((campaign) => { + if (!cancelled) setState({ kind: 'ready', campaign }) + }) + .catch((err) => { + if (cancelled) return + setState({ + kind: 'error', + status: err?.status, + message: err instanceof Error ? err.message : String(err), + }) + }) + return () => { + cancelled = true + } + }, [id]) + + useEffect(() => { + let cancelled = false + fetch(`/api/campaigns/${id}/queue?limit=100`) + .then(async (res) => { + const data = await res.json().catch(() => ({})) + if (!res.ok || !data?.success) { + throw new Error(data?.error || `Failed (${res.status})`) + } + return { + entries: data.entries as QueueEntry[], + hasMore: Boolean(data.pagination?.hasMore), + } + }) + .then(({ entries, hasMore }) => { + if (!cancelled) setQueue({ kind: 'ready', entries, hasMore }) + }) + .catch((err) => { + if (cancelled) return + setQueue({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }) + }) + return () => { + cancelled = true + } + }, [id]) + + const handleDelete = async () => { + if (state.kind !== 'ready') return + if (!confirm(`Delete "${state.campaign.name}" and all its queue entries?`)) { + return + } + setDeleting(true) + try { + const res = await fetch(`/api/campaigns/${id}`, { method: 'DELETE' }) + const data = await res.json().catch(() => ({})) + if (!res.ok || !data?.success) { + throw new Error(data?.error || `Failed (${res.status})`) + } + router.push('/dashboard/campaigns') + } catch (err) { + alert(err instanceof Error ? err.message : String(err)) + setDeleting(false) + } + } + + return ( +
+
+ + + {state.kind === 'loading' && ( +

Loading campaign…

+ )} + + {state.kind === 'error' && ( +
+

{state.message}

+ {state.status === 404 && ( +

+ Campaign may have been deleted.{' '} + + Back to list + + . +

+ )} +
+ )} + + {state.kind === 'ready' && ( + + )} +
+
+ ) +} + +function CampaignDetailBody({ + campaign, + queue, + deleting, + onDelete, +}: { + campaign: CampaignDetail + queue: QueueState + deleting: boolean + onDelete: () => void +}) { + const badge = describeCampaignStatus(campaign.status) + return ( + <> +
+
+

{campaign.name}

+

+ Created {new Date(campaign.createdAt).toLocaleString()} · Updated{' '} + {new Date(campaign.updatedAt).toLocaleString()} +

+
+ + {badge.label} + +
+ +
+

Send progress

+

+ {formatCampaignProgress({ + totalRecipients: campaign.totalRecipients, + sentCount: campaign.sentCount, + })} +

+
+ +
+ + + + + + +
+ + {campaign.queueStats && ( +
+

Queue

+
+ + + + +
+
+ )} + +
+

Recipients

+ +
+ +
+ +
+ + ) +} + +function QueueTable({ queue }: { queue: QueueState }) { + if (queue.kind === 'loading') { + return ( +

Loading recipients…

+ ) + } + if (queue.kind === 'error') { + return ( +

+ Couldn't load recipients: {queue.message} +

+ ) + } + if (queue.entries.length === 0) { + return ( +

No recipients queued.

+ ) + } + return ( +
+ + + + + + + + + + + + {queue.entries.map((row) => { + const badge = describeQueueStatus(row.status) + const lastTs = row.sentAt ?? row.lastAttemptAt ?? row.scheduledFor + return ( + + + + + + + + ) + })} + +
RecipientStatusAttemptsLast activityError
+
{row.recipientEmail}
+ {row.recipientName && ( +
+ {row.recipientName} +
+ )} +
+ + {badge.label} + + + {row.attemptCount} / {row.maxAttempts} + + {formatRelativeTime(lastTs)} + + {summarizeError(row.errorMessage)} +
+ {queue.hasMore && ( +

+ Showing the first 100 recipients. Pagination coming soon. +

+ )} +
+ ) +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/src/app/(frontend)/dashboard/campaigns/new/page.tsx b/src/app/(frontend)/dashboard/campaigns/new/page.tsx index c3f70f7..8e72441 100644 --- a/src/app/(frontend)/dashboard/campaigns/new/page.tsx +++ b/src/app/(frontend)/dashboard/campaigns/new/page.tsx @@ -1,7 +1,7 @@ 'use client' import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' -import { useSearchParams } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -34,6 +34,7 @@ type SubmitResult = | { kind: 'error'; message: string } function NewCampaignPageInner() { + const router = useRouter() const searchParams = useSearchParams() const [name, setName] = useState('') const [subject, setSubject] = useState('') @@ -114,11 +115,15 @@ function NewCampaignPageInner() { if (!res.ok || !data?.success) { throw new Error(data?.error || `Request failed (${res.status})`) } + const campaignId = data.campaign?.id ?? '' setSubmit({ kind: 'success', - campaignId: data.campaign?.id ?? '', + campaignId, queued: data.queuedEmails ?? parsed.recipients.length, }) + if (campaignId) { + router.push(`/dashboard/campaigns/${campaignId}`) + } } catch (err) { setSubmit({ kind: 'error', diff --git a/src/app/(frontend)/dashboard/campaigns/page.tsx b/src/app/(frontend)/dashboard/campaigns/page.tsx new file mode 100644 index 0000000..9695634 --- /dev/null +++ b/src/app/(frontend)/dashboard/campaigns/page.tsx @@ -0,0 +1,143 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { + describeCampaignStatus, + formatCampaignProgress, +} from '@/lib/campaignStatus' + +type CampaignSummary = { + id: string + name: string + status: string + totalRecipients: number + sentCount: number + openCount: number + clickCount: number + replyCount: number + bounceCount: number + unsubscribeCount: number + createdAt: string + updatedAt: string +} + +type LoadState = + | { kind: 'loading' } + | { kind: 'ready'; campaigns: CampaignSummary[] } + | { kind: 'error'; message: string } + +export default function CampaignsListPage() { + const [state, setState] = useState({ kind: 'loading' }) + + useEffect(() => { + let cancelled = false + fetch('/api/campaigns') + .then(async (res) => { + const data = await res.json().catch(() => ({})) + if (!res.ok || !data?.success) { + throw new Error(data?.error || `Failed (${res.status})`) + } + return data.campaigns as CampaignSummary[] + }) + .then((campaigns) => { + if (!cancelled) setState({ kind: 'ready', campaigns }) + }) + .catch((err) => { + if (cancelled) return + setState({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }) + }) + return () => { + cancelled = true + } + }, []) + + return ( +
+
+
+

Campaigns

+ + + +
+ + {state.kind === 'loading' && ( +

Loading campaigns…

+ )} + + {state.kind === 'error' && ( +

+ Couldn't load campaigns: {state.message} +

+ )} + + {state.kind === 'ready' && state.campaigns.length === 0 && ( +
+

+ You haven't created any campaigns yet. +

+ + + +
+ )} + + {state.kind === 'ready' && state.campaigns.length > 0 && ( +
+ + + + + + + + + + + + {state.campaigns.map((c) => { + const badge = describeCampaignStatus(c.status) + return ( + + + + + + + + ) + })} + +
NameStatusSentRepliesCreated
+ + {c.name} + + + + {badge.label} + + + {formatCampaignProgress({ + totalRecipients: c.totalRecipients, + sentCount: c.sentCount, + })} + {c.replyCount} + {new Date(c.createdAt).toLocaleDateString()} +
+
+ )} +
+
+ ) +} diff --git a/src/app/api/campaigns/[id]/queue/route.ts b/src/app/api/campaigns/[id]/queue/route.ts new file mode 100644 index 0000000..8af4160 --- /dev/null +++ b/src/app/api/campaigns/[id]/queue/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth, AuthorizationError } from '@/lib/authorization' +import { + getCampaignWithStats, + getQueueEntriesByCampaign, +} from '@coldflow/db' + +/** + * GET /api/campaigns/[id]/queue + * + * Per-recipient queue entries for a campaign. Owner-checked. Paginated: + * `?limit=N&offset=N` (defaults limit=100, offset=0; max limit=500). + * + * The campaign endpoint already returns aggregate counts; this surfaces + * the per-row state so users can see who is pending / sent / failed. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params + const user = await requireAuth() + + // Verify the campaign exists and the requester owns it. Cheap, and + // means the queue endpoint can never leak rows from another tenant. + const campaign = await getCampaignWithStats(id) + if (!campaign) { + return NextResponse.json( + { success: false, error: 'Campaign not found' }, + { status: 404 }, + ) + } + if (campaign.userId !== user.id) { + return NextResponse.json( + { success: false, error: 'Unauthorized to access this campaign' }, + { status: 403 }, + ) + } + + const searchParams = request.nextUrl.searchParams + const limit = clamp(parseInt(searchParams.get('limit') || '100'), 1, 500) + const offset = Math.max(0, parseInt(searchParams.get('offset') || '0')) + + const entries = await getQueueEntriesByCampaign(id, limit, offset) + + return NextResponse.json({ + success: true, + entries: entries.map((e) => ({ + id: e.id, + recipientEmail: e.recipientEmail, + recipientName: e.recipientName, + status: e.status, + attemptCount: e.attemptCount, + maxAttempts: e.maxAttempts, + scheduledFor: e.scheduledFor, + lastAttemptAt: e.lastAttemptAt, + sentAt: e.sentAt, + errorMessage: e.errorMessage, + })), + pagination: { + limit, + offset, + hasMore: entries.length === limit, + }, + }) + } catch (error) { + console.error('Error fetching campaign queue:', error) + if (error instanceof AuthorizationError) { + return NextResponse.json( + { success: false, error: error.message }, + { status: error.statusCode }, + ) + } + return NextResponse.json( + { success: false, error: 'Failed to fetch campaign queue' }, + { status: 500 }, + ) + } +} + +function clamp(value: number, min: number, max: number): number { + if (Number.isNaN(value)) return min + return Math.min(max, Math.max(min, value)) +} diff --git a/src/lib/campaignStatus.ts b/src/lib/campaignStatus.ts new file mode 100644 index 0000000..9fd6300 --- /dev/null +++ b/src/lib/campaignStatus.ts @@ -0,0 +1,49 @@ +export type CampaignStatus = + | 'draft' + | 'scheduled' + | 'sending' + | 'completed' + | 'paused' + +export type CampaignStatusBadge = { + label: string + tone: 'neutral' | 'info' | 'progress' | 'success' | 'warning' +} + +const KNOWN: Record = { + draft: { label: 'Draft', tone: 'neutral' }, + scheduled: { label: 'Scheduled', tone: 'info' }, + sending: { label: 'Sending', tone: 'progress' }, + completed: { label: 'Completed', tone: 'success' }, + paused: { label: 'Paused', tone: 'warning' }, +} + +export function describeCampaignStatus( + status: string | null | undefined, +): CampaignStatusBadge { + if (status && status in KNOWN) { + return KNOWN[status as CampaignStatus] + } + return { label: status ? capitalize(status) : 'Unknown', tone: 'neutral' } +} + +export function formatCampaignProgress(input: { + totalRecipients: number + sentCount: number +}): string { + const total = Math.max(0, input.totalRecipients) + const sent = clamp(input.sentCount, 0, total) + if (total === 0) return '0 / 0' + const pct = Math.round((sent / total) * 100) + return `${sent} / ${total} (${pct}%)` +} + +function clamp(value: number, min: number, max: number): number { + if (Number.isNaN(value)) return min + return Math.min(max, Math.max(min, value)) +} + +function capitalize(s: string): string { + if (!s) return s + return s[0].toUpperCase() + s.slice(1) +} diff --git a/src/lib/queueRowSummary.ts b/src/lib/queueRowSummary.ts new file mode 100644 index 0000000..0ad7622 --- /dev/null +++ b/src/lib/queueRowSummary.ts @@ -0,0 +1,92 @@ +export type QueueStatus = + | 'pending' + | 'processing' + | 'sent' + | 'failed' + | 'bounced' + +export type QueueStatusBadge = { + label: string + tone: 'neutral' | 'info' | 'progress' | 'success' | 'warning' | 'error' +} + +const KNOWN: Record = { + pending: { label: 'Pending', tone: 'neutral' }, + processing: { label: 'Processing', tone: 'progress' }, + sent: { label: 'Sent', tone: 'success' }, + failed: { label: 'Failed', tone: 'error' }, + bounced: { label: 'Bounced', tone: 'warning' }, +} + +export function describeQueueStatus( + status: string | null | undefined, +): QueueStatusBadge { + if (status && status in KNOWN) { + return KNOWN[status as QueueStatus] + } + return { label: status ? capitalize(status) : 'Unknown', tone: 'neutral' } +} + +/** + * Trim verbose Gmail / SMTP / OAuth error blobs to a single useful line. + * Caller-provided errors are surfaced in the UI table; long stack traces or + * full HTTP bodies make the row unreadable. Always returns a string ≤ max. + */ +export function summarizeError( + raw: string | null | undefined, + max = 140, +): string { + if (!raw) return '' + const firstLine = raw.split(/\r?\n/, 1)[0]?.trim() ?? '' + if (firstLine.length <= max) return firstLine + return firstLine.slice(0, max - 1).trimEnd() + '…' +} + +/** + * Format a duration between `from` and `to` (defaults to now) as a short, + * UI-friendly relative string ("2m ago", "in 3h", "just now"). Pure; + * tests pass a fixed `now` so no system-clock dependency. + */ +export function formatRelativeTime( + from: Date | string | null | undefined, + now: Date = new Date(), +): string { + if (!from) return '' + const ts = typeof from === 'string' ? new Date(from) : from + if (Number.isNaN(ts.getTime())) return '' + + const diffMs = ts.getTime() - now.getTime() + const past = diffMs < 0 + const abs = Math.abs(diffMs) + + if (abs < 45_000) return 'just now' + + const units: Array<[number, string]> = [ + [60_000, 'm'], + [3_600_000, 'h'], + [86_400_000, 'd'], + [604_800_000, 'w'], + ] + + let value = 0 + let suffix = '' + for (let i = 0; i < units.length; i++) { + const [scale, label] = units[i] + const next = units[i + 1]?.[0] + if (!next || abs < next) { + value = Math.round(abs / scale) + suffix = label + break + } + } + if (!suffix) { + value = Math.round(abs / 604_800_000) + suffix = 'w' + } + return past ? `${value}${suffix} ago` : `in ${value}${suffix}` +} + +function capitalize(s: string): string { + if (!s) return s + return s[0].toUpperCase() + s.slice(1) +} diff --git a/tests/int/campaignStatus.int.spec.ts b/tests/int/campaignStatus.int.spec.ts new file mode 100644 index 0000000..7d3b0d2 --- /dev/null +++ b/tests/int/campaignStatus.int.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + describeCampaignStatus, + formatCampaignProgress, +} from '@/lib/campaignStatus' + +describe('describeCampaignStatus', () => { + it.each([ + ['draft', { label: 'Draft', tone: 'neutral' }], + ['scheduled', { label: 'Scheduled', tone: 'info' }], + ['sending', { label: 'Sending', tone: 'progress' }], + ['completed', { label: 'Completed', tone: 'success' }], + ['paused', { label: 'Paused', tone: 'warning' }], + ] as const)('maps known %s status', (input, expected) => { + expect(describeCampaignStatus(input)).toEqual(expected) + }) + + it('falls back to capitalized neutral for unknown status', () => { + expect(describeCampaignStatus('archived')).toEqual({ + label: 'Archived', + tone: 'neutral', + }) + }) + + it('handles null and undefined', () => { + expect(describeCampaignStatus(null)).toEqual({ + label: 'Unknown', + tone: 'neutral', + }) + expect(describeCampaignStatus(undefined)).toEqual({ + label: 'Unknown', + tone: 'neutral', + }) + }) + + it('handles empty string as Unknown', () => { + expect(describeCampaignStatus('')).toEqual({ + label: 'Unknown', + tone: 'neutral', + }) + }) +}) + +describe('formatCampaignProgress', () => { + it('formats with percentage', () => { + expect( + formatCampaignProgress({ totalRecipients: 100, sentCount: 25 }), + ).toBe('25 / 100 (25%)') + }) + + it('returns 0 / 0 when total is zero', () => { + expect( + formatCampaignProgress({ totalRecipients: 0, sentCount: 0 }), + ).toBe('0 / 0') + }) + + it('clamps sent above total back to total (no >100% display)', () => { + expect( + formatCampaignProgress({ totalRecipients: 10, sentCount: 25 }), + ).toBe('10 / 10 (100%)') + }) + + it('clamps negative sent count to zero', () => { + expect( + formatCampaignProgress({ totalRecipients: 10, sentCount: -3 }), + ).toBe('0 / 10 (0%)') + }) + + it('treats negative total as zero', () => { + expect( + formatCampaignProgress({ totalRecipients: -5, sentCount: 1 }), + ).toBe('0 / 0') + }) + + it('rounds percentage to nearest integer', () => { + expect( + formatCampaignProgress({ totalRecipients: 3, sentCount: 1 }), + ).toBe('1 / 3 (33%)') + expect( + formatCampaignProgress({ totalRecipients: 3, sentCount: 2 }), + ).toBe('2 / 3 (67%)') + }) + + it('handles NaN sent count as zero', () => { + expect( + formatCampaignProgress({ totalRecipients: 10, sentCount: NaN }), + ).toBe('0 / 10 (0%)') + }) +}) diff --git a/tests/int/queueRowSummary.int.spec.ts b/tests/int/queueRowSummary.int.spec.ts new file mode 100644 index 0000000..037ddaf --- /dev/null +++ b/tests/int/queueRowSummary.int.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { + describeQueueStatus, + formatRelativeTime, + summarizeError, +} from '@/lib/queueRowSummary' + +describe('describeQueueStatus', () => { + it.each([ + ['pending', 'Pending', 'neutral'], + ['processing', 'Processing', 'progress'], + ['sent', 'Sent', 'success'], + ['failed', 'Failed', 'error'], + ['bounced', 'Bounced', 'warning'], + ])('maps %s to %s/%s', (input, label, tone) => { + expect(describeQueueStatus(input)).toEqual({ label, tone }) + }) + + it('falls back to capitalized neutral for unknown', () => { + expect(describeQueueStatus('archived')).toEqual({ + label: 'Archived', + tone: 'neutral', + }) + }) + + it('handles null / undefined / empty', () => { + expect(describeQueueStatus(null).label).toBe('Unknown') + expect(describeQueueStatus(undefined).label).toBe('Unknown') + expect(describeQueueStatus('').label).toBe('Unknown') + }) +}) + +describe('summarizeError', () => { + it('returns empty string for empty input', () => { + expect(summarizeError(null)).toBe('') + expect(summarizeError(undefined)).toBe('') + expect(summarizeError('')).toBe('') + }) + + it('returns the first line, trimmed', () => { + expect(summarizeError(' oh no \nstack trace\nmore\n')).toBe('oh no') + }) + + it('truncates with ellipsis when over the max', () => { + const long = 'x'.repeat(200) + const out = summarizeError(long, 50) + expect(out).toHaveLength(50) + expect(out.endsWith('…')).toBe(true) + }) + + it('honors a custom max length', () => { + expect(summarizeError('hello world', 5)).toBe('hell…') + }) + + it('handles CRLF line endings', () => { + expect(summarizeError('first\r\nsecond')).toBe('first') + }) + + it('keeps lines exactly at the max as-is (no ellipsis)', () => { + const exact = 'a'.repeat(140) + expect(summarizeError(exact)).toBe(exact) + }) + + it('trims trailing whitespace before adding the ellipsis', () => { + const out = summarizeError('hello world world', 10) + expect(out.endsWith('…')).toBe(true) + expect(out).not.toMatch(/ +…$/) + }) +}) + +describe('formatRelativeTime', () => { + const now = new Date('2026-05-03T12:00:00Z') + + it.each([ + [new Date('2026-05-03T12:00:00Z'), 'just now'], + [new Date('2026-05-03T11:59:50Z'), 'just now'], + [new Date('2026-05-03T11:58:00Z'), '2m ago'], + [new Date('2026-05-03T11:00:00Z'), '1h ago'], + [new Date('2026-05-03T09:00:00Z'), '3h ago'], + [new Date('2026-05-02T12:00:00Z'), '1d ago'], + [new Date('2026-04-26T12:00:00Z'), '1w ago'], + [new Date('2026-04-01T12:00:00Z'), '5w ago'], + ])('past: %s -> %s', (from, expected) => { + expect(formatRelativeTime(from, now)).toBe(expected) + }) + + it.each([ + [new Date('2026-05-03T12:02:00Z'), 'in 2m'], + [new Date('2026-05-03T13:00:00Z'), 'in 1h'], + [new Date('2026-05-04T12:00:00Z'), 'in 1d'], + [new Date('2026-05-10T12:00:00Z'), 'in 1w'], + ])('future: %s -> %s', (from, expected) => { + expect(formatRelativeTime(from, now)).toBe(expected) + }) + + it('accepts ISO string input', () => { + expect(formatRelativeTime('2026-05-03T11:00:00Z', now)).toBe('1h ago') + }) + + it('returns empty for null / undefined / NaN dates', () => { + expect(formatRelativeTime(null, now)).toBe('') + expect(formatRelativeTime(undefined, now)).toBe('') + expect(formatRelativeTime('not-a-date', now)).toBe('') + }) + + it('rounds to the nearest unit (not floor)', () => { + // 89s ago rounds to 1m, 91s ago also rounds to 2m (closer) + expect( + formatRelativeTime(new Date(now.getTime() - 89_000), now), + ).toBe('1m ago') + expect( + formatRelativeTime(new Date(now.getTime() - 91_000), now), + ).toBe('2m ago') + }) +})