perspectapi-ts-sdk 6.5.9 → 7.0.1
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 +46 -1011
- package/dist/chunk-MZ22HQBX.mjs +1451 -0
- package/dist/index-BL9-AZpq.d.mts +2227 -0
- package/dist/index-BL9-AZpq.d.ts +2227 -0
- package/dist/index.d.mts +130 -2221
- package/dist/index.d.ts +130 -2221
- package/dist/index.js +71 -7
- package/dist/index.mjs +13 -1364
- package/dist/v2/index.d.mts +1 -0
- package/dist/v2/index.d.ts +1 -0
- package/dist/v2/index.js +1477 -0
- package/dist/v2/index.mjs +40 -0
- package/docs/README.md +15 -0
- package/docs/v1-deprecated/README.md +9 -0
- package/docs/v1-deprecated/examples/README.md +324 -0
- package/docs/v1-deprecated/examples/basic-usage.ts +258 -0
- package/docs/v1-deprecated/examples/cloudflare-worker.ts +274 -0
- package/docs/v1-deprecated/examples/content-query-with-slug-prefix.ts +237 -0
- package/docs/v1-deprecated/examples/image-transforms.ts +200 -0
- package/docs/v1-deprecated/examples/site-user-checkout.ts +186 -0
- package/docs/v1-deprecated/examples/slug-prefix-examples.ts +491 -0
- package/docs/v1-deprecated/legacy-docs/caching.md +667 -0
- package/docs/v1-deprecated/legacy-docs/contact.md +1396 -0
- package/docs/v1-deprecated/legacy-docs/csrf-protection.md +664 -0
- package/docs/v1-deprecated/legacy-docs/image-transforms.md +523 -0
- package/docs/v1-deprecated/legacy-docs/loaders.md +304 -0
- package/docs/v1-deprecated/legacy-docs/newsletter.md +811 -0
- package/docs/v1-deprecated/legacy-docs/site-users.md +817 -0
- package/docs/v1-deprecated/legacy-notes/CHANGELOG-CHECKOUT.md +143 -0
- package/docs/v1-deprecated/legacy-notes/CSRF-CHECKOUT.md +271 -0
- package/docs/v1-deprecated/legacy-notes/IMAGE_TRANSFORMS_PORT.md +298 -0
- package/docs/v1-deprecated/sdk-readme.md +1076 -0
- package/examples/README.md +19 -0
- package/examples/basic-v2.ts +37 -0
- package/llms.txt +25 -0
- package/package.json +18 -7
- package/src/client/api-keys-client.ts +4 -0
- package/src/client/auth-client.ts +4 -0
- package/src/client/base-client.ts +7 -0
- package/src/client/bundles-client.ts +4 -0
- package/src/client/categories-client.ts +4 -0
- package/src/client/checkout-client.ts +4 -0
- package/src/client/contact-client.ts +4 -0
- package/src/client/content-client.ts +4 -0
- package/src/client/newsletter-client.ts +4 -0
- package/src/client/newsletter-management-client.ts +4 -0
- package/src/client/organizations-client.ts +4 -0
- package/src/client/products-client.ts +4 -0
- package/src/client/site-users-client.ts +10 -1
- package/src/client/sites-client.ts +4 -0
- package/src/client/webhooks-client.ts +4 -0
- package/src/deprecation.ts +2 -1
- package/src/index.ts +2 -1
- package/src/loaders.ts +59 -0
- package/src/perspect-api-client.ts +2 -2
- package/src/v2/client/orders-client.ts +89 -6
- package/src/v2/types.ts +3 -0
|
@@ -0,0 +1,1396 @@
|
|
|
1
|
+
# Contact Form API Documentation
|
|
2
|
+
|
|
3
|
+
> Deprecated v1 material. Do not copy these examples into new code. v1 sunsets
|
|
4
|
+
> on 2026-06-01; use `createPerspectApiV2Client` from
|
|
5
|
+
> `perspectapi-ts-sdk/v2` and `/api/v2`.
|
|
6
|
+
|
|
7
|
+
The PerspectAPI SDK provides a robust contact form system with spam prevention, rate limiting, CAPTCHA support, and comprehensive admin management capabilities.
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
- [Quick Start](#quick-start)
|
|
12
|
+
- [Core Features](#core-features)
|
|
13
|
+
- [API Reference](#api-reference)
|
|
14
|
+
- [Public Methods](#public-methods)
|
|
15
|
+
- [Admin Methods](#admin-methods)
|
|
16
|
+
- [Security Features](#security-features)
|
|
17
|
+
- [Best Practices](#best-practices)
|
|
18
|
+
- [Examples](#examples)
|
|
19
|
+
- [Error Handling](#error-handling)
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { createPerspectApiClient } from 'perspectapi-ts-sdk';
|
|
25
|
+
|
|
26
|
+
const client = createPerspectApiClient({
|
|
27
|
+
baseUrl: 'https://api.example.com',
|
|
28
|
+
apiKey: 'your-api-key'
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Browser-based submission with CSRF protection
|
|
32
|
+
const csrfResponse = await client.getCsrfToken('my-site');
|
|
33
|
+
const csrfToken = csrfResponse.data.token;
|
|
34
|
+
|
|
35
|
+
// Submit a contact form with CSRF token
|
|
36
|
+
const submission = await client.contact.submitContact('my-site', {
|
|
37
|
+
name: 'John Doe',
|
|
38
|
+
email: 'john@example.com',
|
|
39
|
+
subject: 'Product Inquiry',
|
|
40
|
+
message: 'I would like to know more about your products.'
|
|
41
|
+
}, csrfToken); // Pass CSRF token for security
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Core Features
|
|
45
|
+
|
|
46
|
+
- **Spam Prevention**: Multiple layers including honeypot fields, rate limiting, and CAPTCHA
|
|
47
|
+
- **Cloudflare Turnstile**: Built-in support for privacy-focused CAPTCHA
|
|
48
|
+
- **Rate Limiting**: Automatic prevention of abuse (5 submissions per 15 minutes per IP/email)
|
|
49
|
+
- **Email Notifications**: Automatic notifications to site owners
|
|
50
|
+
- **Status Tracking**: Track submission status (pending, read, archived)
|
|
51
|
+
- **Admin Management**: Complete admin interface for managing submissions
|
|
52
|
+
- **Bulk Operations**: Process multiple submissions at once
|
|
53
|
+
- **Export Capability**: Export submissions in various formats
|
|
54
|
+
- **Custom Fields**: Support for additional metadata
|
|
55
|
+
|
|
56
|
+
## API Reference
|
|
57
|
+
|
|
58
|
+
### Public Methods
|
|
59
|
+
|
|
60
|
+
#### `submitContact(siteName, data)`
|
|
61
|
+
|
|
62
|
+
Submit a contact form to a specific site.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
interface CreateContactRequest {
|
|
66
|
+
name: string; // Required: Full name
|
|
67
|
+
email: string; // Required: Valid email address
|
|
68
|
+
subject?: string; // Optional: Subject line
|
|
69
|
+
message: string; // Required: Message content
|
|
70
|
+
phone?: string; // Optional: Phone number
|
|
71
|
+
company?: string; // Optional: Company name
|
|
72
|
+
turnstileToken?: string; // Optional: Cloudflare Turnstile response
|
|
73
|
+
metadata?: Record<string, any>; // Optional: Additional custom data
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Example with CSRF token (for browser-based forms)
|
|
77
|
+
const csrfToken = (await client.getCsrfToken('my-site')).data.token;
|
|
78
|
+
|
|
79
|
+
const submission = await client.contact.submitContact('my-site', {
|
|
80
|
+
name: 'Jane Smith',
|
|
81
|
+
email: 'jane@company.com',
|
|
82
|
+
subject: 'Partnership Opportunity',
|
|
83
|
+
message: 'We are interested in partnering with your company...',
|
|
84
|
+
phone: '+1-555-0123',
|
|
85
|
+
company: 'Tech Corp',
|
|
86
|
+
turnstileToken: 'turnstile-response-token',
|
|
87
|
+
metadata: {
|
|
88
|
+
source: 'contact-page',
|
|
89
|
+
campaign: 'q4-2024'
|
|
90
|
+
}
|
|
91
|
+
}, csrfToken); // Pass CSRF token
|
|
92
|
+
|
|
93
|
+
// Response
|
|
94
|
+
{
|
|
95
|
+
success: true,
|
|
96
|
+
code: "CONTACT_SUBMITTED",
|
|
97
|
+
contact_id: "contact_abc123",
|
|
98
|
+
message: "Contact form submitted successfully",
|
|
99
|
+
status: "submitted"
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### `getContactStatus(siteName, id)`
|
|
104
|
+
|
|
105
|
+
Check the status of a contact submission.
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
const status = await client.contact.getContactStatus('my-site', 'contact_abc123');
|
|
109
|
+
|
|
110
|
+
// Response
|
|
111
|
+
{
|
|
112
|
+
success: true,
|
|
113
|
+
code: "CONTACT_FOUND",
|
|
114
|
+
contact_id: "contact_abc123",
|
|
115
|
+
status: "pending", // or "read", "replied", "archived"
|
|
116
|
+
submitted_at: "2024-01-15T10:30:00Z",
|
|
117
|
+
processed_at: null,
|
|
118
|
+
metadata: {}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Admin Methods
|
|
123
|
+
|
|
124
|
+
#### `getContactSubmissions(siteName, params)`
|
|
125
|
+
|
|
126
|
+
Get all contact submissions with filtering and pagination.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const submissions = await client.contact.getContactSubmissions('my-site', {
|
|
130
|
+
page: 1,
|
|
131
|
+
limit: 50,
|
|
132
|
+
status: 'unread', // Filter by status
|
|
133
|
+
startDate: '2024-01-01', // Date range start
|
|
134
|
+
endDate: '2024-01-31', // Date range end
|
|
135
|
+
search: 'product' // Search in name, email, subject, message
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Response includes paginated list of submissions
|
|
139
|
+
{
|
|
140
|
+
data: [
|
|
141
|
+
{
|
|
142
|
+
id: "contact_123",
|
|
143
|
+
name: "John Doe",
|
|
144
|
+
email: "john@example.com",
|
|
145
|
+
subject: "Product Question",
|
|
146
|
+
message: "...",
|
|
147
|
+
status: "unread",
|
|
148
|
+
createdAt: "2024-01-15T10:30:00Z"
|
|
149
|
+
}
|
|
150
|
+
// ...
|
|
151
|
+
],
|
|
152
|
+
pagination: {
|
|
153
|
+
page: 1,
|
|
154
|
+
limit: 50,
|
|
155
|
+
total: 234,
|
|
156
|
+
totalPages: 5
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### `getContactSubmissionById(siteName, id)`
|
|
162
|
+
|
|
163
|
+
Get detailed information about a specific submission.
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
const submission = await client.contact.getContactSubmissionById(
|
|
167
|
+
'my-site',
|
|
168
|
+
'contact_abc123'
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Response includes full details
|
|
172
|
+
{
|
|
173
|
+
id: "contact_abc123",
|
|
174
|
+
name: "Jane Smith",
|
|
175
|
+
email: "jane@example.com",
|
|
176
|
+
subject: "Partnership Inquiry",
|
|
177
|
+
message: "Full message content...",
|
|
178
|
+
phone: "+1-555-0123",
|
|
179
|
+
company: "Tech Corp",
|
|
180
|
+
status: "pending",
|
|
181
|
+
ipAddress: "203.0.113.0",
|
|
182
|
+
userAgent: "Mozilla/5.0...",
|
|
183
|
+
metadata: {
|
|
184
|
+
source: "contact-page",
|
|
185
|
+
campaign: "q4-2024"
|
|
186
|
+
},
|
|
187
|
+
createdAt: "2024-01-15T10:30:00Z"
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
#### `updateContactStatus(siteName, id, status, notes?)`
|
|
192
|
+
|
|
193
|
+
Update the status of a contact submission.
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
await client.contact.updateContactStatus(
|
|
197
|
+
'my-site',
|
|
198
|
+
'contact_abc123',
|
|
199
|
+
'replied',
|
|
200
|
+
'Responded via email on Jan 16'
|
|
201
|
+
);
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### `markContactAsRead(siteName, id)`
|
|
205
|
+
|
|
206
|
+
Mark a submission as read.
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
await client.contact.markContactAsRead('my-site', 'contact_abc123');
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
#### `markContactAsUnread(siteName, id)`
|
|
213
|
+
|
|
214
|
+
Mark a submission as unread.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
await client.contact.markContactAsUnread('my-site', 'contact_abc123');
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
#### `archiveContact(siteName, id)`
|
|
221
|
+
|
|
222
|
+
Archive a contact submission.
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
await client.contact.archiveContact('my-site', 'contact_abc123');
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
#### `deleteContact(siteName, id)`
|
|
229
|
+
|
|
230
|
+
Permanently delete a contact submission.
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
await client.contact.deleteContact('my-site', 'contact_abc123');
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
#### `bulkUpdateContacts(siteName, data)`
|
|
237
|
+
|
|
238
|
+
Perform bulk operations on multiple submissions.
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
const result = await client.contact.bulkUpdateContacts('my-site', {
|
|
242
|
+
ids: ['contact_1', 'contact_2', 'contact_3'],
|
|
243
|
+
action: 'mark_read' // or 'mark_unread', 'archive', 'delete'
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Response
|
|
247
|
+
{
|
|
248
|
+
success: true,
|
|
249
|
+
updatedCount: 3,
|
|
250
|
+
failedCount: 0
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
#### `getContactStats(siteName, params)`
|
|
255
|
+
|
|
256
|
+
Get contact form statistics.
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
const stats = await client.contact.getContactStats('my-site', {
|
|
260
|
+
startDate: '2024-01-01',
|
|
261
|
+
endDate: '2024-01-31'
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Response
|
|
265
|
+
{
|
|
266
|
+
totalSubmissions: 234,
|
|
267
|
+
unreadSubmissions: 12,
|
|
268
|
+
readSubmissions: 200,
|
|
269
|
+
archivedSubmissions: 22,
|
|
270
|
+
submissionsByDay: [
|
|
271
|
+
{ date: "2024-01-01", count: 8 },
|
|
272
|
+
{ date: "2024-01-02", count: 12 },
|
|
273
|
+
// ...
|
|
274
|
+
],
|
|
275
|
+
submissionsByStatus: {
|
|
276
|
+
pending: 12,
|
|
277
|
+
read: 200,
|
|
278
|
+
archived: 22
|
|
279
|
+
},
|
|
280
|
+
averageResponseTime: 3600000 // milliseconds
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
#### `exportContacts(siteName, params)`
|
|
285
|
+
|
|
286
|
+
Export contact submissions to various formats.
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
const exportData = await client.contact.exportContacts('my-site', {
|
|
290
|
+
format: 'csv', // or 'json', 'xlsx'
|
|
291
|
+
startDate: '2024-01-01',
|
|
292
|
+
endDate: '2024-01-31',
|
|
293
|
+
status: 'all'
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Response
|
|
297
|
+
{
|
|
298
|
+
downloadUrl: "https://api.example.com/exports/contacts_export_123.csv",
|
|
299
|
+
expiresAt: "2024-01-16T10:30:00Z"
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
#### `getContactConfig(siteName)`
|
|
304
|
+
|
|
305
|
+
Get contact form configuration.
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
const config = await client.contact.getContactConfig('my-site');
|
|
309
|
+
|
|
310
|
+
// Response
|
|
311
|
+
{
|
|
312
|
+
turnstileEnabled: true,
|
|
313
|
+
rateLimitEnabled: true,
|
|
314
|
+
rateLimitWindow: 900, // seconds (15 minutes)
|
|
315
|
+
rateLimitMax: 5,
|
|
316
|
+
requiredFields: ['name', 'email', 'message'],
|
|
317
|
+
customFields: [
|
|
318
|
+
{
|
|
319
|
+
name: 'department',
|
|
320
|
+
type: 'select',
|
|
321
|
+
required: false,
|
|
322
|
+
label: 'Department'
|
|
323
|
+
}
|
|
324
|
+
]
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
#### `updateContactConfig(siteName, config)`
|
|
329
|
+
|
|
330
|
+
Update contact form configuration (admin only).
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
await client.contact.updateContactConfig('my-site', {
|
|
334
|
+
turnstileEnabled: true,
|
|
335
|
+
rateLimitEnabled: true,
|
|
336
|
+
rateLimitWindow: 600, // 10 minutes
|
|
337
|
+
rateLimitMax: 3,
|
|
338
|
+
requiredFields: ['name', 'email', 'message', 'phone'],
|
|
339
|
+
customFields: [
|
|
340
|
+
{
|
|
341
|
+
name: 'urgency',
|
|
342
|
+
type: 'select',
|
|
343
|
+
required: true,
|
|
344
|
+
label: 'How urgent is your request?'
|
|
345
|
+
}
|
|
346
|
+
]
|
|
347
|
+
});
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## Response Format
|
|
351
|
+
|
|
352
|
+
All contact API methods return responses in a standardized format with consistent success/error handling:
|
|
353
|
+
|
|
354
|
+
### Success Responses
|
|
355
|
+
|
|
356
|
+
All successful responses include:
|
|
357
|
+
- `success: true` - Indicates the operation completed successfully
|
|
358
|
+
- `code: string` - Machine-readable status code for programmatic handling
|
|
359
|
+
- `message?: string` - Human-readable message for display to users (optional)
|
|
360
|
+
- Additional response-specific data
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
// Successful contact submission
|
|
364
|
+
{
|
|
365
|
+
success: true,
|
|
366
|
+
code: "CONTACT_SUBMITTED",
|
|
367
|
+
contact_id: "contact_abc123",
|
|
368
|
+
message: "Contact form submitted successfully",
|
|
369
|
+
status: "submitted"
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Contact status lookup
|
|
373
|
+
{
|
|
374
|
+
success: true,
|
|
375
|
+
code: "CONTACT_FOUND",
|
|
376
|
+
contact_id: "contact_abc123",
|
|
377
|
+
status: "pending",
|
|
378
|
+
submitted_at: "2024-01-15T10:30:00Z",
|
|
379
|
+
processed_at: null,
|
|
380
|
+
metadata: {}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Bulk operations
|
|
384
|
+
{
|
|
385
|
+
success: true,
|
|
386
|
+
code: "CONTACTS_UPDATED",
|
|
387
|
+
updatedCount: 5,
|
|
388
|
+
failedCount: 0
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Error Responses
|
|
393
|
+
|
|
394
|
+
Failed operations include:
|
|
395
|
+
- `success: false` - Indicates the operation failed
|
|
396
|
+
- `code: string` - Machine-readable error code
|
|
397
|
+
- `error: string` - Error message describing what went wrong
|
|
398
|
+
- `details?: any` - Additional error details (for validation errors)
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
// Rate limiting
|
|
402
|
+
{
|
|
403
|
+
success: false,
|
|
404
|
+
code: "RATE_LIMITED",
|
|
405
|
+
error: "Rate limit exceeded. Please try again later.",
|
|
406
|
+
remaining_submissions: 0
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Validation error
|
|
410
|
+
{
|
|
411
|
+
success: false,
|
|
412
|
+
code: "VALIDATION_ERROR",
|
|
413
|
+
error: "Validation failed",
|
|
414
|
+
details: {
|
|
415
|
+
fieldErrors: {
|
|
416
|
+
email: ["Invalid email format"],
|
|
417
|
+
message: ["Message is required"]
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Contact not found
|
|
423
|
+
{
|
|
424
|
+
success: false,
|
|
425
|
+
code: "CONTACT_NOT_FOUND",
|
|
426
|
+
error: "Contact not found"
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Response Codes
|
|
431
|
+
|
|
432
|
+
#### Success Codes (`success: true`)
|
|
433
|
+
- `CONTACT_SUBMITTED` - Contact form submitted successfully
|
|
434
|
+
- `CONTACT_FOUND` - Contact record retrieved
|
|
435
|
+
- `CONTACTS_LISTED` - Contact list retrieved
|
|
436
|
+
- `CONTACTS_UPDATED` - Bulk update completed
|
|
437
|
+
|
|
438
|
+
#### Error Codes (`success: false`)
|
|
439
|
+
- `INVALID_BODY` - Malformed request data
|
|
440
|
+
- `VALIDATION_ERROR` - Field validation failed
|
|
441
|
+
- `SITE_NOT_FOUND` - Site not found
|
|
442
|
+
- `RATE_LIMITED` - Too many submissions
|
|
443
|
+
- `CONTACT_NOT_FOUND` - Contact record not found
|
|
444
|
+
- `MISSING_SITE_ID` - Site ID parameter missing
|
|
445
|
+
- `UNAUTHORIZED` - Invalid authentication
|
|
446
|
+
- `DATABASE_ERROR` - Database operation failed
|
|
447
|
+
- `SERVER_ERROR` - Internal server error
|
|
448
|
+
|
|
449
|
+
### Handling Responses
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
const result = await client.contact.submitContact('my-site', {
|
|
453
|
+
name: 'John Doe',
|
|
454
|
+
email: 'john@example.com',
|
|
455
|
+
message: 'Hello world'
|
|
456
|
+
}, csrfToken);
|
|
457
|
+
|
|
458
|
+
// Type-safe response handling
|
|
459
|
+
if (result.data?.success) {
|
|
460
|
+
switch (result.data.code) {
|
|
461
|
+
case 'CONTACT_SUBMITTED':
|
|
462
|
+
showSuccess('Your message has been sent successfully!');
|
|
463
|
+
// Redirect to thank you page
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
switch (result.data?.code) {
|
|
468
|
+
case 'RATE_LIMITED':
|
|
469
|
+
showError('Please wait before sending another message');
|
|
470
|
+
break;
|
|
471
|
+
case 'VALIDATION_ERROR':
|
|
472
|
+
showValidationErrors(result.data.details);
|
|
473
|
+
break;
|
|
474
|
+
default:
|
|
475
|
+
showError(result.data?.error || 'Something went wrong');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## Security Features
|
|
481
|
+
|
|
482
|
+
### 1. Cloudflare Turnstile Integration
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
// Client-side: Get Turnstile token
|
|
486
|
+
const turnstileToken = await getTurnstileResponse();
|
|
487
|
+
|
|
488
|
+
// Include token in submission
|
|
489
|
+
const submission = await client.contact.submitContact('my-site', {
|
|
490
|
+
name: 'John Doe',
|
|
491
|
+
email: 'john@example.com',
|
|
492
|
+
message: 'Hello',
|
|
493
|
+
turnstileToken // Required when Turnstile is enabled
|
|
494
|
+
});
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### 2. Honeypot Field
|
|
498
|
+
|
|
499
|
+
The API automatically handles honeypot fields to catch bots:
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
// In your HTML form
|
|
503
|
+
<input type="text" name="hp" style="display: none" />
|
|
504
|
+
|
|
505
|
+
// The SDK handles this automatically
|
|
506
|
+
// If honeypot is filled, submission is silently rejected
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### 3. Rate Limiting
|
|
510
|
+
|
|
511
|
+
Built-in rate limiting prevents abuse:
|
|
512
|
+
- 5 submissions per 15 minutes per IP/email combination
|
|
513
|
+
- Returns 429 status when limit exceeded
|
|
514
|
+
- Automatic cleanup of old rate limit records
|
|
515
|
+
|
|
516
|
+
### 4. CSRF Protection
|
|
517
|
+
|
|
518
|
+
The API requires CSRF tokens for browser-based submissions:
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
// Fetch CSRF token with proper credentials
|
|
522
|
+
const csrfResponse = await fetch('https://api.example.com/api/v1/csrf/token/my-site', {
|
|
523
|
+
credentials: 'same-origin', // Include cookies for session
|
|
524
|
+
headers: {
|
|
525
|
+
'X-API-Key': 'your-api-key'
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
const csrfData = await csrfResponse.json();
|
|
529
|
+
const csrfToken = csrfData.token;
|
|
530
|
+
|
|
531
|
+
// Use token in SDK
|
|
532
|
+
const submission = await client.contact.submitContact('my-site', formData, csrfToken);
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**Important**: Never auto-fetch CSRF tokens in the SDK itself, as this defeats the security purpose. The client application must fetch and provide the token.
|
|
536
|
+
|
|
537
|
+
## Best Practices
|
|
538
|
+
|
|
539
|
+
### 1. Always Use Turnstile for Public Forms
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
// React component example
|
|
543
|
+
function ContactForm() {
|
|
544
|
+
const [turnstileToken, setTurnstileToken] = useState('');
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<form onSubmit={handleSubmit}>
|
|
548
|
+
{/* Form fields */}
|
|
549
|
+
|
|
550
|
+
<Turnstile
|
|
551
|
+
sitekey={process.env.REACT_APP_TURNSTILE_SITE_KEY}
|
|
552
|
+
onVerify={(token) => setTurnstileToken(token)}
|
|
553
|
+
/>
|
|
554
|
+
|
|
555
|
+
<button type="submit">Send Message</button>
|
|
556
|
+
</form>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
### 2. Implement Proper Error Handling
|
|
562
|
+
|
|
563
|
+
```typescript
|
|
564
|
+
try {
|
|
565
|
+
const submission = await client.contact.submitContact('my-site', formData);
|
|
566
|
+
showSuccess('Message sent successfully!');
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if (error.status === 429) {
|
|
569
|
+
showError('Too many submissions. Please wait before trying again.');
|
|
570
|
+
} else if (error.status === 400) {
|
|
571
|
+
showError('Please check your input and try again.');
|
|
572
|
+
} else {
|
|
573
|
+
showError('Something went wrong. Please try again later.');
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### 3. Validate on Both Client and Server
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
// Client-side validation
|
|
582
|
+
function validateForm(data) {
|
|
583
|
+
const errors = {};
|
|
584
|
+
|
|
585
|
+
if (!data.name || data.name.length < 2) {
|
|
586
|
+
errors.name = 'Name must be at least 2 characters';
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (!isValidEmail(data.email)) {
|
|
590
|
+
errors.email = 'Please enter a valid email';
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (!data.message || data.message.length < 10) {
|
|
594
|
+
errors.message = 'Message must be at least 10 characters';
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return errors;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Server validates automatically via the API
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### 4. Implement Admin Notifications
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
// Set up webhook or polling for new submissions
|
|
607
|
+
async function checkNewSubmissions() {
|
|
608
|
+
const submissions = await client.contact.getContactSubmissions('my-site', {
|
|
609
|
+
status: 'unread',
|
|
610
|
+
limit: 10
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
if (submissions.data.length > 0) {
|
|
614
|
+
// Send notification to admin
|
|
615
|
+
notifyAdmin(`You have ${submissions.data.length} new messages`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Run every 5 minutes
|
|
620
|
+
setInterval(checkNewSubmissions, 5 * 60 * 1000);
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
## Examples
|
|
624
|
+
|
|
625
|
+
### React Contact Form Component
|
|
626
|
+
|
|
627
|
+
```tsx
|
|
628
|
+
import React, { useState } from 'react';
|
|
629
|
+
import { createPerspectApiClient } from 'perspectapi-ts-sdk';
|
|
630
|
+
import { Turnstile } from '@marsidev/react-turnstile';
|
|
631
|
+
|
|
632
|
+
interface FormData {
|
|
633
|
+
name: string;
|
|
634
|
+
email: string;
|
|
635
|
+
subject: string;
|
|
636
|
+
message: string;
|
|
637
|
+
phone?: string;
|
|
638
|
+
company?: string;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function ContactForm() {
|
|
642
|
+
const [formData, setFormData] = useState<FormData>({
|
|
643
|
+
name: '',
|
|
644
|
+
email: '',
|
|
645
|
+
subject: '',
|
|
646
|
+
message: ''
|
|
647
|
+
});
|
|
648
|
+
const [turnstileToken, setTurnstileToken] = useState('');
|
|
649
|
+
const [csrfToken, setCsrfToken] = useState<string | null>(null);
|
|
650
|
+
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
|
651
|
+
const [errorMessage, setErrorMessage] = useState('');
|
|
652
|
+
|
|
653
|
+
const client = createPerspectApiClient({
|
|
654
|
+
baseUrl: process.env.REACT_APP_API_URL!,
|
|
655
|
+
apiKey: process.env.REACT_APP_API_KEY!
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// Fetch CSRF token on component mount
|
|
659
|
+
useEffect(() => {
|
|
660
|
+
async function fetchCsrfToken() {
|
|
661
|
+
try {
|
|
662
|
+
const response = await client.getCsrfToken('my-site');
|
|
663
|
+
setCsrfToken(response.data.token || '');
|
|
664
|
+
} catch (error) {
|
|
665
|
+
console.error('Failed to fetch CSRF token:', error);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
fetchCsrfToken();
|
|
669
|
+
}, []);
|
|
670
|
+
|
|
671
|
+
const handleInputChange = (
|
|
672
|
+
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
673
|
+
) => {
|
|
674
|
+
const { name, value } = e.target;
|
|
675
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
679
|
+
e.preventDefault();
|
|
680
|
+
|
|
681
|
+
if (!csrfToken) {
|
|
682
|
+
setErrorMessage('Security token not loaded. Please refresh and try again.');
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
setStatus('submitting');
|
|
687
|
+
setErrorMessage('');
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
const result = await client.contact.submitContact('my-site', {
|
|
691
|
+
...formData,
|
|
692
|
+
turnstileToken,
|
|
693
|
+
metadata: {
|
|
694
|
+
source: 'contact-page',
|
|
695
|
+
timestamp: new Date().toISOString()
|
|
696
|
+
}
|
|
697
|
+
}, csrfToken); // Pass CSRF token
|
|
698
|
+
|
|
699
|
+
setStatus('success');
|
|
700
|
+
setFormData({
|
|
701
|
+
name: '',
|
|
702
|
+
email: '',
|
|
703
|
+
subject: '',
|
|
704
|
+
message: ''
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// Show success message
|
|
708
|
+
alert('Thank you for your message! We\'ll get back to you soon.');
|
|
709
|
+
} catch (error: any) {
|
|
710
|
+
setStatus('error');
|
|
711
|
+
|
|
712
|
+
if (error.status === 429) {
|
|
713
|
+
setErrorMessage('Too many submissions. Please wait a few minutes.');
|
|
714
|
+
} else if (error.status === 400) {
|
|
715
|
+
setErrorMessage('Please check your input and try again.');
|
|
716
|
+
} else {
|
|
717
|
+
setErrorMessage('Something went wrong. Please try again later.');
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
return (
|
|
723
|
+
<form onSubmit={handleSubmit} className="contact-form">
|
|
724
|
+
<div className="form-group">
|
|
725
|
+
<label htmlFor="name">Name *</label>
|
|
726
|
+
<input
|
|
727
|
+
type="text"
|
|
728
|
+
id="name"
|
|
729
|
+
name="name"
|
|
730
|
+
value={formData.name}
|
|
731
|
+
onChange={handleInputChange}
|
|
732
|
+
required
|
|
733
|
+
disabled={status === 'submitting'}
|
|
734
|
+
/>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
<div className="form-group">
|
|
738
|
+
<label htmlFor="email">Email *</label>
|
|
739
|
+
<input
|
|
740
|
+
type="email"
|
|
741
|
+
id="email"
|
|
742
|
+
name="email"
|
|
743
|
+
value={formData.email}
|
|
744
|
+
onChange={handleInputChange}
|
|
745
|
+
required
|
|
746
|
+
disabled={status === 'submitting'}
|
|
747
|
+
/>
|
|
748
|
+
</div>
|
|
749
|
+
|
|
750
|
+
<div className="form-group">
|
|
751
|
+
<label htmlFor="phone">Phone</label>
|
|
752
|
+
<input
|
|
753
|
+
type="tel"
|
|
754
|
+
id="phone"
|
|
755
|
+
name="phone"
|
|
756
|
+
value={formData.phone || ''}
|
|
757
|
+
onChange={handleInputChange}
|
|
758
|
+
disabled={status === 'submitting'}
|
|
759
|
+
/>
|
|
760
|
+
</div>
|
|
761
|
+
|
|
762
|
+
<div className="form-group">
|
|
763
|
+
<label htmlFor="company">Company</label>
|
|
764
|
+
<input
|
|
765
|
+
type="text"
|
|
766
|
+
id="company"
|
|
767
|
+
name="company"
|
|
768
|
+
value={formData.company || ''}
|
|
769
|
+
onChange={handleInputChange}
|
|
770
|
+
disabled={status === 'submitting'}
|
|
771
|
+
/>
|
|
772
|
+
</div>
|
|
773
|
+
|
|
774
|
+
<div className="form-group">
|
|
775
|
+
<label htmlFor="subject">Subject</label>
|
|
776
|
+
<input
|
|
777
|
+
type="text"
|
|
778
|
+
id="subject"
|
|
779
|
+
name="subject"
|
|
780
|
+
value={formData.subject}
|
|
781
|
+
onChange={handleInputChange}
|
|
782
|
+
disabled={status === 'submitting'}
|
|
783
|
+
/>
|
|
784
|
+
</div>
|
|
785
|
+
|
|
786
|
+
<div className="form-group">
|
|
787
|
+
<label htmlFor="message">Message *</label>
|
|
788
|
+
<textarea
|
|
789
|
+
id="message"
|
|
790
|
+
name="message"
|
|
791
|
+
value={formData.message}
|
|
792
|
+
onChange={handleInputChange}
|
|
793
|
+
rows={5}
|
|
794
|
+
required
|
|
795
|
+
disabled={status === 'submitting'}
|
|
796
|
+
/>
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
{/* Honeypot field - hidden from users */}
|
|
800
|
+
<input
|
|
801
|
+
type="text"
|
|
802
|
+
name="hp"
|
|
803
|
+
style={{ display: 'none' }}
|
|
804
|
+
tabIndex={-1}
|
|
805
|
+
autoComplete="off"
|
|
806
|
+
/>
|
|
807
|
+
|
|
808
|
+
{/* Turnstile CAPTCHA */}
|
|
809
|
+
<div className="form-group">
|
|
810
|
+
<Turnstile
|
|
811
|
+
sitekey={process.env.REACT_APP_TURNSTILE_SITE_KEY!}
|
|
812
|
+
onVerify={(token) => setTurnstileToken(token)}
|
|
813
|
+
/>
|
|
814
|
+
</div>
|
|
815
|
+
|
|
816
|
+
{errorMessage && (
|
|
817
|
+
<div className="error-message">{errorMessage}</div>
|
|
818
|
+
)}
|
|
819
|
+
|
|
820
|
+
<button
|
|
821
|
+
type="submit"
|
|
822
|
+
disabled={status === 'submitting' || !turnstileToken || !csrfToken}
|
|
823
|
+
>
|
|
824
|
+
{status === 'submitting' ? 'Sending...' : 'Send Message'}
|
|
825
|
+
</button>
|
|
826
|
+
|
|
827
|
+
{status === 'success' && (
|
|
828
|
+
<div className="success-message">
|
|
829
|
+
Message sent successfully!
|
|
830
|
+
</div>
|
|
831
|
+
)}
|
|
832
|
+
</form>
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
### Vue.js Contact Form
|
|
838
|
+
|
|
839
|
+
```vue
|
|
840
|
+
<template>
|
|
841
|
+
<form @submit.prevent="submitForm" class="contact-form">
|
|
842
|
+
<div class="form-group">
|
|
843
|
+
<label for="name">Name *</label>
|
|
844
|
+
<input
|
|
845
|
+
v-model="formData.name"
|
|
846
|
+
type="text"
|
|
847
|
+
id="name"
|
|
848
|
+
required
|
|
849
|
+
:disabled="isSubmitting"
|
|
850
|
+
/>
|
|
851
|
+
</div>
|
|
852
|
+
|
|
853
|
+
<div class="form-group">
|
|
854
|
+
<label for="email">Email *</label>
|
|
855
|
+
<input
|
|
856
|
+
v-model="formData.email"
|
|
857
|
+
type="email"
|
|
858
|
+
id="email"
|
|
859
|
+
required
|
|
860
|
+
:disabled="isSubmitting"
|
|
861
|
+
/>
|
|
862
|
+
</div>
|
|
863
|
+
|
|
864
|
+
<div class="form-group">
|
|
865
|
+
<label for="message">Message *</label>
|
|
866
|
+
<textarea
|
|
867
|
+
v-model="formData.message"
|
|
868
|
+
id="message"
|
|
869
|
+
rows="5"
|
|
870
|
+
required
|
|
871
|
+
:disabled="isSubmitting"
|
|
872
|
+
></textarea>
|
|
873
|
+
</div>
|
|
874
|
+
|
|
875
|
+
<!-- Honeypot -->
|
|
876
|
+
<input
|
|
877
|
+
type="text"
|
|
878
|
+
name="hp"
|
|
879
|
+
v-model="honeypot"
|
|
880
|
+
style="display: none"
|
|
881
|
+
/>
|
|
882
|
+
|
|
883
|
+
<!-- Turnstile -->
|
|
884
|
+
<div ref="turnstileContainer"></div>
|
|
885
|
+
|
|
886
|
+
<div v-if="errorMessage" class="error">
|
|
887
|
+
{{ errorMessage }}
|
|
888
|
+
</div>
|
|
889
|
+
|
|
890
|
+
<button type="submit" :disabled="isSubmitting || !turnstileToken">
|
|
891
|
+
{{ isSubmitting ? 'Sending...' : 'Send Message' }}
|
|
892
|
+
</button>
|
|
893
|
+
</form>
|
|
894
|
+
</template>
|
|
895
|
+
|
|
896
|
+
<script>
|
|
897
|
+
import { createPerspectApiClient } from 'perspectapi-ts-sdk';
|
|
898
|
+
|
|
899
|
+
export default {
|
|
900
|
+
data() {
|
|
901
|
+
return {
|
|
902
|
+
formData: {
|
|
903
|
+
name: '',
|
|
904
|
+
email: '',
|
|
905
|
+
message: ''
|
|
906
|
+
},
|
|
907
|
+
honeypot: '',
|
|
908
|
+
turnstileToken: '',
|
|
909
|
+
csrfToken: null,
|
|
910
|
+
isSubmitting: false,
|
|
911
|
+
errorMessage: ''
|
|
912
|
+
};
|
|
913
|
+
},
|
|
914
|
+
async mounted() {
|
|
915
|
+
// Fetch CSRF token
|
|
916
|
+
const client = createPerspectApiClient({
|
|
917
|
+
baseUrl: process.env.VUE_APP_API_URL,
|
|
918
|
+
apiKey: process.env.VUE_APP_API_KEY
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const response = await client.getCsrfToken('my-site');
|
|
923
|
+
this.csrfToken = response.data.token;
|
|
924
|
+
} catch (error) {
|
|
925
|
+
console.error('Failed to fetch CSRF token:', error);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Initialize Turnstile
|
|
929
|
+
window.turnstile.render(this.$refs.turnstileContainer, {
|
|
930
|
+
sitekey: process.env.VUE_APP_TURNSTILE_SITE_KEY,
|
|
931
|
+
callback: (token) => {
|
|
932
|
+
this.turnstileToken = token;
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
},
|
|
936
|
+
methods: {
|
|
937
|
+
async submitForm() {
|
|
938
|
+
if (this.honeypot) return; // Bot detected
|
|
939
|
+
|
|
940
|
+
if (!this.csrfToken) {
|
|
941
|
+
this.errorMessage = 'Security token not loaded. Please refresh.';
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
this.isSubmitting = true;
|
|
946
|
+
this.errorMessage = '';
|
|
947
|
+
|
|
948
|
+
const client = createPerspectApiClient({
|
|
949
|
+
baseUrl: process.env.VUE_APP_API_URL,
|
|
950
|
+
apiKey: process.env.VUE_APP_API_KEY
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
await client.contact.submitContact('my-site', {
|
|
955
|
+
...this.formData,
|
|
956
|
+
turnstileToken: this.turnstileToken
|
|
957
|
+
}, this.csrfToken); // Pass CSRF token
|
|
958
|
+
|
|
959
|
+
// Reset form
|
|
960
|
+
this.formData = {
|
|
961
|
+
name: '',
|
|
962
|
+
email: '',
|
|
963
|
+
message: ''
|
|
964
|
+
};
|
|
965
|
+
window.turnstile.reset();
|
|
966
|
+
|
|
967
|
+
alert('Message sent successfully!');
|
|
968
|
+
} catch (error) {
|
|
969
|
+
this.handleError(error);
|
|
970
|
+
} finally {
|
|
971
|
+
this.isSubmitting = false;
|
|
972
|
+
}
|
|
973
|
+
},
|
|
974
|
+
handleError(error) {
|
|
975
|
+
if (error.status === 429) {
|
|
976
|
+
this.errorMessage = 'Too many attempts. Please wait.';
|
|
977
|
+
} else {
|
|
978
|
+
this.errorMessage = 'Failed to send message. Please try again.';
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
</script>
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
### Next.js API Route Handler
|
|
987
|
+
|
|
988
|
+
```typescript
|
|
989
|
+
// pages/api/contact.ts or app/api/contact/route.ts
|
|
990
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
991
|
+
import { createPerspectApiClient } from 'perspectapi-ts-sdk';
|
|
992
|
+
|
|
993
|
+
export async function POST(request: NextRequest) {
|
|
994
|
+
const body = await request.json();
|
|
995
|
+
|
|
996
|
+
// Initialize SDK with server-side credentials
|
|
997
|
+
const client = createPerspectApiClient({
|
|
998
|
+
baseUrl: process.env.PERSPECT_API_URL!,
|
|
999
|
+
apiKey: process.env.PERSPECT_API_KEY!
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
try {
|
|
1003
|
+
// Server-side validation
|
|
1004
|
+
if (!body.name || !body.email || !body.message) {
|
|
1005
|
+
return NextResponse.json(
|
|
1006
|
+
{ error: 'Missing required fields' },
|
|
1007
|
+
{ status: 400 }
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Submit to PerspectAPI
|
|
1012
|
+
const result = await client.contact.submitContact(
|
|
1013
|
+
process.env.SITE_NAME!,
|
|
1014
|
+
{
|
|
1015
|
+
name: body.name,
|
|
1016
|
+
email: body.email,
|
|
1017
|
+
subject: body.subject,
|
|
1018
|
+
message: body.message,
|
|
1019
|
+
phone: body.phone,
|
|
1020
|
+
company: body.company,
|
|
1021
|
+
turnstileToken: body.turnstileToken,
|
|
1022
|
+
metadata: {
|
|
1023
|
+
ip: request.ip,
|
|
1024
|
+
userAgent: request.headers.get('user-agent'),
|
|
1025
|
+
source: 'website'
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
return NextResponse.json({
|
|
1031
|
+
success: true,
|
|
1032
|
+
id: result.data.id,
|
|
1033
|
+
message: 'Contact form submitted successfully'
|
|
1034
|
+
});
|
|
1035
|
+
} catch (error: any) {
|
|
1036
|
+
console.error('Contact form error:', error);
|
|
1037
|
+
|
|
1038
|
+
if (error.status === 429) {
|
|
1039
|
+
return NextResponse.json(
|
|
1040
|
+
{ error: 'Too many submissions. Please wait.' },
|
|
1041
|
+
{ status: 429 }
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return NextResponse.json(
|
|
1046
|
+
{ error: 'Failed to submit contact form' },
|
|
1047
|
+
{ status: 500 }
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
### Admin Dashboard Component
|
|
1054
|
+
|
|
1055
|
+
```typescript
|
|
1056
|
+
// Admin dashboard for managing contact submissions
|
|
1057
|
+
import { useState, useEffect } from 'react';
|
|
1058
|
+
import { createPerspectApiClient } from 'perspectapi-ts-sdk';
|
|
1059
|
+
|
|
1060
|
+
export function ContactAdmin() {
|
|
1061
|
+
const [submissions, setSubmissions] = useState([]);
|
|
1062
|
+
const [stats, setStats] = useState(null);
|
|
1063
|
+
const [filter, setFilter] = useState('unread');
|
|
1064
|
+
|
|
1065
|
+
const client = createPerspectApiClient({
|
|
1066
|
+
baseUrl: process.env.REACT_APP_API_URL!,
|
|
1067
|
+
jwt: localStorage.getItem('adminToken')!
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
useEffect(() => {
|
|
1071
|
+
loadSubmissions();
|
|
1072
|
+
loadStats();
|
|
1073
|
+
}, [filter]);
|
|
1074
|
+
|
|
1075
|
+
const loadSubmissions = async () => {
|
|
1076
|
+
const result = await client.contact.getContactSubmissions('my-site', {
|
|
1077
|
+
status: filter === 'all' ? undefined : filter,
|
|
1078
|
+
limit: 50,
|
|
1079
|
+
page: 1
|
|
1080
|
+
});
|
|
1081
|
+
setSubmissions(result.data);
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
const loadStats = async () => {
|
|
1085
|
+
const stats = await client.contact.getContactStats('my-site');
|
|
1086
|
+
setStats(stats.data);
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
const markAsRead = async (id: string) => {
|
|
1090
|
+
await client.contact.markContactAsRead('my-site', id);
|
|
1091
|
+
await loadSubmissions();
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
const archiveSubmission = async (id: string) => {
|
|
1095
|
+
await client.contact.archiveContact('my-site', id);
|
|
1096
|
+
await loadSubmissions();
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
const exportSubmissions = async () => {
|
|
1100
|
+
const result = await client.contact.exportContacts('my-site', {
|
|
1101
|
+
format: 'csv',
|
|
1102
|
+
status: filter === 'all' ? undefined : filter
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
// Download the export
|
|
1106
|
+
window.open(result.data.downloadUrl, '_blank');
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
const bulkMarkRead = async () => {
|
|
1110
|
+
const unreadIds = submissions
|
|
1111
|
+
.filter(s => s.status === 'unread')
|
|
1112
|
+
.map(s => s.id);
|
|
1113
|
+
|
|
1114
|
+
if (unreadIds.length > 0) {
|
|
1115
|
+
await client.contact.bulkUpdateContacts('my-site', {
|
|
1116
|
+
ids: unreadIds,
|
|
1117
|
+
action: 'mark_read'
|
|
1118
|
+
});
|
|
1119
|
+
await loadSubmissions();
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
return (
|
|
1124
|
+
<div className="contact-admin">
|
|
1125
|
+
<div className="stats">
|
|
1126
|
+
{stats && (
|
|
1127
|
+
<>
|
|
1128
|
+
<div>Total: {stats.totalSubmissions}</div>
|
|
1129
|
+
<div>Unread: {stats.unreadSubmissions}</div>
|
|
1130
|
+
<div>Archived: {stats.archivedSubmissions}</div>
|
|
1131
|
+
</>
|
|
1132
|
+
)}
|
|
1133
|
+
</div>
|
|
1134
|
+
|
|
1135
|
+
<div className="actions">
|
|
1136
|
+
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
|
|
1137
|
+
<option value="all">All</option>
|
|
1138
|
+
<option value="unread">Unread</option>
|
|
1139
|
+
<option value="read">Read</option>
|
|
1140
|
+
<option value="archived">Archived</option>
|
|
1141
|
+
</select>
|
|
1142
|
+
|
|
1143
|
+
<button onClick={bulkMarkRead}>Mark All Read</button>
|
|
1144
|
+
<button onClick={exportSubmissions}>Export CSV</button>
|
|
1145
|
+
</div>
|
|
1146
|
+
|
|
1147
|
+
<div className="submissions">
|
|
1148
|
+
{submissions.map(submission => (
|
|
1149
|
+
<div key={submission.id} className="submission-card">
|
|
1150
|
+
<div className="header">
|
|
1151
|
+
<strong>{submission.name}</strong>
|
|
1152
|
+
<span>{submission.email}</span>
|
|
1153
|
+
<span>{new Date(submission.createdAt).toLocaleDateString()}</span>
|
|
1154
|
+
</div>
|
|
1155
|
+
|
|
1156
|
+
<div className="subject">{submission.subject}</div>
|
|
1157
|
+
<div className="message">{submission.message}</div>
|
|
1158
|
+
|
|
1159
|
+
<div className="actions">
|
|
1160
|
+
{submission.status === 'unread' && (
|
|
1161
|
+
<button onClick={() => markAsRead(submission.id)}>
|
|
1162
|
+
Mark Read
|
|
1163
|
+
</button>
|
|
1164
|
+
)}
|
|
1165
|
+
<button onClick={() => archiveSubmission(submission.id)}>
|
|
1166
|
+
Archive
|
|
1167
|
+
</button>
|
|
1168
|
+
</div>
|
|
1169
|
+
</div>
|
|
1170
|
+
))}
|
|
1171
|
+
</div>
|
|
1172
|
+
</div>
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
### Cloudflare Worker for Contact Processing
|
|
1178
|
+
|
|
1179
|
+
```typescript
|
|
1180
|
+
// Cloudflare Worker to handle contact forms
|
|
1181
|
+
import { createPerspectApiClient } from 'perspectapi-ts-sdk';
|
|
1182
|
+
|
|
1183
|
+
export default {
|
|
1184
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
1185
|
+
if (request.method !== 'POST') {
|
|
1186
|
+
return new Response('Method not allowed', { status: 405 });
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const client = createPerspectApiClient({
|
|
1190
|
+
baseUrl: env.API_URL,
|
|
1191
|
+
apiKey: env.API_KEY
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
try {
|
|
1195
|
+
const formData = await request.json();
|
|
1196
|
+
|
|
1197
|
+
// Get client IP for rate limiting
|
|
1198
|
+
const clientIp = request.headers.get('CF-Connecting-IP');
|
|
1199
|
+
|
|
1200
|
+
const result = await client.contact.submitContact(env.SITE_NAME, {
|
|
1201
|
+
...formData,
|
|
1202
|
+
metadata: {
|
|
1203
|
+
ip: clientIp,
|
|
1204
|
+
country: request.cf?.country,
|
|
1205
|
+
source: 'cloudflare-worker'
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
// Send email notification (optional)
|
|
1210
|
+
await env.EMAIL_QUEUE.send({
|
|
1211
|
+
to: env.ADMIN_EMAIL,
|
|
1212
|
+
subject: `New Contact: ${formData.subject}`,
|
|
1213
|
+
text: `New contact form submission from ${formData.name}`
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
return new Response(JSON.stringify({
|
|
1217
|
+
success: true,
|
|
1218
|
+
id: result.data.id
|
|
1219
|
+
}), {
|
|
1220
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1221
|
+
});
|
|
1222
|
+
} catch (error: any) {
|
|
1223
|
+
return new Response(JSON.stringify({
|
|
1224
|
+
error: error.message
|
|
1225
|
+
}), {
|
|
1226
|
+
status: error.status || 500,
|
|
1227
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
## Error Handling
|
|
1235
|
+
|
|
1236
|
+
```typescript
|
|
1237
|
+
import { createApiError } from 'perspectapi-ts-sdk';
|
|
1238
|
+
|
|
1239
|
+
try {
|
|
1240
|
+
await client.contact.submitContact('my-site', data);
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
const apiError = createApiError(error);
|
|
1243
|
+
|
|
1244
|
+
switch (apiError.status) {
|
|
1245
|
+
case 400:
|
|
1246
|
+
// Validation error
|
|
1247
|
+
console.error('Invalid input:', apiError.details);
|
|
1248
|
+
break;
|
|
1249
|
+
case 429:
|
|
1250
|
+
// Rate limited
|
|
1251
|
+
console.warn('Too many requests, please wait');
|
|
1252
|
+
break;
|
|
1253
|
+
case 500:
|
|
1254
|
+
// Server error
|
|
1255
|
+
console.error('Server error, please try again');
|
|
1256
|
+
break;
|
|
1257
|
+
default:
|
|
1258
|
+
console.error('Unexpected error:', apiError.message);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
## Rate Limiting
|
|
1264
|
+
|
|
1265
|
+
The API enforces rate limits to prevent abuse:
|
|
1266
|
+
|
|
1267
|
+
- **Default**: 5 submissions per 15 minutes per IP/email combination
|
|
1268
|
+
- **Response**: HTTP 429 when limit exceeded
|
|
1269
|
+
- **Reset**: Limits reset after the time window expires
|
|
1270
|
+
|
|
1271
|
+
Handle rate limits gracefully:
|
|
1272
|
+
|
|
1273
|
+
```typescript
|
|
1274
|
+
async function submitWithRetry(data: any, maxRetries = 3) {
|
|
1275
|
+
let attempts = 0;
|
|
1276
|
+
|
|
1277
|
+
while (attempts < maxRetries) {
|
|
1278
|
+
try {
|
|
1279
|
+
return await client.contact.submitContact('my-site', data);
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
if (error.status === 429 && attempts < maxRetries - 1) {
|
|
1282
|
+
// Wait before retry (exponential backoff)
|
|
1283
|
+
const delay = Math.pow(2, attempts) * 1000;
|
|
1284
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1285
|
+
attempts++;
|
|
1286
|
+
} else {
|
|
1287
|
+
throw error;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
## Testing
|
|
1295
|
+
|
|
1296
|
+
```typescript
|
|
1297
|
+
// Jest test example
|
|
1298
|
+
describe('Contact Form', () => {
|
|
1299
|
+
const client = createPerspectApiClient({
|
|
1300
|
+
baseUrl: 'http://localhost:8787',
|
|
1301
|
+
apiKey: 'test-key'
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
test('should submit contact form', async () => {
|
|
1305
|
+
const result = await client.contact.submitContact('test-site', {
|
|
1306
|
+
name: 'Test User',
|
|
1307
|
+
email: 'test@example.com',
|
|
1308
|
+
message: 'This is a test message'
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
expect(result.data.id).toBeDefined();
|
|
1312
|
+
expect(result.data.status).toBe('pending');
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
test('should handle rate limiting', async () => {
|
|
1316
|
+
// Submit multiple times to trigger rate limit
|
|
1317
|
+
for (let i = 0; i < 5; i++) {
|
|
1318
|
+
await client.contact.submitContact('test-site', {
|
|
1319
|
+
name: 'Test User',
|
|
1320
|
+
email: 'test@example.com',
|
|
1321
|
+
message: `Message ${i}`
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// 6th submission should fail
|
|
1326
|
+
await expect(
|
|
1327
|
+
client.contact.submitContact('test-site', {
|
|
1328
|
+
name: 'Test User',
|
|
1329
|
+
email: 'test@example.com',
|
|
1330
|
+
message: 'This should fail'
|
|
1331
|
+
})
|
|
1332
|
+
).rejects.toThrow('Too many requests');
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
```
|
|
1336
|
+
|
|
1337
|
+
## Migration from Other Systems
|
|
1338
|
+
|
|
1339
|
+
If migrating from another contact form system:
|
|
1340
|
+
|
|
1341
|
+
```typescript
|
|
1342
|
+
// Example: Migrate from WordPress Contact Form 7
|
|
1343
|
+
async function migrateContacts(oldContacts: any[]) {
|
|
1344
|
+
const client = createPerspectApiClient({
|
|
1345
|
+
baseUrl: process.env.API_URL!,
|
|
1346
|
+
apiKey: process.env.API_KEY!
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
for (const contact of oldContacts) {
|
|
1350
|
+
// Transform old format to new
|
|
1351
|
+
const submission = {
|
|
1352
|
+
name: contact.your_name,
|
|
1353
|
+
email: contact.your_email,
|
|
1354
|
+
subject: contact.your_subject,
|
|
1355
|
+
message: contact.your_message,
|
|
1356
|
+
metadata: {
|
|
1357
|
+
migrated: true,
|
|
1358
|
+
originalId: contact.id,
|
|
1359
|
+
originalDate: contact.date
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
try {
|
|
1364
|
+
await client.contact.submitContact('my-site', submission);
|
|
1365
|
+
console.log(`Migrated contact ${contact.id}`);
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
console.error(`Failed to migrate ${contact.id}:`, error);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Rate limit prevention
|
|
1371
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
```
|
|
1375
|
+
|
|
1376
|
+
## Security Best Practices
|
|
1377
|
+
|
|
1378
|
+
1. **Always use HTTPS** for API calls
|
|
1379
|
+
2. **Implement Turnstile** on public forms
|
|
1380
|
+
3. **Validate input** on both client and server
|
|
1381
|
+
4. **Use honeypot fields** for additional bot protection
|
|
1382
|
+
5. **Monitor rate limits** and adjust as needed
|
|
1383
|
+
6. **Sanitize output** when displaying submissions
|
|
1384
|
+
7. **Implement access controls** for admin functions
|
|
1385
|
+
8. **Log all submissions** for audit purposes
|
|
1386
|
+
9. **Regular backups** of contact data
|
|
1387
|
+
10. **GDPR compliance** - provide data export/deletion
|
|
1388
|
+
|
|
1389
|
+
## Compliance
|
|
1390
|
+
|
|
1391
|
+
The contact system helps with compliance:
|
|
1392
|
+
|
|
1393
|
+
- **GDPR**: Data export, deletion capabilities
|
|
1394
|
+
- **CAN-SPAM**: Clear identification, no deceptive practices
|
|
1395
|
+
- **Privacy**: IP addresses stored for security, deletable on request
|
|
1396
|
+
- **Accessibility**: Form fields properly labeled for screen readers
|