feat: country, state, city select

This commit is contained in:
mehedi-hasan 2024-04-24 20:02:12 +06:00
parent c4c8c34579
commit 65ea9e5681
6 changed files with 295 additions and 75 deletions

32
package-lock.json generated
View File

@ -48,6 +48,7 @@
"contentlayer": "^0.3.4",
"eslint": "8.48.0",
"eslint-config-next": "^14.0.1",
"geonames.js": "^3.0.6",
"gray-matter": "^4.0.3",
"lucide-react": "^0.291.0",
"monaco-jsx-highlighter": "^2.77.77",
@ -6177,6 +6178,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/geonames.js": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/geonames.js/-/geonames.js-3.0.6.tgz",
"integrity": "sha512-iDnDEzknKB7RXunjwYNgWg8ZbxjJhO9ChauXjo3njcsqhpHp3rpssqJLhATEp6T6NIybWlT/V3i6dxw+kFcICA==",
"dependencies": {
"axios": "^0.21.0",
"qs": "^6.9.4"
}
},
"node_modules/geonames.js/node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -13521,6 +13539,20 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.12.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz",
"integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -50,6 +50,7 @@
"contentlayer": "^0.3.4",
"eslint": "8.48.0",
"eslint-config-next": "^14.0.1",
"geonames.js": "^3.0.6",
"gray-matter": "^4.0.3",
"lucide-react": "^0.291.0",
"monaco-jsx-highlighter": "^2.77.77",

View File

