kyd-shared-badge 0.3.117 → 0.3.118
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/package.json +1 -1
- package/src/SharedBadgeDisplay.tsx +1 -1
- package/src/components/CategoryBars.tsx +9 -0
- package/src/components/RoleOverviewCard.tsx +23 -2
- package/src/components/support/SupportForm.tsx +160 -0
- package/src/index.ts +3 -1
- package/src/lib/support/routes.ts +162 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/textarea.tsx +23 -0
package/package.json
CHANGED
|
@@ -387,7 +387,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless, selfCheck = false,
|
|
|
387
387
|
</div>
|
|
388
388
|
)}
|
|
389
389
|
<div>
|
|
390
|
-
<Reveal headless={isHeadless} as={'h4'} offsetY={8} className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>
|
|
390
|
+
<Reveal headless={isHeadless} as={'h4'} offsetY={8} className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Score Improvement Recommendationsw</Reveal>
|
|
391
391
|
<Reveal headless={isHeadless}>
|
|
392
392
|
<div className={'space-y-3'}>
|
|
393
393
|
{rec?.summary ? (
|
|
@@ -53,6 +53,14 @@ const CategoryBars: React.FC<CategoryBarsProps> = ({
|
|
|
53
53
|
0
|
|
54
54
|
)
|
|
55
55
|
);
|
|
56
|
+
const candidatePercentRaw = Math.round(
|
|
57
|
+
Number(
|
|
58
|
+
score?.business?.percent_progress ??
|
|
59
|
+
score?.combined?.percent_progress ??
|
|
60
|
+
0
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
const candidateClamp = Math.max(0, Math.min(100, candidatePercentRaw));
|
|
56
64
|
const absPercent = Math.max(0, Math.min(Math.abs(signed), 100));
|
|
57
65
|
const isNegative = signed < 0;
|
|
58
66
|
const label =
|
|
@@ -131,6 +139,7 @@ const CategoryBars: React.FC<CategoryBarsProps> = ({
|
|
|
131
139
|
{hasTarget && (
|
|
132
140
|
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
|
|
133
141
|
Target: {targetClamp}%
|
|
142
|
+
<div>Candidate Match: {candidateClamp}%</div>
|
|
134
143
|
</div>
|
|
135
144
|
)}
|
|
136
145
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useMemo } from 'react';
|
|
4
|
-
|
|
4
|
+
// Link not required for hash-only navigation
|
|
5
5
|
import { FiInfo } from 'react-icons/fi';
|
|
6
6
|
import GaugeComponent from '@knowyourdeveloper/react-gauge-component';
|
|
7
7
|
import { clampPercent, hexToRgba, scoreToColorHex, red, yellow, green } from '../colors';
|
|
@@ -101,10 +101,31 @@ export default function RoleOverviewCard({
|
|
|
101
101
|
}}
|
|
102
102
|
pointer={{ type: 'arrow', elastic: true, animationDelay: 0 }}
|
|
103
103
|
/>
|
|
104
|
+
<div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
|
|
105
|
+
<div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
|
|
106
|
+
<div style={{ fontWeight: 600 }}>{title}</div>
|
|
107
|
+
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Alignment reflects how well the candidate matches the target role based on KYD evidence.</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
104
110
|
</div>
|
|
105
111
|
{roleName ? (
|
|
106
112
|
<div className="mt-3 text-center">
|
|
107
|
-
<
|
|
113
|
+
<a
|
|
114
|
+
href="#role"
|
|
115
|
+
className={'text-sm font-semibold text-[var(--text-main)] hover:underline'}
|
|
116
|
+
onClick={(e) => {
|
|
117
|
+
try {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
if (typeof window !== 'undefined') {
|
|
120
|
+
window.location.hash = 'role';
|
|
121
|
+
// Ensure listeners respond even if framework doesn't emit hashchange automatically
|
|
122
|
+
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
123
|
+
}
|
|
124
|
+
} catch {}
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{roleName}
|
|
128
|
+
</a>
|
|
108
129
|
</div>
|
|
109
130
|
) : null}
|
|
110
131
|
</div>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react"
|
|
4
|
+
import toast from "react-hot-toast"
|
|
5
|
+
import { Button } from "../../ui/button"
|
|
6
|
+
import { Input } from "../../ui/input"
|
|
7
|
+
import { Textarea } from "../../ui/textarea"
|
|
8
|
+
|
|
9
|
+
type SubjectOption = 'Question - General' | 'Question - Billing' | 'Feature Recommendation' | 'Other'
|
|
10
|
+
|
|
11
|
+
export function SupportForm({
|
|
12
|
+
session,
|
|
13
|
+
productLabel = 'KYD Self-check',
|
|
14
|
+
}: {
|
|
15
|
+
session?: { user?: { fullName?: string | null; name?: string | null; email?: string | null } | null } | null
|
|
16
|
+
productLabel?: string
|
|
17
|
+
}) {
|
|
18
|
+
const [subject, setSubject] = useState<SubjectOption>('Question - General')
|
|
19
|
+
const [description, setDescription] = useState('')
|
|
20
|
+
const [file, setFile] = useState<File | null>(null)
|
|
21
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
22
|
+
const [isSent, setIsSent] = useState(false)
|
|
23
|
+
|
|
24
|
+
const nameVal = useMemo(() => (session?.user?.fullName || session?.user?.name || ''), [session])
|
|
25
|
+
const emailVal = useMemo(() => (session?.user?.email || ''), [session])
|
|
26
|
+
const productVal = productLabel
|
|
27
|
+
|
|
28
|
+
const onFileChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
29
|
+
const f = e.target.files?.[0] || null
|
|
30
|
+
if (!f) { setFile(null); return }
|
|
31
|
+
const allowed = new Set(['image/png', 'image/jpeg', 'image/webp'])
|
|
32
|
+
if (!allowed.has(f.type)) { toast.error('Only PNG, JPG, or WEBP allowed'); return }
|
|
33
|
+
if (f.size > 5 * 1024 * 1024) { toast.error('Max file size is 5MB'); return }
|
|
34
|
+
setFile(f)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
if (!session) { toast.error('Please sign in to submit.'); return }
|
|
40
|
+
if (!description.trim()) { toast.error('Please add a description.'); return }
|
|
41
|
+
|
|
42
|
+
setIsSubmitting(true)
|
|
43
|
+
try {
|
|
44
|
+
let screenshotKey: string | undefined
|
|
45
|
+
if (file) {
|
|
46
|
+
const mime = file.type
|
|
47
|
+
const body = JSON.stringify({ filename: file.name, mime })
|
|
48
|
+
const res = await fetch('/api/support/presign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
|
|
49
|
+
if (!res.ok) throw new Error('Failed to prepare upload')
|
|
50
|
+
const { uploadUrl, key } = await res.json()
|
|
51
|
+
const up = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': mime }, body: file })
|
|
52
|
+
if (!up.ok) throw new Error('Upload failed')
|
|
53
|
+
screenshotKey = key
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const resp = await fetch('/api/support', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ subject, description, screenshotKey })
|
|
60
|
+
})
|
|
61
|
+
if (!resp.ok) {
|
|
62
|
+
const t = await resp.json().catch(() => ({}))
|
|
63
|
+
throw new Error(t?.error || 'Failed to submit support request')
|
|
64
|
+
}
|
|
65
|
+
toast.success('Sent to support. Thank you!')
|
|
66
|
+
setDescription('')
|
|
67
|
+
setFile(null)
|
|
68
|
+
setSubject('Question - General')
|
|
69
|
+
setIsSent(true)
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const e2 = err as Error
|
|
72
|
+
toast.error(e2.message || 'Something went wrong')
|
|
73
|
+
} finally {
|
|
74
|
+
setIsSubmitting(false)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (isSent) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="w-full">
|
|
81
|
+
<div className="rounded-xl border p-6 md:p-10 text-center max-w-2xl mx-auto">
|
|
82
|
+
<h2 className="text-xl md:text-2xl font-semibold mb-2">Thanks! Your message was sent.</h2>
|
|
83
|
+
<p className="text-sm md:text-base mb-6">Our team will review and get back to you at your account email.</p>
|
|
84
|
+
<div className="flex items-center justify-center gap-3">
|
|
85
|
+
<Button onClick={() => setIsSent(false)} className="px-4">Send another</Button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<form onSubmit={handleSubmit} className="w-full">
|
|
94
|
+
<div className="grid md:grid-cols-2 gap-6">
|
|
95
|
+
<div className="rounded-xl border p-4 sm:p-6">
|
|
96
|
+
<h2 className="text-lg font-semibold mb-4">Contact Support</h2>
|
|
97
|
+
|
|
98
|
+
<div className="space-y-4">
|
|
99
|
+
<div>
|
|
100
|
+
<label className="block text-sm mb-1">Name</label>
|
|
101
|
+
<Input value={nameVal} readOnly />
|
|
102
|
+
</div>
|
|
103
|
+
<div>
|
|
104
|
+
<label className="block text-sm mb-1">Email</label>
|
|
105
|
+
<Input value={emailVal} readOnly />
|
|
106
|
+
</div>
|
|
107
|
+
<div>
|
|
108
|
+
<label className="block text-sm mb-1">Product</label>
|
|
109
|
+
<Input value={productVal} readOnly />
|
|
110
|
+
</div>
|
|
111
|
+
<div>
|
|
112
|
+
<label className="block text-sm mb-1">Subject</label>
|
|
113
|
+
<select
|
|
114
|
+
value={subject}
|
|
115
|
+
onChange={(e) => setSubject(e.target.value as SubjectOption)}
|
|
116
|
+
className="w-full rounded-md border bg-transparent p-2"
|
|
117
|
+
>
|
|
118
|
+
<option>Question - General</option>
|
|
119
|
+
<option>Question - Billing</option>
|
|
120
|
+
<option>Feature Recommendation</option>
|
|
121
|
+
<option>Other</option>
|
|
122
|
+
</select>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="rounded-xl border p-4 sm:p-6">
|
|
128
|
+
<h2 className="text-lg font-semibold mb-4">Description</h2>
|
|
129
|
+
<div className="space-y-4">
|
|
130
|
+
<div>
|
|
131
|
+
<Textarea
|
|
132
|
+
value={description}
|
|
133
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
134
|
+
placeholder="Tell us what's going on..."
|
|
135
|
+
className="min-h-[160px]"
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
<div>
|
|
139
|
+
<label className="block text-sm mb-1">Optional screenshot (PNG/JPG/WEBP, ≤5MB)</label>
|
|
140
|
+
<Input type="file" accept="image/png,image/jpeg,image/webp" onChange={onFileChange} />
|
|
141
|
+
{file ? (
|
|
142
|
+
<p className="text-xs mt-2">{file.name} ({Math.round(file.size/1024)} KB)</p>
|
|
143
|
+
) : null}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="mt-6">
|
|
148
|
+
<Button type="submit" disabled={isSubmitting} className="w-full justify-center">
|
|
149
|
+
{isSubmitting ? 'Sending…' : 'Send to Support'}
|
|
150
|
+
</Button>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</form>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default SupportForm
|
|
159
|
+
|
|
160
|
+
|
package/src/index.ts
CHANGED
|
@@ -11,4 +11,6 @@ export * from './components/charts/ChartEmptyState';
|
|
|
11
11
|
export * from './components/charts/ChartLegend';
|
|
12
12
|
export { default as ConnectAccounts } from './connect/ConnectAccounts';
|
|
13
13
|
export * from './connect/types';
|
|
14
|
-
export { normalizeLinkedInInput } from './connect/linkedin';
|
|
14
|
+
export { normalizeLinkedInInput } from './connect/linkedin';
|
|
15
|
+
export { default as SupportForm } from './components/support/SupportForm';
|
|
16
|
+
export { createSupportPresignRoute, createSupportTicketRoute } from './lib/support/routes';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server'
|
|
2
|
+
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
|
|
3
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
|
|
4
|
+
import { DynamoDBDocumentClient, GetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'
|
|
5
|
+
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'
|
|
6
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
7
|
+
|
|
8
|
+
const s3 = new S3Client({})
|
|
9
|
+
const dbClient = new DynamoDBClient({})
|
|
10
|
+
const doc = DynamoDBDocumentClient.from(dbClient)
|
|
11
|
+
const ses = new SESClient({})
|
|
12
|
+
|
|
13
|
+
function getIp(req: NextRequest) {
|
|
14
|
+
const xf = req.headers.get('x-forwarded-for') || ''
|
|
15
|
+
const ip = xf.split(',')[0].trim() || req.headers.get('x-real-ip') || ''
|
|
16
|
+
return String(ip)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getUa(req: NextRequest) {
|
|
20
|
+
return req.headers.get('user-agent') || ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getLocationHeaders(req: NextRequest) {
|
|
24
|
+
const country = req.headers.get('x-vercel-ip-country') || ''
|
|
25
|
+
const city = req.headers.get('x-vercel-ip-city') || ''
|
|
26
|
+
return { country, city }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function escapeHtml(str: string) {
|
|
30
|
+
return String(str)
|
|
31
|
+
.replace(/&/g, '&')
|
|
32
|
+
.replace(/</g, '<')
|
|
33
|
+
.replace(/>/g, '>')
|
|
34
|
+
.replace(/"/g, '"')
|
|
35
|
+
.replace(/'/g, ''')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createSupportPresignRoute(req: NextRequest, userId: string) {
|
|
39
|
+
try {
|
|
40
|
+
const { filename, mime } = await req.json()
|
|
41
|
+
const bucket = process.env.SUPPORT_SCREENSHOTS_BUCKET as string
|
|
42
|
+
if (!bucket) return Response.json({ error: 'Bucket not configured' }, { status: 500 })
|
|
43
|
+
|
|
44
|
+
const safeName = String(filename || 'upload').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
45
|
+
const ext = safeName.includes('.') ? safeName.split('.').pop() : 'png'
|
|
46
|
+
const key = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
|
|
47
|
+
|
|
48
|
+
const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: String(mime || 'image/png') })
|
|
49
|
+
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 60 * 5 })
|
|
50
|
+
return Response.json({ uploadUrl, key })
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error('support presign error', e)
|
|
53
|
+
return Response.json({ error: 'Failed to create upload URL' }, { status: 500 })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function createSupportTicketRoute(req: NextRequest, userId: string) {
|
|
58
|
+
try {
|
|
59
|
+
const body = await req.json().catch(() => ({}))
|
|
60
|
+
const subject: string = String(body?.subject || '')
|
|
61
|
+
const description: string = String(body?.description || '')
|
|
62
|
+
const screenshotKey: string | undefined = body?.screenshotKey
|
|
63
|
+
if (!subject || !description) return Response.json({ error: 'Missing fields' }, { status: 400 })
|
|
64
|
+
|
|
65
|
+
const userTable = process.env.USER_TABLE_NAME as string
|
|
66
|
+
const badgeTable = process.env.BADGE_TABLE_NAME as string
|
|
67
|
+
const supportBucket = process.env.SUPPORT_SCREENSHOTS_BUCKET as string
|
|
68
|
+
const sesFrom = process.env.SES_FROM_EMAIL as string
|
|
69
|
+
if (!userTable || !badgeTable || !sesFrom) return Response.json({ error: 'Server not configured' }, { status: 500 })
|
|
70
|
+
|
|
71
|
+
// Get user to find stripeCustomerId + totals
|
|
72
|
+
const userResp = await doc.send(new GetCommand({ TableName: userTable, Key: { userId } }))
|
|
73
|
+
const userItem = (userResp as any).Item || {}
|
|
74
|
+
const stripeCustomerId: string | undefined = userItem.stripeCustomerId
|
|
75
|
+
|
|
76
|
+
// Stripe total spent
|
|
77
|
+
let totalSpentUsd = 0
|
|
78
|
+
try {
|
|
79
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY
|
|
80
|
+
if (stripeKey && stripeCustomerId) {
|
|
81
|
+
const Stripe = (await import('stripe')).default
|
|
82
|
+
const stripe = new Stripe(stripeKey)
|
|
83
|
+
let hasMore = true
|
|
84
|
+
let startingAfter: string | undefined
|
|
85
|
+
while (hasMore) {
|
|
86
|
+
const inv = await stripe.invoices.list({ customer: stripeCustomerId, status: 'paid', limit: 100, starting_after: startingAfter })
|
|
87
|
+
for (const i of inv.data) totalSpentUsd += (i.total || 0)
|
|
88
|
+
hasMore = inv.has_more
|
|
89
|
+
startingAfter = inv.data.at(-1)?.id
|
|
90
|
+
}
|
|
91
|
+
totalSpentUsd = Math.round(totalSpentUsd) / 100
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
|
|
95
|
+
// Total reports run by user
|
|
96
|
+
let totalReports = 0
|
|
97
|
+
try {
|
|
98
|
+
const countResp = await doc.send(new QueryCommand({
|
|
99
|
+
TableName: badgeTable,
|
|
100
|
+
IndexName: 'userId-index',
|
|
101
|
+
KeyConditionExpression: 'userId = :u',
|
|
102
|
+
ExpressionAttributeValues: { ':u': userId },
|
|
103
|
+
Select: 'COUNT',
|
|
104
|
+
}))
|
|
105
|
+
totalReports = (countResp as any).Count || 0
|
|
106
|
+
} catch {}
|
|
107
|
+
|
|
108
|
+
const ip = getIp(req)
|
|
109
|
+
const ua = getUa(req)
|
|
110
|
+
const { country, city } = getLocationHeaders(req)
|
|
111
|
+
|
|
112
|
+
let screenshotUrl: string | undefined
|
|
113
|
+
if (screenshotKey && supportBucket) {
|
|
114
|
+
const getCmd = new GetObjectCommand({ Bucket: supportBucket, Key: screenshotKey })
|
|
115
|
+
screenshotUrl = await getSignedUrl(s3, getCmd, { expiresIn: 60 * 30 })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const product = 'KYD Self-check'
|
|
119
|
+
const name = userItem?.fullName || userItem?.email || ''
|
|
120
|
+
const email = userItem?.email || ''
|
|
121
|
+
|
|
122
|
+
const html = `
|
|
123
|
+
<div style="font-family:Arial,sans-serif;line-height:1.5">
|
|
124
|
+
<h2>New Support Request</h2>
|
|
125
|
+
<h3>User</h3>
|
|
126
|
+
<ul>
|
|
127
|
+
<li><b>Name:</b> ${escapeHtml(name)}</li>
|
|
128
|
+
<li><b>Email:</b> ${escapeHtml(email)}</li>
|
|
129
|
+
<li><b>Product:</b> ${escapeHtml(product)}</li>
|
|
130
|
+
</ul>
|
|
131
|
+
<h3>Details</h3>
|
|
132
|
+
<ul>
|
|
133
|
+
<li><b>Subject:</b> ${escapeHtml(subject)}</li>
|
|
134
|
+
<li><b>Description:</b><br/>${escapeHtml(description).replace(/\n/g,'<br/>')}</li>
|
|
135
|
+
</ul>
|
|
136
|
+
<h3>Meta</h3>
|
|
137
|
+
<ul>
|
|
138
|
+
<li><b>Total spent (USD):</b> $${totalSpentUsd.toFixed(2)}</li>
|
|
139
|
+
<li><b>Total reports run:</b> ${totalReports}</li>
|
|
140
|
+
<li><b>Location:</b> ${escapeHtml([city, country].filter(Boolean).join(', ') || 'Unknown')}</li>
|
|
141
|
+
<li><b>Browser:</b> ${escapeHtml(ua)}</li>
|
|
142
|
+
<li><b>IP:</b> ${escapeHtml(ip)}</li>
|
|
143
|
+
</ul>
|
|
144
|
+
${screenshotUrl ? `<p><a href="${screenshotUrl}">View Screenshot</a></p>` : ''}
|
|
145
|
+
</div>
|
|
146
|
+
`
|
|
147
|
+
|
|
148
|
+
const send = new SendEmailCommand({
|
|
149
|
+
Source: sesFrom,
|
|
150
|
+
Destination: { ToAddresses: ['team@knowyourdeveloper.ai'] },
|
|
151
|
+
Message: { Subject: { Data: `Support: ${subject}` }, Body: { Html: { Data: html } } },
|
|
152
|
+
})
|
|
153
|
+
await ses.send(send)
|
|
154
|
+
|
|
155
|
+
return Response.json({ ok: true })
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error('support submit error', e)
|
|
158
|
+
return Response.json({ error: 'Failed to send support request' }, { status: 500 })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
package/src/ui/index.ts
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../utils"
|
|
4
|
+
|
|
5
|
+
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
|
6
|
+
({ className, ...props }, ref) => {
|
|
7
|
+
return (
|
|
8
|
+
<textarea
|
|
9
|
+
className={cn(
|
|
10
|
+
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-xs",
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
ref={ref}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
Textarea.displayName = "Textarea"
|
|
20
|
+
|
|
21
|
+
export { Textarea }
|
|
22
|
+
|
|
23
|
+
|