Skip to content

Commit 2be3f29

Browse files
authored
Merge pull request #643 from webstackdev/feature/style-fixes-contact-form-and-behavior
Feature/style fixes contact form and behavior
2 parents d4a8d20 + 49596fc commit 2be3f29

14 files changed

Lines changed: 311 additions & 106 deletions

File tree

_TODO.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,6 @@ https://aws.plainenglish.io/how-to-build-a-chatbot-using-aws-lex-and-lambda-in-2
2727

2828
- Finish styling
2929

30-
## Env Vars
30+
## Contact Form
3131

32-
- Add HubSpot env vars to Vercel
33-
34-
## Content Issues
35-
36-
- Need an article on OpenStack
32+
- `0/2000` characters should show number of characters left instead

src/actions/contact/@types/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export type ContactFormData = {
22
name: string
33
email: string
4+
company?: string
45
phone?: string
56
message: string
67
consent?: boolean

src/actions/contact/__tests__/responder.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('contact responder', () => {
1414
{
1515
name: 'Jane Doe',
1616
email: 'jane@example.com',
17+
company: 'Acme Co',
1718
service: 'Website redesign',
1819
timeline: '2-3-months',
1920
budget: '$5k-$10k',
@@ -42,6 +43,7 @@ describe('contact responder', () => {
4243

4344
// Key fields should appear somewhere in the rendered content.
4445
expect(document.body.textContent ?? '').toContain('jane@example.com')
46+
expect(document.body.textContent ?? '').toContain('Acme Co')
4547
expect(document.body.textContent ?? '').toContain('Website redesign')
4648
expect(document.body.textContent ?? '').toContain('$5k-$10k')
4749
})
@@ -77,6 +79,7 @@ describe('contact responder', () => {
7779
name: ' Jane ',
7880
email: 'jane@example.com',
7981
message: ' Hello ',
82+
company: 'Acme Co',
8083
service: 'Web development',
8184
timeline: '2-3-months',
8285
budget: '$5k-$10k',
@@ -90,6 +93,7 @@ describe('contact responder', () => {
9093
expect(formData.name).toBe(' Jane ')
9194
expect(formData.email).toBe('jane@example.com')
9295
expect(formData.message).toBe(' Hello ')
96+
expect(formData.company).toBe('Acme Co')
9397
expect(formData.service).toBe('Web development')
9498
expect(formData.timeline).toBe('2-3-months')
9599
expect(formData.budget).toBe('$5k-$10k')
@@ -124,6 +128,18 @@ describe('contact responder', () => {
124128
expect(firstAttachment.content).toBeInstanceOf(Buffer)
125129
})
126130

131+
it('accepts browser-recorded audio with codec parameters', async () => {
132+
const file = new File(['audio'], 'recording.webm', { type: 'audio/webm;codecs=opus' })
133+
134+
const attachments = await parseAttachmentsFromInput({
135+
file1: file,
136+
})
137+
138+
expect(attachments).toHaveLength(1)
139+
expect(attachments[0]?.contentType).toBe('audio/webm')
140+
expect(attachments[0]?.filename).toBe('recording.webm')
141+
})
142+
127143
it('rejects disallowed mime types', async () => {
128144
const file = new File(['x'], 'x.exe', { type: 'application/x-msdownload' })
129145

src/actions/contact/action.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise
2626
}
2727

2828
const resend = new Resend(getResendApiKey())
29-
const attachments = files.map(file => ({ filename: file.filename, content: file.content }))
29+
const attachments = files.map(file => ({
30+
filename: file.filename,
31+
content: file.content,
32+
contentType: file.contentType,
33+
}))
3034

3135
try {
3236
const result = await resend.emails.send({

src/actions/contact/responder.ts

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,36 @@ export const isAllowedTimeline = (timeline: string): timeline is ContactTimeline
1616
return (contactTimelineValues as readonly string[]).includes(timeline)
1717
}
1818

19+
const allowedAttachmentTypes = [
20+
'image/jpeg',
21+
'image/png',
22+
'image/gif',
23+
'image/webp',
24+
'application/pdf',
25+
'application/msword',
26+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
27+
'audio/mpeg',
28+
'audio/mp4',
29+
'audio/ogg',
30+
'audio/wav',
31+
'audio/webm',
32+
'video/mp4',
33+
'video/quicktime',
34+
'video/webm',
35+
'application/zip',
36+
'text/plain',
37+
] as const
38+
39+
type AllowedAttachmentType = (typeof allowedAttachmentTypes)[number]
40+
41+
const isAllowedAttachmentType = (mimeType: string): mimeType is AllowedAttachmentType => {
42+
return allowedAttachmentTypes.some(allowedAttachmentType => allowedAttachmentType === mimeType)
43+
}
44+
45+
const normalizeMimeType = (mimeType: string): string => {
46+
return mimeType.split(';')[0]?.trim().toLowerCase() || ''
47+
}
48+
1949
const readInputString = (input: Record<string, unknown>, key: string): string => {
2050
const value = input[key]
2151
return typeof value === 'string' ? value : ''
@@ -34,6 +64,7 @@ export function generateEmailContent(data: ContactFormData, files: FileAttachmen
3464
`<p><strong>Email:</strong> ${escapeHtml(data.email)}</p>`,
3565
]
3666

67+
if (data.company) fields.push(`<p><strong>Company:</strong> ${escapeHtml(data.company)}</p>`)
3768
if (data.phone) fields.push(`<p><strong>Phone:</strong> ${escapeHtml(data.phone)}</p>`)
3869
if (data.service) fields.push(`<p><strong>Service:</strong> ${escapeHtml(data.service)}</p>`)
3970
if (data.budget) fields.push(`<p><strong>Budget:</strong> ${escapeHtml(data.budget)}</p>`)
@@ -81,6 +112,7 @@ export function getFormDataFromInput(input: Record<string, unknown>): ContactFor
81112
consent: readInputBoolean(input, 'consent'),
82113
}
83114

115+
const company = readInputString(input, 'company')
84116
const phone = readInputString(input, 'phone')
85117
const budget = readInputString(input, 'budget')
86118
const timeline = readInputString(input, 'timeline')
@@ -93,6 +125,7 @@ export function getFormDataFromInput(input: Record<string, unknown>): ContactFor
93125

94126
const dataSubjectId = readInputString(input, 'DataSubjectId')
95127

128+
if (company) formData.company = company
96129
if (phone) formData.phone = phone
97130
if (budget) formData.budget = budget
98131
// Assertion OK because it is validated by Zod
@@ -106,21 +139,6 @@ export function getFormDataFromInput(input: Record<string, unknown>): ContactFor
106139

107140
export async function parseAttachments(form: FormData): Promise<FileAttachment[]> {
108141
const files: FileAttachment[] = []
109-
const allowedTypes = [
110-
'image/jpeg',
111-
'image/png',
112-
'image/gif',
113-
'image/webp',
114-
'application/pdf',
115-
'application/msword',
116-
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
117-
'audio/mpeg',
118-
'audio/wav',
119-
'video/mp4',
120-
'video/quicktime',
121-
'application/zip',
122-
'text/plain',
123-
]
124142
const maxFileSize = 10 * 1024 * 1024
125143
const maxFiles = 5
126144

@@ -137,15 +155,17 @@ export async function parseAttachments(form: FormData): Promise<FileAttachment[]
137155
throw new ActionsFunctionError(`File ${value.name} exceeds 10MB limit`, { status: 400 })
138156
}
139157

140-
if (!allowedTypes.includes(value.type)) {
158+
const normalizedType = normalizeMimeType(value.type)
159+
160+
if (!isAllowedAttachmentType(normalizedType)) {
141161
throw new ActionsFunctionError(`File type ${value.type} not allowed`, { status: 400 })
142162
}
143163

144164
const buffer = Buffer.from(await value.arrayBuffer())
145165
files.push({
146166
filename: value.name,
147167
content: buffer,
148-
contentType: value.type,
168+
contentType: normalizedType,
149169
size: value.size,
150170
})
151171
}
@@ -158,21 +178,6 @@ export async function parseAttachmentsFromInput(
158178
input: Record<string, unknown>
159179
): Promise<FileAttachment[]> {
160180
const files: FileAttachment[] = []
161-
const allowedTypes = [
162-
'image/jpeg',
163-
'image/png',
164-
'image/gif',
165-
'image/webp',
166-
'application/pdf',
167-
'application/msword',
168-
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
169-
'audio/mpeg',
170-
'audio/wav',
171-
'video/mp4',
172-
'video/quicktime',
173-
'application/zip',
174-
'text/plain',
175-
]
176181
const maxFileSize = 10 * 1024 * 1024
177182
const maxFiles = 5
178183

@@ -190,15 +195,17 @@ export async function parseAttachmentsFromInput(
190195
throw new ActionsFunctionError(`File ${value.name} exceeds 10MB limit`, { status: 400 })
191196
}
192197

193-
if (!allowedTypes.includes(value.type)) {
198+
const normalizedType = normalizeMimeType(value.type)
199+
200+
if (!isAllowedAttachmentType(normalizedType)) {
194201
throw new ActionsFunctionError(`File type ${value.type} not allowed`, { status: 400 })
195202
}
196203

197204
const buffer = Buffer.from(await value.arrayBuffer())
198205
files.push({
199206
filename: value.name,
200207
content: buffer,
201-
contentType: value.type,
208+
contentType: normalizedType,
202209
size: value.size,
203210
})
204211
}

src/components/Animations/Confetti/client/__tests__/index.spec.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,22 @@ describe('ConfettiAnimationElement', () => {
165165
element.fire()
166166

167167
expect(confettiCreateMock).toHaveBeenCalledTimes(1)
168-
expect(confettiInstanceMock).toHaveBeenCalledTimes(1)
168+
expect(confettiInstanceMock).toHaveBeenCalledTimes(2)
169169

170170
const args = confettiInstanceMock.mock.calls[0]?.[0]
171171
expect(args).toEqual(
172172
expect.objectContaining({
173173
particleCount: expect.any(Number),
174174
spread: expect.any(Number),
175175
startVelocity: expect.any(Number),
176+
shapes: ['star'],
177+
})
178+
)
179+
180+
const accentArgs = confettiInstanceMock.mock.calls[1]?.[0]
181+
expect(accentArgs).toEqual(
182+
expect.objectContaining({
183+
shapes: ['circle'],
176184
})
177185
)
178186
})
@@ -220,7 +228,7 @@ describe('ConfettiAnimationElement', () => {
220228
controllerArgs?.onPlay()
221229

222230
element.fire()
223-
expect(confettiInstanceMock).toHaveBeenCalledTimes(1)
231+
expect(confettiInstanceMock).toHaveBeenCalledTimes(2)
224232
})
225233
})
226234

@@ -234,8 +242,9 @@ describe('ConfettiAnimationElement', () => {
234242
})
235243
)
236244

237-
expect(confettiInstanceMock).toHaveBeenCalledTimes(1)
238-
expect(confettiInstanceMock).toHaveBeenCalledWith(
245+
expect(confettiInstanceMock).toHaveBeenCalledTimes(2)
246+
expect(confettiInstanceMock).toHaveBeenNthCalledWith(
247+
1,
239248
expect.objectContaining({ particleCount: 12 })
240249
)
241250
})
@@ -272,7 +281,7 @@ describe('ConfettiAnimationElement', () => {
272281
})
273282
)
274283

275-
expect(confettiInstanceMock).toHaveBeenCalledTimes(1)
284+
expect(confettiInstanceMock).toHaveBeenCalledTimes(2)
276285

277286
const callArgs = confettiInstanceMock.mock.calls[0]?.[0]
278287
expect(callArgs).toEqual(expect.objectContaining({ particleCount: 12 }))
@@ -290,13 +299,22 @@ describe('ConfettiAnimationElement', () => {
290299
shapes: ['star'],
291300
})
292301

293-
expect(confettiInstanceMock).toHaveBeenCalledTimes(1)
294-
expect(confettiInstanceMock).toHaveBeenCalledWith(
302+
expect(confettiInstanceMock).toHaveBeenCalledTimes(2)
303+
expect(confettiInstanceMock).toHaveBeenNthCalledWith(
304+
1,
295305
expect.objectContaining({
296306
colors: ['#bada55', '#ff0000'],
297307
shapes: ['star'],
298308
})
299309
)
310+
311+
expect(confettiInstanceMock).toHaveBeenNthCalledWith(
312+
2,
313+
expect.objectContaining({
314+
colors: ['#bada55', '#ff0000'],
315+
shapes: ['circle'],
316+
})
317+
)
300318
})
301319
})
302320
})

src/components/Animations/Confetti/client/index.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ export type ConfettiFireOptions = {
4747
*/
4848
startVelocity?: number
4949

50+
/**
51+
* How long each particle should animate for, in frames.
52+
*/
53+
ticks?: number
54+
55+
/**
56+
* Downward acceleration to apply to particles.
57+
*/
58+
gravity?: number
59+
60+
/**
61+
* How quickly particle velocity decays over time.
62+
*/
63+
decay?: number
64+
5065
/**
5166
* Scale factor for each confetti particle. Use decimals to make the confetti
5267
* smaller. (default: 1)
@@ -72,6 +87,12 @@ export type ConfettiFireOptions = {
7287

7388
type ConfettiInstance = ReturnType<typeof createConfetti>
7489

90+
const defaultConfettiColors = ['#003d86', '#dc2626', '#facc15']
91+
92+
const toAccentShapes = (shapes: ConfettiShape[]): ConfettiShape[] => {
93+
return shapes.includes('star') ? ['circle'] : shapes
94+
}
95+
7596
export class ConfettiAnimationElement extends LitElement {
7697
private initialized = false
7798
private animationController: AnimationControllerHandle | undefined
@@ -175,23 +196,40 @@ export class ConfettiAnimationElement extends LitElement {
175196

176197
const {
177198
origin,
178-
particleCount = 50,
179-
spread = 70,
180-
startVelocity = 45,
181-
scalar = 1,
182-
colors,
183-
shapes = ['square', 'circle'],
199+
particleCount = 40,
200+
spread = 360,
201+
startVelocity = 30,
202+
ticks = 50,
203+
gravity = 0,
204+
decay = 0.94,
205+
scalar = 1.2,
206+
colors = defaultConfettiColors,
207+
shapes = ['star'],
184208
} = options
185209

186-
void this.confettiInstance({
187-
particleCount,
210+
const baseBurstOptions = {
188211
spread,
189212
startVelocity,
190-
scalar,
213+
ticks,
214+
gravity,
215+
decay,
191216
origin,
192217
colors,
218+
}
219+
220+
void this.confettiInstance({
221+
...baseBurstOptions,
222+
particleCount,
223+
scalar,
193224
shapes,
194225
})
226+
227+
void this.confettiInstance({
228+
...baseBurstOptions,
229+
particleCount: Math.max(10, Math.round(particleCount / 4)),
230+
scalar: Math.max(0.75, scalar * 0.625),
231+
shapes: toAccentShapes(shapes),
232+
})
195233
} catch (error) {
196234
handleScriptError(error, context)
197235
}

0 commit comments

Comments
 (0)