add: rating page

This commit is contained in:
Mahyar Khanbolooki
2025-07-21 19:38:00 +03:30
parent a0b52e7945
commit 13a3c3ce8a
7 changed files with 473 additions and 5 deletions
+128
View File
@@ -0,0 +1,128 @@
import clsx from 'clsx';
import { motion } from 'framer-motion';
import { Refresh } from 'iconsax-react';
import React, { useEffect, useRef, useState } from 'react';
type Props = {
className?: ToggleButtonClassName;
pending?: boolean;
defaultChecked?: boolean;
onToggle?: (checked: boolean) => void;
name: string; // required for form data
children?: React.ReactNode;
disabled?: boolean;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'onClick' | 'type'>;
export type ToggleButtonClassName = {
wrapper?: string;
wrapperDeactive?: string;
wrapperActive?: string;
wrapperDisabled?: string;
wrapperEnabled?: string;
wrapperPending?: string;
icon?: string;
iconDeactive?: string;
iconActive?: string;
content?: string;
contentDeactive?: string;
contentActive?: string;
};
export const ToggleButtonClassNames: ToggleButtonClassName = {
wrapper:
'relative transition-all border border-solid duration-200 cursor-pointer w-full rounded-normal p-3 font-normal text-sm overflow-clip',
wrapperDeactive: 'bg-white border-neutral-200',
wrapperActive: 'border-[#439C46]',
wrapperDisabled: 'bg-disabled text-disabled-text',
wrapperEnabled: 'bg-container text-white hover:bg-gray-100 active:brightness-110',
wrapperPending: 'bg-transparent text-transparent cursor-auto',
content: 'text-xs transition-all duration-150',
contentDeactive: 'text-foreground',
contentActive: 'text-[#439C46]',
};
function ToggleButton({
className = ToggleButtonClassNames,
disabled = false,
pending = false,
defaultChecked = false,
onToggle,
name,
children,
...rest
}: Props) {
const [checked, setChecked] = useState(defaultChecked);
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
if (disabled || pending) return;
const newChecked = !checked;
setChecked(newChecked);
onToggle?.(newChecked);
};
useEffect(() => {
const form = inputRef.current?.form;
if (!form) return;
const onReset = () => setChecked(defaultChecked);
form.addEventListener('reset', onReset);
return () => form.removeEventListener('reset', onReset);
}, [defaultChecked]);
return (
<>
<input
type="checkbox"
name={name}
checked={checked}
readOnly
hidden
ref={inputRef}
/>
<button
type="button"
disabled={disabled}
onClick={handleClick}
className={clsx(
className.wrapper,
disabled ? className.wrapperDisabled : className.wrapperEnabled,
pending && className.wrapperPending,
checked ? className.wrapperActive : className.wrapperDeactive
)}
{...rest}
>
{pending && (
<motion.div
className="absolute left-0 top-0 h-full w-full bg-neutral-200"
initial={{ y: '100%' }}
animate={{ y: 0 }}
>
<motion.div
animate={{ rotateZ: [0, 360] }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
>
<Refresh stroke="#333333" size={24} />
</motion.div>
</motion.div>
)}
<span
className={clsx(
className.content,
disabled || !checked ? className.contentDeactive : className.contentActive
)}
>
{children}
</span>
</button>
</>
);
}
export default ToggleButton;
+27 -5
View File
@@ -11,8 +11,15 @@ type Props = {
itemRenderer?: (child: ReactNode, index: number) => ReactElement;
children: ReactNode;
className?: TabContainerClassName;
changeType?: TabContainerRenderType;
defaultIndex?: number;
} & Omit<React.OlHTMLAttributes<HTMLUListElement>, 'className'>;
export enum TabContainerRenderType {
RENDERER,
VISIBILITY,
}
export type TabContainerClassName = {
wrapper?: string;
scrollView?: string;
@@ -41,9 +48,9 @@ export const TabContainerClassNames: TabContainerClassName = {
titleActive: 'text-black'
}
function TabContainer({ className = TabContainerClassNames, itemRenderer, children, ...restProps }: Props) {
function TabContainer({ className = TabContainerClassNames, changeType = TabContainerRenderType.RENDERER, defaultIndex = 0, itemRenderer, children, ...restProps }: Props) {
const [selectedTab, setSelectedTab] = useState(0);
const [selectedTab, setSelectedTab] = useState(defaultIndex);
const updateTab = useCallback((index: number) => {
setSelectedTab(index);
@@ -99,9 +106,24 @@ function TabContainer({ className = TabContainerClassNames, itemRenderer, childr
})}
</HorizontalScrollView>
<div>
{items[selectedTab]?.props?.viewRenderer ?? <div></div>}
</div>
{changeType === TabContainerRenderType.RENDERER &&
<div>
{items[selectedTab]?.props?.viewRenderer ?? <div></div>}
</div>
}
{changeType === TabContainerRenderType.VISIBILITY &&
<>
{items.map((item, index) => {
return (
<div key={index} className={clsx(
index === selectedTab ? 'visible' : 'hidden'
)}>
{item.props.viewRenderer}
</div>
)
})}
</>
}
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-3 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+43
View File
@@ -0,0 +1,43 @@
import { Star, Star1 } from 'iconsax-react'
import React from 'react'
import { Slider } from '../ui/slider';
type Props = {
content?: Array<{ value: string, icon: React.ReactElement }>;
className?: string;
name?: string;
}
const defaultContent: Array<{ value: string, icon: React.ReactElement }> = [
{ value: '5', icon: <Star className='fill-black me-1 mb-1' size={24} /> },
{ value: '4', icon: <Star className='fill-black me-1 mb-1' size={24} /> },
{ value: '3', icon: <Star className='fill-black me-1 mb-1' size={24} /> },
{ value: '2', icon: <Star className='fill-black me-1 mb-1' size={24} /> },
{ value: '1', icon: <Star className='fill-black me-1 mb-1' size={24} /> },
]
function RateSelectionBar({ name, content = defaultContent, className = '' }: Props) {
return (
<div className={`${className}`}>
<div className='relative w-full h-3 rounded-full'>
<Slider
min={1}
max={content.length}
name={name}
/>
</div>
<div className="flex justify-between items-center mt-2">
{content?.map((value, index) => {
return (
<span key={index} className='flex items-center gap-1'>
<Star1 className='fill-black mb-1' size={12} />
<span className='text-xs font-medium'>{value.value}</span>
</span>
)
})}
</div>
</div>
)
}
export default RateSelectionBar