Skip to content

Commit fd0f076

Browse files
committed
fix: add better form validation & focus states
1 parent ab50f4f commit fd0f076

File tree

7 files changed

+72
-61
lines changed

7 files changed

+72
-61
lines changed

apps/web/app/page.tsx

Lines changed: 2 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Image from "next/image";
22
import { QRCodeUrl } from "@/components/QRCodeUrl";
33
import { QRCodeCard } from "@/components/QRCodeCard";
4+
import Link from "next/link";
45

56
export default function Home() {
67
return (
@@ -20,53 +21,7 @@ export default function Home() {
2021
<QRCodeUrl />
2122
</div>
2223
</main>
23-
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
24-
<a
25-
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
26-
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
27-
target="_blank"
28-
rel="noopener noreferrer"
29-
>
30-
<Image
31-
aria-hidden
32-
src="/file.svg"
33-
alt="File icon"
34-
width={16}
35-
height={16}
36-
/>
37-
Learn
38-
</a>
39-
<a
40-
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
41-
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
42-
target="_blank"
43-
rel="noopener noreferrer"
44-
>
45-
<Image
46-
aria-hidden
47-
src="/window.svg"
48-
alt="Window icon"
49-
width={16}
50-
height={16}
51-
/>
52-
Examples
53-
</a>
54-
<a
55-
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
56-
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
57-
target="_blank"
58-
rel="noopener noreferrer"
59-
>
60-
<Image
61-
aria-hidden
62-
src="/globe.svg"
63-
alt="Globe icon"
64-
width={16}
65-
height={16}
66-
/>
67-
Go to nextjs.org →
68-
</a>
69-
</footer>
24+
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center" />
7025
</div>
7126
);
7227
}

apps/web/components/ColorInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function ColorInput({ type }: ColorInputProps) {
1717
<div
1818
className={cn(
1919
"flex w-full rounded-md transition-all duration-200 border bg-background overflow-hidden",
20-
"focus-within:ring-2 focus-within:ring-primary focus-within:outline-none"
20+
"focus-within:ring-[1.5px] focus-within:ring-primary focus-within:outline-none"
2121
)}
2222
>
2323
<div className="flex items-center justify-center min-w-9 text-sm bg-secondary border-r text-secondary-foreground">

apps/web/components/DataInput.tsx

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useRef, useEffect } from "react";
3+
import { useRef, useEffect, useState } from "react";
44
import { cn } from "@/lib/utils";
55
import { Input } from "./ui/input";
66
import {
@@ -20,6 +20,11 @@ export function DataInput() {
2020
const isSeparated = config.inputs.length > 1;
2121
const containerRef = useRef<HTMLDivElement>(null);
2222

23+
// Add validation state tracking
24+
const [validationState, setValidationState] = useState<
25+
Record<string, boolean>
26+
>({});
27+
2328
const gridCols =
2429
{
2530
1: "grid-cols-1",
@@ -28,11 +33,22 @@ export function DataInput() {
2833
4: "grid-cols-4",
2934
}[config.layout?.columns ?? 1] ?? "grid-cols-1";
3035

31-
// Handle input changes
32-
const handleInputChange = (id: string, value: string) => {
36+
// Handle input changes with validation
37+
const handleInputChange = (
38+
id: string,
39+
value: string,
40+
validation?: (value: string) => boolean
41+
) => {
3342
const values = { ...data?.data } as Record<string, string>;
3443
values[id] = value;
3544
setData(type, values);
45+
46+
if (validation) {
47+
setValidationState((prev) => ({
48+
...prev,
49+
[id]: validation(value),
50+
}));
51+
}
3652
};
3753

3854
// Animate height changes
@@ -54,21 +70,33 @@ export function DataInput() {
5470
<div
5571
ref={containerRef}
5672
className="flex flex-col gap-4 w-full relative transition-[height] duration-300 ease-in-out"
57-
style={{ height: "40px" }} // Initial height of single input
73+
style={{ height: "40px" }}
5874
>
5975
<div className="absolute w-full">
60-
<div className={cn("flex relative")}>
76+
<div
77+
className={cn(
78+
"flex relative rounded-md transition-all duration-200",
79+
!isSeparated &&
80+
"focus-within:ring-[1.5px] focus-within:ring-primary"
81+
)}
82+
>
6183
<DataSelect type={type} onTypeSelect={(t) => setData(t, {})} />
6284
<Input
6385
type={config.inputs[0].type}
6486
id={config.inputs[0].id}
6587
value={data?.data?.[config.inputs[0].id] || ""}
6688
onChange={(e) =>
67-
handleInputChange(config.inputs[0].id, e.target.value)
89+
handleInputChange(
90+
config.inputs[0].id,
91+
e.target.value,
92+
config.inputs[0].validation
93+
)
6894
}
6995
className={cn(
70-
"rounded-l-none focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-0",
71-
isSeparated && "rounded-l-md ml-3"
96+
"rounded-l-none focus-visible:ring-0 px-3 py-0",
97+
isSeparated && "rounded-l-md ml-3 focus-visible:ring-[1.5px]",
98+
validationState[config.inputs[0].id] === false &&
99+
"text-destructive"
72100
)}
73101
placeholder={config.inputs[0].placeholder}
74102
/>
@@ -88,12 +116,15 @@ export function DataInput() {
88116
type={input.type}
89117
id={input.id}
90118
value={data?.data?.[input.id] || ""}
91-
onChange={(e) => handleInputChange(input.id, e.target.value)}
119+
onChange={(e) =>
120+
handleInputChange(input.id, e.target.value, input.validation)
121+
}
92122
placeholder={input.placeholder}
93123
className={cn(
94124
input.className,
95125
"animate-in fade-in duration-300 ease-in-out",
96-
`delay-[${index * 75}ms]`
126+
`delay-[${index * 75}ms]`,
127+
validationState[input.id] === false && "text-destructive"
97128
)}
98129
/>
99130
))}

apps/web/components/LogoInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function LogoInput() {
2323
}, [logo]);
2424

2525
return (
26-
<div className="flex w-full rounded-md transition-all duration-200 border bg-background overflow-hidden focus-within:ring-2 focus-within:ring-primary">
26+
<div className="flex w-full rounded-md transition-all duration-200 border bg-background overflow-hidden focus-within:ring-[1.5px] focus-within:ring-primary">
2727
<div className="flex items-center justify-center min-w-9 text-sm bg-secondary border-r text-secondary-foreground">
2828
{isValidImage === true && logo ? (
2929
<img

apps/web/components/ui/input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
88
type={type}
99
data-slot="input"
1010
className={cn(
11-
"w-full text-sm rounded-md transition-all duration-200 border bg-background px-4 py-2 outline-none focus:ring-2 focus:ring-primary h-9",
11+
"w-full text-sm rounded-md transition-all duration-200 border bg-background px-4 py-2 outline-none focus:ring-[1.5px] focus:ring-primary h-9",
1212
className
1313
)}
1414
{...props}

apps/web/lib/qr/schema.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const inputFieldSchema = z.object({
1010
type: z.enum(["text", "number", "email", "tel", "url"]).default("text"),
1111
label: z.string().optional(),
1212
className: z.string().optional(),
13+
validation: z.function().args(z.string()).returns(z.boolean()).optional(),
1314
});
1415

1516
// Data type schema
@@ -34,7 +35,10 @@ export const qrDataSchemas = {
3435
subject: z.string(),
3536
body: z.string(),
3637
}),
37-
phone: z.object({ phone: z.string() }),
38+
phone: z.object({
39+
phone: z.string(),
40+
message: z.string().optional(),
41+
}),
3842
wifi: z.object({
3943
ssid: z.string(),
4044
password: z.string(),
@@ -68,6 +72,15 @@ export const DataInputs: QRDataTypesConfig = {
6872
id: "url",
6973
placeholder: "Enter URL",
7074
type: "url",
75+
validation: (value) => {
76+
// Allows URLs with or without protocol
77+
// Must have valid domain structure (e.g. example.com, sub.example.co.uk)
78+
// Can have paths, query params, fragments
79+
// Protocol if present must be http:// or https://
80+
return /^(?:(?:https?:\/\/)?(?:[\w-]+\.)+[a-z]{2,}(?:\/[^\s]*)?)?$/i.test(
81+
value
82+
);
83+
},
7184
},
7285
],
7386
},
@@ -79,6 +92,7 @@ export const DataInputs: QRDataTypesConfig = {
7992
id: "to",
8093
placeholder: "To Email",
8194
type: "email",
95+
validation: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
8296
},
8397
{
8498
id: "subject",
@@ -100,8 +114,18 @@ export const DataInputs: QRDataTypesConfig = {
100114
id: "phone",
101115
placeholder: "Enter phone number",
102116
type: "tel",
117+
validation: (value) => /^\+?[\d\s-()]{10,}$/.test(value),
118+
},
119+
{
120+
id: "message",
121+
placeholder: "Message (optional)",
122+
type: "text",
103123
},
104124
],
125+
layout: {
126+
grid: true,
127+
columns: 1,
128+
},
105129
},
106130
wifi: {
107131
icon: Icons.WiFi,

apps/web/lib/qr/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type QRDataInput = {
3939
placeholder: string;
4040
label?: string;
4141
className?: string;
42+
validation?: (value: string) => boolean;
4243
};
4344
export type QRDataConfig = {
4445
icon: FC<IconProps>;

0 commit comments

Comments
 (0)