@ -1,4 +1,5 @@
"use client";
import Geolocation from "@/components/geolocation";
import { PhoneInput } from "@/components/phone-input";
import {
Accordion,
@ -57,9 +58,11 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
const [currentStep, setCurrentStep] = useState(0);
const [data, setData] = useState({});
const delta = currentStep - previousStep;
const [countryGeoId, setCountryGeoId] = useState<null | number>(null);
const [stateGeoId, setStateGeoId] = useState<null | number>(null);
const defaultValues = {
// firstname: "",
// state: "",
jobs: [
{
jobtitle: "",
@ -137,6 +140,7 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
"email",
"contactno",
"country",
"state",
"city",
],
},
@ -187,6 +191,8 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
const countries = [{ id: "1", name: "india" }];
const cities = [{ id: "1", name: "kerala" }];
console.log(form.getValues());
return (
<>
<div className="flex items-center justify-between">
@ -208,18 +214,18 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
{steps.map((step, index) => (
<li key={step.name} className="md:flex-1">
{currentStep > index ? (
<div className="group flex w-full flex-col border-l-4 border-sky-600 py-2 pl-4 transition-colors md:border-l-0 md:border-t-4 md:pb-0 md:pl-0 md:pt-4">
<span className="text-sm font-medium text-sky-600 transition-colors ">
<div className="group flex w-full flex-col border-l-4 border-primary py-2 pl-4 transition-colors md:border-l-0 md:border-t-4 md:pb-0 md:pl-0 md:pt-4">
<span className="text-sm font-medium text-primary transition-colors ">
{step.id}
</span>
<span className="text-sm font-medium">{step.name}</span>
</div>
) : currentStep === index ? (
<div
className="flex w-full flex-col border-l-4 border-sky-600 py-2 pl-4 md:border-l-0 md:border-t-4 md:pb-0 md:pl-0 md:pt-4"
className="flex w-full flex-col border-l-4 border-primary py-2 pl-4 md:border-l-0 md:border-t-4 md:pb-0 md:pl-0 md:pt-4"
aria-current="step"
>
<span className="text-sm font-medium text-sky-600">
<span className="text-sm font-medium text-primary">
{step.id}
</span>
<span className="text-sm font-medium">{step.name}</span>
@ -323,7 +329,6 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
defaultCountry="IN"
international
{...field}
/>
</FormControl>
<FormMessage />
@ -335,63 +340,68 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>Country</FormLabel>
<Select
disabled={loading}
onValueChange={field.onChange}
value={field.value}
defaultValue={field.value}
<FormLabel
// className="block mb-[6px] mt-[2px]"
>
<FormControl>
<SelectTrigger>
<SelectValue
defaultValue={field.value}
placeholder="Select a country"
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{/* @ts-ignore */}
{countries.map((country) => (
<SelectItem key={country.id} value={country.name.toLocaleLowerCase()}>
{country.name}
</SelectItem>
))}
</SelectContent>
</Select>
Country
</FormLabel>
<Geolocation
geoId={null}
onChange={(country, geoId) => {
setCountryGeoId(geoId);
field.onChange(country);
}}
isCountry
placeholderText="Select a country"
searchPlaceholder="Search country..."
emptyPlaceholder="No country found"
isDisabled={false}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="state"
render={({ field }) => (
<FormItem>
<FormLabel>State</FormLabel>
<Geolocation
geoId={countryGeoId}
onChange={(state, geoId) => {
setStateGeoId(geoId);
field.onChange(state);
}}
isCountry={false}
placeholderText="Select a state"
searchPlaceholder="Search state..."
emptyPlaceholder="No state found"
isDisabled={!countryGeoId}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>City</FormLabel>
<Select
disabled={loading}
onValueChange={field.onChange}
value={field.value}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
defaultValue={field.value}
placeholder="Select a city"
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{/* @ts-ignore */}
{cities.map((city) => (
<SelectItem key={city.id} value={city.name.toLocaleLowerCase()}>
{city.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Geolocation
geoId={stateGeoId}
onChange={(v) => field.onChange(v)}
isCountry={false}
placeholderText="Select a city"
searchPlaceholder="Search city..."
emptyPlaceholder="No city found"
isDisabled={!stateGeoId}
/>
<FormMessage />
</FormItem>
)}
@ -602,7 +612,7 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
<div>
<h1>Completed</h1>
<pre className="whitespace-pre-wrap">
{JSON.stringify(data)}
{JSON.stringify(data, null, 2)}
</pre>
</div>
)}
@ -616,11 +626,12 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
{/* Navigation */}
<div className="mt-8 pt-5">
<div className="flex justify-between">
<button
<Button
type="button"
onClick={prev}
disabled={currentStep === 0}
className="rounded bg-white px-2 py-1 text-sm font-semibold text-sky-900 shadow-sm ring-1 ring-inset ring-sky-300 hover:bg-sky-50 disabled:cursor-not-allowed disabled:opacity-50"
size="icon"
// className="rounded bg-white px-2 py-1 text-sm font-semibold text-sky-900 shadow-sm ring-1 ring-inset ring-sky-300 hover:bg-sky-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -636,12 +647,13 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</button>
<button
</Button>
<Button
type="button"
onClick={next}
disabled={currentStep === steps.length - 1}
className="rounded bg-white px-2 py-1 text-sm font-semibold text-sky-900 shadow-sm ring-1 ring-inset ring-sky-300 hover:bg-sky-50 disabled:cursor-not-allowed disabled:opacity-50"
size="icon"
// className="rounded bg-white px-2 py-1 text-sm font-semibold text-sky-900 shadow-sm ring-1 ring-inset ring-sky-300 hover:bg-sky-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -657,7 +669,7 @@ export const CreateProfileOne: React.FC<ProfileFormType> = ({
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</Button>
</div>
</div>
</>

View File

@ -0,0 +1,161 @@
"use client";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { FC, useEffect, useState } from "react";
import Geonames from "geonames.js";
import { FormControl } from "./ui/form";
const frameworks = [
{
value: "next.js",
label: "Next.js",
},
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
];
// @ts-ignore
const geonames = new Geonames({
username: "thalesandrade",
lan: "en",
encoding: "JSON",
});
interface Props {
geoId: number | null;
onChange: (country: string, geoId: number) => void;
isCountry: boolean;
placeholderText: string;
searchPlaceholder: string;
emptyPlaceholder: string;
isDisabled: boolean;
}
const Geolocation: FC<Props> = ({
geoId,
onChange,
isCountry,
placeholderText,
searchPlaceholder,
emptyPlaceholder,
isDisabled,
}) => {
const [open, setOpen] = useState(false);
const [currentValue, setCurrentValue] = useState<null | number>(null);
const [options, setOptions] = useState<
{ countryName: string; geonameId: number; name: string }[]
>([]);
useEffect(() => {
try {
const data = () => {
isCountry
? geonames.countryInfo({}).then((res: any) => {
console.log(res);
setOptions(res.geonames);
})
: geonames.children({ geonameId: geoId }).then((res: any) => {
if (res.totalResultsCount) setOptions(res.geonames);
});
};
data();
} catch (err) {
console.error(err);
}
}, [geoId, isCountry]);
const currentPlaceholderText =
currentValue && isCountry
? options.find((option: any) => option.geonameId === currentValue)
?.countryName
: currentValue
? options.find((option: any) => option.geonameId === currentValue)?.name
: placeholderText;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild disabled={isDisabled}>
<FormControl>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{currentPlaceholderText}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] md:w-[400px] p-0">
<Command>
<CommandList>
<CommandInput placeholder={searchPlaceholder} className="h-9" />
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((v: any, index) => (
<CommandItem
key={index}
value={v.geonameId}
onSelect={() => {
setCurrentValue(
v.geonameId === currentValue ? null : v.geonameId,
);
const name = isCountry ? v.countryName : v.name;
onChange(
v.geonameId === currentValue ? "" : name,
v.geonameId === currentValue ? null : v.geonameId,
);
setOpen(false);
}}
>
{isCountry ? v.countryName : v.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
currentValue === v.geonameId
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export default Geolocation;

View File

@ -117,7 +117,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
className
)}
{...props}

View File

@ -3,28 +3,42 @@ import { isValidPhoneNumber } from "react-phone-number-input";
export const profileSchema = z.object({
firstname: z
.string()
.min(3, { message: "First name must be at least 3 characters" }),
.string({ required_error: "Please enter your first name" })
.trim()
.min(1, { message: "Please enter your first name" }),
lastname: z
.string()
.min(3, { message: "Last name must be at least 3 characters" }),
.string({ required_error: "Please enter your last name" })
.trim()
.min(1, { message: "Please enter your last name" }),
email: z
.string()
.email({ message: "Invalid email" }),
contactno: z.string({required_error: "Number is required"}).refine((value) => isValidPhoneNumber(value), {message: "Invalid number"}),
country: z.string().min(1, { message: "Please select a category" }),
city: z.string().min(1, { message: "Please select a category" }),
.string({ required_error: "Please enter your email" })
.email({ message: "Please enter a valid email" }),
contactno: z
.string({ required_error: "Please enter your phone number" })
.refine((value) => isValidPhoneNumber(value), {
message: "Please enter a valid phone number",
}),
country: z
.string({ required_error: "Please select your country" })
.trim()
.min(1, { message: "Please select your country" }),
state: z
.string({ required_error: "Please select your state" })
.trim()
.min(1, { message: "Please select your state" }),
city: z
.string({ required_error: "Please select your city" })
.trim()
.min(1, { message: "Please select your city" }),
// jobs array is for the dynamic fields
jobs: z.array(
z.object({
jobcountry: z.string().min(1, { message: "Please select a country" }),
jobcity: z.string().min(1, { message: "Please select a city" }),
jobtitle: z
.string(),
// .min(3, { message: "First Name must be at least 3 characters" }),
employer: z
.string(),
// .min(3, { message: "First Name must be at least 3 characters" }),
jobtitle: z.string(),
// .min(3, { message: "First Name must be at least 3 characters" }),
employer: z.string(),
// .min(3, { message: "First Name must be at least 3 characters" }),
startdate: z
.string()
.refine((value) => /^\d{4}-\d{2}-\d{2}$/.test(value), {