kyd-shared-badge 0.3.116 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyd-shared-badge",
3
- "version": "0.3.116",
3
+ "version": "0.3.118",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -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
+
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.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
@@ -1,5 +1,6 @@
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
6
  export * from './tooltip';
@@ -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
+