This commit is contained in:
2026-04-10 17:16:13 +05:00
commit f4b2b9b67e
80 changed files with 5776 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
icon?: JSX.Element;
color?: "primary" | "secondary";
width?: "fit" | "full";
disabled?: boolean;
className?: string;
onClick?: () => void;
type?: "submit" | "reset" | "button";
rounded?: string;
}
export function Button({
children,
color = "primary",
icon,
width = "fit",
disabled = false,
className,
onClick,
type,
rounded,
}: ButtonProps) {
const widthClass = width === "full" ? "w-full" : "w-fit";
return (
<button
type={type}
disabled={disabled}
onClick={(e) => {
if (type !== "submit") e.preventDefault();
onClick?.();
}}
className={`group cursor-pointer relative px-6 py-2${
rounded ? " rounded-" + rounded : ""
} min-w-fit ${
(color === "primary" ? "bg-gradient" : "") ||
(color === "secondary" ? " outline-1 outline-[#3D425C]" : "")
} ${icon ? "pr-4" : ""} flex gap-1 items-center overflow-hidden ${widthClass} ${className ?? ""} justify-between`}
>
<span className="group-hover:opacity-10 absolute top-0 left-0 w-full h-full transition-opacity bg-black opacity-0"></span>
<span className={"relative font-medium" + (icon ? "" : " m-auto")}>
{children}
</span>
<span className="relative">{icon}</span>
</button>
);
}
+56
View File
@@ -0,0 +1,56 @@
import {
FieldValues,
Path,
useController,
useFormContext,
useWatch,
} from "react-hook-form";
export function CheckboxesGroup<IFieldValues extends FieldValues>({
options,
name,
}: {
options: string[];
name: Path<IFieldValues>;
}) {
const { control } = useFormContext<IFieldValues>();
const {
field: { ref, onChange, ...inputProps },
} = useController({ control, name });
const values: string[] = useWatch({ control, name });
return (
<div className="flex flex-wrap lg:gap-[0.556vw] gap-2">
{options.map((option) => (
<label
htmlFor={name + "_" + option}
key={option}
className={`cursor-pointer transition-colors lg:rounded-[1.111vw] rounded-2xl font-medium text-nowrap select-none lg:px-[1.667vw] px-6 lg:py-[1.181vw] py-[17px] btnm ${
values.includes(option)
? "bg-white text-black"
: "bg-[#37393B99] hover:bg-[#37393B]"
}`}
>
{option}
<input
id={name + "_" + option}
className="hidden"
type="checkbox"
{...inputProps}
checked={values.includes(option)}
ref={ref}
onChange={() => {
onChange(
values.includes(option)
? values.filter((x) => x !== option)
: [...values, option]
);
}}
/>
</label>
))}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import CheckIcon from "@/components/icons/CheckIcon";
function CustomCheckbox({
value,
onChange,
}: {
value: string;
onChange: (value: string, checked: boolean) => void;
}) {
const [checked, setChecked] = useState<boolean>(false);
useEffect(() => {
onChange(value, checked);
}, [checked, onChange, value]);
return (
<>
<div
onClick={() => setChecked(!checked)}
className="flex gap-x-[10px] items-center hover:cursor-pointer"
>
<div
className={`${
checked ? "bg-gradient" : "bg-[#37393B]"
} w-[20px] h-[20px] radius-[5px] rounded relative`}
>
{checked && (
<div className="text-white lg:size-[1.389vw] size-4 absolute top-0">
<CheckIcon />
</div>
)}
</div>
<span className="md:text-sm">{value}</span>
</div>
</>
);
}
export default CustomCheckbox;
+45
View File
@@ -0,0 +1,45 @@
import { type Ref } from "react";
import {
formatRuPhoneDisplay,
normalizeRuPhoneFromInput,
} from "@/lib/phoneRu";
const inputClassName =
"placeholder:btnl placeholder:font-medium placeholder:select-none peer btnl w-full h-full bg-transparent rounded-none transition-all outline-none";
interface PhoneInputRuProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
inputRef?: Ref<HTMLInputElement>;
id?: string;
placeholder?: string;
}
export function PhoneInputRu({
value,
onChange,
onBlur,
inputRef,
id = "tel",
placeholder = "+7 (XXX) XXX - XX - XX",
}: PhoneInputRuProps) {
return (
<input
ref={inputRef}
type="tel"
autoComplete="tel"
id={id}
placeholder={placeholder}
className={inputClassName}
value={formatRuPhoneDisplay(value)}
onBlur={onBlur}
onChange={(e) => {
if (!e.nativeEvent.type.startsWith("input")) return;
const cleanValue = e.target.value.replace(/\s/g, "");
const inputType = (e.nativeEvent as InputEvent).inputType;
onChange(normalizeRuPhoneFromInput(cleanValue, inputType));
}}
/>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from "react";
import MuteIcon from "@/components/icons/MuteIcon";
import UnmutedIcon from "@/components/icons/UnmutedIcon";
export function VideoMutingBtn({
handleClick,
muted,
}: {
muted: boolean;
handleClick: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [point, setPoint] = useState([0, 0]);
useEffect(() => {
const el = ref.current;
const move = (e: MouseEvent) => setPoint([e.clientX, e.clientY]);
el?.addEventListener("mousemove", move);
return () => {
el?.removeEventListener("mousemove", move);
};
}, []);
return (
<div className="absolute left-0 top-0 h-[calc(5/6*100%)] w-full z-[7]">
<div ref={ref} className="group relative w-full h-full">
<button
type="button"
className="bg-[#37393B99] p-[1.736vw] [backdrop-filter:blur(30.72px)] rounded-full group-hover:opacity-100 transition-opacity group-hover:cursor-none opacity-0 sticky outline-none"
style={{ left: point[0] - 32, top: point[1] - 32 }}
onClick={handleClick}
>
{muted ? (
<div className="text-white lg:size-[1.944vw] size-7">
<UnmutedIcon />
</div>
) : (
<div className="text-white lg:size-[1.944vw] size-7">
<MuteIcon />
</div>
)}
</button>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { AnimatePresence, motion } from "framer-motion";
import {
ComponentProps,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { VideoMutingBtn } from "./VideoMutingBtn";
import { VideoProgressBar } from "./VideoProgressBar";
export const VideoPlayer = forwardRef<
HTMLVideoElement,
{
src: string;
showMutingBtn: boolean;
children?: React.ReactNode;
} & ComponentProps<"video">
>(
(
{ src, showMutingBtn, children, loop = true, autoPlay = true, className },
ref
) => {
const progressbarRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => videoRef.current!);
const [muted, setMuted] = useState(autoPlay);
const [playing, setPlaying] = useState(autoPlay);
const [progress, setProgress] = useState(0);
function handleProgressbarClick(e: React.MouseEvent) {
const video = videoRef.current;
const bar = progressbarRef.current;
if (!video || !bar) return;
video.currentTime =
(video.duration * (e.clientX - bar.getBoundingClientRect().x)) /
bar.clientWidth;
setProgress(
((video.currentTime ?? 0) / (video.duration ?? 1)) * 100
);
}
function handlePlaybackClick() {
if (!videoRef.current) return;
setPlaying(videoRef.current.paused);
videoRef.current[videoRef.current.paused ? "play" : "pause"]();
}
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const timeUpdateHandler = () =>
setProgress(((video.currentTime ?? 0) / (video.duration ?? 1)) * 100);
video.addEventListener("timeupdate", timeUpdateHandler);
return () => video.removeEventListener("timeupdate", timeUpdateHandler);
}, []);
return (
<div className="relative h-full">
<video
ref={videoRef}
src={src}
autoPlay={autoPlay}
muted={muted}
loop={loop}
playsInline
className={`lg:rounded-[1.111vw] rounded-2xl w-full h-full object-cover${
className ? " " + className : ""
}`}
/>
{showMutingBtn && (
<VideoMutingBtn
handleClick={() => setMuted(!videoRef.current!.muted)}
muted={muted}
/>
)}
<div className="absolute inset-0 rounded-2xl [background:linear-gradient(to_top,rgba(20,22,31,0.6),rgba(20,22,31,0))]" />
<AnimatePresence>
{muted && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
<VideoProgressBar
muted={muted}
progress={progress}
progressbarRef={progressbarRef}
playing={playing}
handlePlaybackClick={handlePlaybackClick}
handleProgressbarClick={handleProgressbarClick}
/>
</div>
);
}
);
VideoPlayer.displayName = "VideoPlayer";
+69
View File
@@ -0,0 +1,69 @@
import { MouseEventHandler, RefObject, useState } from "react";
import PauseIcon from "@/components/icons/PauseIcon";
import PlayIcon from "@/components/icons/PlayIcon";
export function VideoProgressBar({
muted,
progressbarRef,
playing,
handlePlaybackClick,
handleProgressbarClick,
progress,
}: {
muted: boolean;
progress: number;
progressbarRef: RefObject<HTMLDivElement | null>;
playing: boolean;
handlePlaybackClick: MouseEventHandler<HTMLButtonElement>;
handleProgressbarClick: MouseEventHandler<HTMLDivElement>;
}) {
const [isMouseDown, setIsMouseDown] = useState(false);
return (
<div
className={`bottom-2 left-2 right-2 absolute z-10 select-none flex items-stretch gap-1 ${
muted ? "hidden" : ""
}`}
>
<button
type="button"
className="p-[18px] bg-[#37393B99] rounded-2xl cursor-pointer"
onClick={handlePlaybackClick}
>
{playing ? (
<div className="text-white lg:size-[1.389vw] size-5">
<PauseIcon />
</div>
) : (
<div className="text-white lg:size-[1.389vw] size-5">
<PlayIcon />
</div>
)}
</button>
<div
className="flex-1 rounded-2xl bg-[#37393B99] px-6 cursor-pointer flex items-center select-none"
onMouseDown={(e) => {
setIsMouseDown(true);
handleProgressbarClick(e);
}}
onMouseMove={(e) => {
if (isMouseDown) handleProgressbarClick(e);
}}
onMouseUp={() => setIsMouseDown(false)}
onMouseLeave={() => setIsMouseDown(false)}
>
<div
ref={progressbarRef}
className="h-1 bg-[#7A7A7A] relative rounded-3xl cursor-pointer w-full"
>
<div
className="left-0 h-1 bg-white rounded-3xl transition-all"
style={{
width: progress + "%",
}}
/>
</div>
</div>
</div>
);
}