kyd-shared-badge 0.3.117 → 0.3.119

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyd-shared-badge",
3
- "version": "0.3.117",
3
+ "version": "0.3.119",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -18,7 +18,9 @@
18
18
  "dependencies": {
19
19
  "@ai-sdk/openai": "^2.0.42",
20
20
  "@aws-sdk/client-s3": "^3.893.0",
21
+ "@aws-sdk/client-ses": "^3.916.0",
21
22
  "@aws-sdk/lib-dynamodb": "^3.893.0",
23
+ "@aws-sdk/s3-request-presigner": "^3.916.0",
22
24
  "@chatscope/chat-ui-kit-react": "^2.1.1",
23
25
  "@chatscope/chat-ui-kit-styles": "^1.4.0",
24
26
  "@knowyourdeveloper/react-bubble-chart": "^1.0.8",
@@ -34,6 +36,7 @@
34
36
  "react-hot-toast": "^2.6.0",
35
37
  "react-icons": "^5.5.0",
36
38
  "recharts": "^2.15.4",
39
+ "stripe": "^19.1.0",
37
40
  "tailwind-merge": "^3.3.1",
38
41
  "ulid": "^3.0.1"
39
42
  },
@@ -15,6 +15,7 @@ import { FaGithub, FaGitlab, FaStackOverflow, FaLinkedin, FaGoogle, FaKaggle } f
15
15
  import { SiCredly, SiFiverr } from 'react-icons/si';
16
16
  import GaugeCard from './components/GaugeCard';
17
17
  import RiskCard from './components/RiskCard';
18
+ import RoleOverviewCard from './components/RoleOverviewCard';
18
19
 
19
20
  import { yellow, green } from './colors';
20
21
  import SkillsValidation from './components/SkillsValidation';
@@ -87,6 +88,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
87
88
  return undefined;
88
89
  }
89
90
  })();
91
+ const hasEnterpriseMatch = !!assessmentResult?.enterprise_match;
90
92
 
