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
+81
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@tanstack/react-query": "^5.81.5",
@@ -1031,6 +1032,12 @@
"node": ">=12.4.0"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
@@ -1060,6 +1067,32 @@
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -1126,6 +1159,21 @@
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
@@ -1351,6 +1399,39 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz",
"integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@tanstack/react-query": "^5.81.5",
+130
View File
@@ -0,0 +1,130 @@
'use client';
import ToggleButton from '@/components/button/ToggleButton';
import TabContainer, { TabContainerClassNames, TabContainerRenderType } from '@/components/tab/TabContainer';
import { TabHeader } from '@/components/tab/TabHeader';
import { Button } from '@/components/ui/button';
import RateSelectionBar from '@/components/utils/RateSelectionBar';
import clsx from 'clsx';
import { useParams } from 'next/navigation';
import React from 'react'
type Props = object
type Params = {
orderId: string;
}
function RatingOrderIndex({ }: Props) {
const params: Params = useParams();
console.log(params)
const badPoitns = () => {
return (
<>
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
<ToggleButton name='minus1'>
اماده سازی زمانبر
</ToggleButton>
<ToggleButton name='minus2'>
بهداشت محیطی بد
</ToggleButton>
<ToggleButton name='minus3'>
کیفیت منو پایین
</ToggleButton>
<ToggleButton name='minus4'>
تنوع پایین منو
</ToggleButton>
<ToggleButton name='minus5'>
برخورد نامناسب کارکنان
</ToggleButton>
<ToggleButton name='minus6'>
قیمت نامناسب
</ToggleButton>
</div>
</>
)
}
const goodPoitns = () => {
return (
<>
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
<ToggleButton name='plus1'>
اماده سازی سریع
</ToggleButton>
<ToggleButton name='plus2'>
بهداشت محیطی خوب
</ToggleButton>
<ToggleButton name='plus3'>
کیفیت منو خوب
</ToggleButton>
<ToggleButton name='plus4'>
تنوع بالا منو
</ToggleButton>
<ToggleButton name='plus5'>
برخورد مناسب کارکنان
</ToggleButton>
<ToggleButton name='plus6'>
قیمت مناسب
</ToggleButton>
</div>
</>
)
}
const submitForm = (formData: FormData) => {
console.log(formData.get('rating'))
}
return (
<section className='min-h-full'>
<h1 className='font-medium'>امتیاز شما به رستوران</h1>
<form action={submitForm} className='bg-container h-full flex flex-col justify-between rounded-[30px] pt-10 px-6 pb-7.5 mt-4'>
<div>
<div className='w-full'>
<RateSelectionBar name='rating'/>
</div>
<div className='mt-10'>
<TabContainer
defaultIndex={1}
changeType={TabContainerRenderType.VISIBILITY}
className={{
...TabContainerClassNames,
wrapper: 'p-2!',
scrollView: clsx(
'border-none! rounded-xl! gap-0! h-full bg-[#EAECF0]! grid! grid-cols-2 px-2! py-2! pt-2!', TabContainerClassNames.scrollView
),
title: 'text-sm2! font-normal mt-1',
header: 'rounded-lg h-7! w-full',
headerActive: 'bg-primary',
titleActive: 'text-accent'
}}>
<TabHeader title='نقاط ضعف' viewRenderer={badPoitns()}></TabHeader>
<TabHeader title='نقاط قوت' viewRenderer={goodPoitns()}></TabHeader>
</TabContainer>
<div className="w-full mt-6">
<label htmlFor='userOpinion'>نظر شما</label>
<br />
<textarea name='userOpinion' id='userOpinion' placeholder='نظر شما' className='mt-2 w-full h-21 px-4 py-2.5 text-xs text-[#888888] rounded-xl border border-[#D0D0D0]' />
<br />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-6">
<Button type='submit' className='mt-auto rounded-xl font-normal cursor-pointer'>ثبت بازخورد</Button>
<Button type='submit' className='mt-auto rounded-xl bg-white cursor-pointer hover:bg-neutral-100 border border-foreground text-foreground font-normal'>بازگشت</Button>
</div>
</form>
</section>
)
}
export default RatingOrderIndex
+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