91
93
  const topBusinessForGenre = (genre: string) => {
92
94
  const cats: string[] = (genreMapping)?.[genre] || [];
@@ -120,28 +122,86 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
120
122
  }`}
121
123
  </style>
122
124
  )}
123
- {/* Share controls removed; app-level pages render their own actions */}
124
- <Reveal headless={isHeadless} offsetY={8} durationMs={500}>
125
- <ReportHeader
126
- badgeId={badgeId}
127
- developerName={badgeData.developerName}
128
- updatedAt={updatedAt}
129
- score={overallFinalPercent || 0}
130
- isPublic={true}
131
- badgeImageUrl={badgeData.badgeImageUrl || ''}
132
- summary={assessmentResult?.report_summary || ''}
133
- enterpriseMatch={(() => {
134
- const em = assessmentResult?.enterprise_match;
135
- if (!em) return null;
136
- const role = em.role || {};
137
- return { label: em.label, description: em.description, roleName: role.name };
138
- })()}
139
- countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
140
- accountAuthenticity={assessmentResult?.account_authenticity}
141
- companyName={badgeData.companyName}
142
- rightBadgeLayout={!assessmentResult?.enterprise_match}
143
- />
144
- </Reveal>
125
+ {/* Overview quadrant layout (mirrors SharedBadgeDisplay overview) */}
126
+ <div className={'mt-6'}>
127
+ <Reveal headless={isHeadless} offsetY={8} durationMs={500}>
128
+ <div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
129
+ <div className={`${hasEnterpriseMatch ? '' : 'md:col-span-2'}`}>
130
+ <ReportHeader
131
+ badgeId={badgeId}
132
+ developerName={badgeData.developerName}
133
+ updatedAt={updatedAt}
134
+ score={overallFinalPercent || 0}
135
+ isPublic={true}
136
+ badgeImageUrl={badgeData.badgeImageUrl || ''}
137
+ summary={undefined}
138
+ enterpriseMatch={null}
139
+ countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
140
+ accountAuthenticity={assessmentResult?.account_authenticity}
141
+ companyName={badgeData.companyName}
142
+ sourcesProviders={(badgeData?.connectedAccounts || []).map(a => (a?.name || '').toLowerCase())}
143
+ rightBadgeLayout={!hasEnterpriseMatch}
144
+ />
145
+ </div>
146
+ {hasEnterpriseMatch && (
147
+ <div>
148
+ {(() => {
149
+ const em = assessmentResult?.enterprise_match;
150
+ if (!em) return null;
151
+ const role = em.role || {};
152
+ return (
153
+ <RoleOverviewCard
154
+ title={'Role Alignment'}
155
+ matchLabel={em.label}
156
+ roleName={role?.name || 'Role'}
157
+ />
158
+ );
159
+ })()}
160
+ </div>
161
+ )}
162
+ <div>
163
+ {(() => {
164
+ const uiTech = graphInsights?.uiSummary?.technical || {};
165
+ const techPct = Math.round(Number(uiTech?.percent ?? 0));
166
+ const techLabel = uiTech?.label || 'EVIDENCE';
167
+ return (
168
+ <GaugeCard
169
+ title={'KYD Technical'}
170
+ description={'Composite of technical evidence; more right indicates stronger capability'}
171
+ percent={techPct}
172
+ label={techLabel}
173
+ topMovers={[]}
174
+ />
175
+ );
176
+ })()}
177
+ </div>
178
+ <div>
179
+ {(() => {
180
+ const uiRisk = graphInsights?.uiSummary?.risk || {};
181
+ const riskPctGood = Math.round(Number(uiRisk?.percent_good ?? 0));
182
+ const riskLabel = uiRisk?.label || 'RISK';
183
+ return (
184
+ <RiskCard
185
+ title={'KYD Risk'}
186
+ description={'Lower bar height indicates lower risk exposure'}
187
+ percentGood={riskPctGood}
188
+ label={riskLabel}
189
+ topMovers={[]}
190
+ />
191
+ );
192
+ })()}
193
+ </div>
194
+ </div>
195
+ </Reveal>
196
+ <div className={'rounded-xl shadow-xl p-6 sm:p-8 mt-6 border'} style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
197
+ <Reveal headless={isHeadless}>
198
+ <div className={'kyd-avoid-break'}>
199
+ <h4 className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Skills Footprint</h4>
200
+ <SkillsBubble skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} skillsByCategory={assessmentResult?.graph_insights?.skillsByCategory} skillsMeta={assessmentResult?.graph_insights?.skillsMeta} />
201
+ </div>
202
+ </Reveal>
203
+ </div>
204
+ </div>
145
205
  {/* Coaching / Evidence under header when present */}
146
206
  {(() => {
147
207
  const em = assessmentResult.enterprise_match;
@@ -158,12 +218,6 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
158
218
  style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}
159
219
  >
160
220
  <div className={'space-y-12 divide-y'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
161
- <div className="pt-8 first:pt-0 kyd-avoid-break">
162
- <Reveal headless={isHeadless} as={'h4'} offsetY={8} durationMs={500} className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Report Summary</Reveal>
163
- <Reveal headless={isHeadless} as={'div'} offsetY={8} durationMs={500} className={'space-y-12 divide-y'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
164
- <SummaryCards graphInsights={graphInsights} assessmentResult={assessmentResult} topBusinessForGenre={topBusinessForGenre} />
165
- </Reveal>
166
- </div>
167
221
 
168
222
  {/* Enterprise Use Cases (directly under the three summary cards) */}
169
223
  {assessmentResult?.enterprise_use_cases?.items && assessmentResult.enterprise_use_cases.items.length > 0 && (
@@ -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)' }}>Recommendations</Reveal>
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
- import Link from 'next/link';
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
- <Link href="#role" className={'text-sm font-semibold text-[var(--text-main)] hover:underline'}>{roleName}</Link>
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
+
@@ -5,7 +5,7 @@ import { normalizeLinkedInInput } from './linkedin';
5
5
  import type { ConnectAccountsProps } from './types';
6
6
  import { CheckCircle, Link2, LinkIcon, Unlink, ArrowLeft, ExternalLink, Settings, Shield, InfoIcon } from 'lucide-react';
7
7
  import { AnimatePresence, motion } from 'framer-motion';
8
- import { Button, Input, Spinner, Card, CardHeader, CardContent, CardFooter, CardTitle } from '../ui';
8
+ import { Button, Input, Spinner, Card, CardHeader, CardContent, CardFooter, CardTitle, ProgressCircle } from '../ui';
9
9
  import Link from 'next/link';
10
10
  import { Tooltip, TooltipTrigger, TooltipProvider, TooltipContent } from '../ui/';
11
11
 
@@ -222,8 +222,33 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
222
222
  return setupAction === 'install' || setupAction === 'created';
223
223
  }, [githubConnectedAccount]);
224
224
 
225
+ // Progress computation
226
+ const targetProviderIds = useMemo(() => {
227
+ const targets = (requiredProviders && requiredProviders.length
228
+ ? requiredProviders
229
+ : providers.map(p => p.id))
230
+ .map(id => id.toLowerCase());
231
+ return new Set(targets);
232
+ }, [requiredProviders, providers]);
233
+ const numConnectedTargets = useMemo(() => {
234
+ let count = 0;
235
+ targetProviderIds.forEach(id => { if (connectedIds.has(id)) count += 1; });
236
+ return count;
237
+ }, [targetProviderIds, connectedIds]);
238
+ const progressPercent = useMemo(() => {
239
+ const total = targetProviderIds.size;
240
+ if (!total) return 0;
241
+ return Math.round((numConnectedTargets / total) * 100);
242
+ }, [numConnectedTargets, targetProviderIds]);
243
+
225
244
  return (
226
- <AnimatePresence initial={false} mode="wait">
245
+ <>
246
+ {providers?.length ? (
247
+ <div className="fixed sm:bottom-6 sm:right-6 bottom-4 right-4 z-50 pointer-events-none">
248
+ <ProgressCircle value={progressPercent} size={68} thickness={6} />
249
+ </div>
250
+ ) : null}
251
+ <AnimatePresence initial={false} mode="wait">
227
252
  {showDataHandling ? (
228
253
  <motion.div
229
254
  key="data-handling-card"
@@ -695,7 +720,8 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
695
720
  </AnimatePresence>
696
721
  </Card>
697
722
  )}
698
- </AnimatePresence>
723
+ </AnimatePresence>
724
+ </>
699
725
  );
700
726
  }
701
727
 
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, '&amp;')
32
+ .replace(/</g, '&lt;')
33
+ .replace(/>/g, '&gt;')
34
+ .replace(/"/g, '&quot;')
35
+ .replace(/'/g, '&#039;')
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[inv.data.length - 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?.name || 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
@@ -1,5 +1,7 @@
1
1
  export * from './button';
2
2
  export * from './card';
3
3
  export * from './input';
4
+ export * from './textarea';
4
5
  export * from './spinner';
5
- export * from './tooltip';
6
+ export * from './tooltip';
7
+ export * from './progress-circle';
@@ -0,0 +1,78 @@
1
+ 'use client'
2
+ import React from 'react'
3
+
4
+ export type ProgressCircleProps = {
5
+ value: number
6
+ size?: number
7
+ thickness?: number
8
+ className?: string
9
+ showLabel?: boolean
10
+ label?: string
11
+ }
12
+
13
+ export function ProgressCircle(props: ProgressCircleProps) {
14
+ const { value, size = 72, thickness = 6, className, showLabel = true, label } = props
15
+
16
+ const clamped = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0))
17
+ const radius = (size - thickness) / 2
18
+ const circumference = 2 * Math.PI * radius
19
+ const dashOffset = circumference * (1 - clamped / 100)
20
+
21
+ return (
22
+ <div className={`relative inline-flex items-center justify-center ${className || ''}`} style={{ width: size, height: size }}>
23
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
24
+ <defs>
25
+ <linearGradient id="kyd-progress-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
26
+ <stop offset="0%" stopColor="var(--icon-accent)" />
27
+ <stop offset="100%" stopColor="var(--icon-accent-hover)" />
28
+ </linearGradient>
29
+ </defs>
30
+ <circle
31
+ cx={size / 2}
32
+ cy={size / 2}
33
+ r={radius}
34
+ fill="none"
35
+ stroke="var(--icon-button-secondary)"
36
+ strokeOpacity={0.3}
37
+ strokeWidth={thickness}
38
+ />
39
+ <circle
40
+ cx={size / 2}
41
+ cy={size / 2}
42
+ r={radius}
43
+ fill="none"
44
+ stroke="url(#kyd-progress-gradient)"
45
+ strokeWidth={thickness}
46
+ strokeLinecap="round"
47
+ strokeDasharray={circumference}
48
+ strokeDashoffset={dashOffset}
49
+ style={{ transition: 'stroke-dashoffset 800ms ease' }}
50
+ transform={`rotate(-90 ${size / 2} ${size / 2})`}
51
+ />
52
+ {/* subtle pulsing halo */}
53
+ <circle
54
+ cx={size / 2}
55
+ cy={size / 2}
56
+ r={radius + thickness / 2}
57
+ fill="none"
58
+ stroke="var(--icon-accent)"
59
+ strokeOpacity={0.15}
60
+ strokeWidth={2}
61
+ className="animate-pulse"
62
+ />
63
+ </svg>
64
+ {showLabel ? (
65
+ <div
66
+ className="absolute inset-0 flex items-center justify-center text-[10px] sm:text-xs font-medium"
67
+ style={{ color: 'var(--text-main)' }}
68
+ >
69
+ {label ? label : `${Math.round(clamped)}%`}
70
+ </div>
71
+ ) : null}
72
+ </div>
73
+ )
74
+ }
75
+
76
+ export default ProgressCircle
77
+
78
+
@@ -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
+