single service + 404 page and 500 page
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
import { FC, Fragment } from 'react'
|
||||
import { useGetDetailService } from './hooks/useProductData'
|
||||
import ServiceHeader from './components/ServiceHeader'
|
||||
import moment from 'moment-jalaali'
|
||||
import ServiceImages from './components/ServiceImages'
|
||||
import Image from 'next/image'
|
||||
import Loading from '../loading'
|
||||
import Rate from 'rc-rate'
|
||||
import { ReviewItem } from './types/ProductTypes'
|
||||
|
||||
// Default avatar image
|
||||
const defaultAvatar = '/images/logo.svg' // Using an existing image
|
||||
|
||||
|
||||
const DetailService: FC<{ id: string }> = ({ id }) => {
|
||||
|
||||
// const { t } = useTranslation('global')
|
||||
// const getAdsLeft = useGetAds(AdsDisplayLocation.SINGLE_SERVICE_PAGE)
|
||||
|
||||
const getDetailService = useGetDetailService(id)
|
||||
|
||||
// Show the skeleton loading component while data is loading
|
||||
if (getDetailService.isLoading) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full flex gap-6 mt-4'>
|
||||
|
||||
{
|
||||
<Fragment>
|
||||
<div className='flex-1 max-w-full'>
|
||||
|
||||
{getDetailService.data?.data.danakService && (
|
||||
<ServiceHeader data={getDetailService.data.data.danakService} />
|
||||
)}
|
||||
<div className='mt-8 flex xl:flex-row flex-col overflowX justify-between items-center xl:gap-8 gap-5'>
|
||||
<div className='bg-white flex xl:min-w-[127px] w-full flex-1 flex-col justify-center items-center h-[109px] rounded-2xl py-6 px-4'>
|
||||
<div className='text-description xl:text-xs text-[11px]'>
|
||||
توسعه شرکت
|
||||
</div>
|
||||
<div className='mt-3 text-sm'>
|
||||
دانک
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-white flex xl:min-w-[127px] w-full flex-1 flex-col justify-center items-center h-[109px] rounded-2xl py-6 px-4'>
|
||||
<div className='text-description xl:text-xs text-[11px]'>
|
||||
تعداد کاربر
|
||||
</div>
|
||||
<div className='mt-3 text-sm'>
|
||||
+{getDetailService?.data?.data?.danakService?.userCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-white flex xl:min-w-[127px] w-full flex-1 flex-col justify-center items-center h-[109px] rounded-2xl py-6 px-4'>
|
||||
<div className='text-description xl:text-xs text-[11px]'>
|
||||
سال تولید
|
||||
</div>
|
||||
<div className='mt-3 text-sm'>
|
||||
{getDetailService?.data?.data?.danakService?.createdAt ?
|
||||
moment(getDetailService.data.data.danakService.createdAt).format('jYYYY/jMM/jDD') : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-white flex xl:min-w-[127px] w-full flex-1 flex-col justify-center items-center h-[109px] rounded-2xl py-6 px-4'>
|
||||
<div className='text-description xl:text-xs text-[11px]'>
|
||||
زبان برنامه
|
||||
</div>
|
||||
<div className='mt-3 text-sm'>
|
||||
{getDetailService?.data?.data?.danakService?.serviceLanguage === 'fa' ? 'فارسی' : 'انگلیسی'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-white flex xl:min-w-[127px] w-full flex-1 flex-col justify-center items-center h-[109px] rounded-2xl py-6 px-4'>
|
||||
<div className='text-description xl:text-xs text-[11px]'>
|
||||
امتیاز
|
||||
</div>
|
||||
<div className='mt-3 dltr flex gap-1 text-sm'>
|
||||
<Rate
|
||||
count={5}
|
||||
style={{ fontSize: 15 }}
|
||||
value={getDetailService?.data?.data?.averageRating}
|
||||
disabled
|
||||
allowHalf
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getDetailService?.data?.data?.danakService?.images && (
|
||||
<ServiceImages images={getDetailService.data.data.danakService.images} />
|
||||
)}
|
||||
|
||||
<div className='mt-8 bg-white p-6 rounded-3xl'>
|
||||
<div>
|
||||
توضیحات کامل
|
||||
</div>
|
||||
{getDetailService.data?.data?.danakService?.metaDescription && (
|
||||
<p dangerouslySetInnerHTML={{ __html: getDetailService.data.data.danakService.metaDescription }} className='text-[13px] mt-4 leading-6'>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='mt-8 flex xl:flex-row flex-col gap-8 items-start'>
|
||||
{/* {
|
||||
getDetailService.data?.data?.purchased &&
|
||||
<CreateReview refetch={() => getDetailService.refetch()} />
|
||||
} */}
|
||||
<div className='flex-1 w-full max-w-full bg-white p-6 rounded-3xl'>
|
||||
<div className='text-sm'>
|
||||
نظرات کاربران
|
||||
</div>
|
||||
|
||||
{
|
||||
getDetailService.data?.data.danakService?.reviews?.map((item: ReviewItem) => {
|
||||
return (
|
||||
<div key={item.id} className='mt-5 border border-border rounded-3xl p-6'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Image alt='avatar' width={24} height={24} src={item.user?.profilePic || defaultAvatar} className='size-6 rounded-full object-cover' />
|
||||
<div className='text-description text-xs'>
|
||||
{item.user?.firstName + ' ' + item.user?.lastName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex justify-between'>
|
||||
<div className='text-sm max-w-[70%] truncate'>
|
||||
{item.title}
|
||||
</div>
|
||||
<div className='text-xs text-description'>
|
||||
{moment(item.createdAt).fromNow()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-2'>
|
||||
<Rate
|
||||
disabled
|
||||
value={item.rating}
|
||||
count={5}
|
||||
style={{ fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 text-[11px] leading-6'>
|
||||
{item.comment}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* {
|
||||
getAdsLeft.isSuccess && getAdsLeft.data.data.ads[0] &&
|
||||
<a href={getAdsLeft.data?.data?.ads[0].link} target='_blank' className='bg-white w-sidebar h-fit hidden xl:block rounded-3xl overflow-hidden sticky top-4'>
|
||||
<img
|
||||
src={getAdsLeft.data.data.ads[0].imageUrl}
|
||||
className='w-full backdrop-blur-[100px] h-[550px] object-cover'
|
||||
/>
|
||||
|
||||
<div className='absolute flex items-center px-4 w-full bottom-0 h-[86px] bg-black bg-opacity-5 backdrop-blur-[14px]'>
|
||||
<div className='max-w-[80%] text-sm leading-6 text-white'>
|
||||
{getAdsLeft.data.data.ads[0].title}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
} */}
|
||||
</Fragment>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailService
|
||||
@@ -0,0 +1,81 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className='w-full flex gap-6 mt-4'>
|
||||
<div className='flex-1 max-w-full'>
|
||||
{/* Service Header Skeleton */}
|
||||
<div className="bg-white p-6 rounded-3xl animate-pulse">
|
||||
<div className="h-8 w-60 bg-gray-200 rounded-md"></div>
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
<div className="h-5 w-32 bg-gray-200 rounded-md"></div>
|
||||
<div className="h-10 w-32 bg-gray-200 rounded-3xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Skeleton */}
|
||||
<div className='mt-8 flex xl:flex-row flex-col overflowX justify-between items-center xl:gap-8 gap-5'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={`stat-${i}`} className='bg-white flex xl:min-w-[127px] w-full flex-1 flex-col justify-center items-center h-[109px] rounded-2xl py-6 px-4 animate-pulse'>
|
||||
<div className='h-4 w-20 bg-gray-200 rounded-md'></div>
|
||||
<div className='mt-3 h-5 w-16 bg-gray-200 rounded-md'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Service Images Skeleton */}
|
||||
<div className="mt-8 grid grid-cols-2 gap-4 animate-pulse">
|
||||
<div className="h-64 bg-gray-200 rounded-2xl"></div>
|
||||
<div className="h-64 bg-gray-200 rounded-2xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Description Skeleton */}
|
||||
<div className='mt-8 bg-white p-6 rounded-3xl animate-pulse'>
|
||||
<div className="h-6 w-32 bg-gray-200 rounded-md"></div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="h-4 w-full bg-gray-200 rounded-md"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded-md"></div>
|
||||
<div className="h-4 w-full bg-gray-200 rounded-md"></div>
|
||||
<div className="h-4 w-3/4 bg-gray-200 rounded-md"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviews Skeleton */}
|
||||
<div className='mt-8 flex xl:flex-row flex-col gap-8 items-start'>
|
||||
<div className='flex-1 w-full max-w-full bg-white p-6 rounded-3xl animate-pulse'>
|
||||
<div className="h-6 w-32 bg-gray-200 rounded-md"></div>
|
||||
|
||||
{[1, 2].map((i) => (
|
||||
<div key={`review-${i}`} className='mt-5 border border-gray-200 rounded-3xl p-6'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<div className="h-6 w-6 bg-gray-200 rounded-full"></div>
|
||||
<div className="h-4 w-32 bg-gray-200 rounded-md"></div>
|
||||
</div>
|
||||
<div className='mt-4 flex justify-between'>
|
||||
<div className="h-5 w-3/4 bg-gray-200 rounded-md"></div>
|
||||
<div className="h-4 w-20 bg-gray-200 rounded-md"></div>
|
||||
</div>
|
||||
<div className='mt-2 flex'>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<div key={`star-${i}-${star}`} className="h-4 w-4 bg-gray-200 rounded-sm mr-1"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-4 space-y-2'>
|
||||
<div className="h-3 w-full bg-gray-200 rounded-md"></div>
|
||||
<div className="h-3 w-full bg-gray-200 rounded-md"></div>
|
||||
<div className="h-3 w-1/2 bg-gray-200 rounded-md"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Ad Skeleton */}
|
||||
<div className='bg-white w-sidebar h-[550px] hidden xl:block rounded-3xl overflow-hidden sticky top-4 animate-pulse'>
|
||||
<div className="w-full h-full bg-gray-200"></div>
|
||||
<div className='absolute flex items-center px-4 w-full bottom-0 h-[86px] bg-gray-100'>
|
||||
<div className="h-5 w-3/4 bg-gray-200 rounded-md"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,39 @@
|
||||
import React from 'react'
|
||||
import { Suspense } from 'react'
|
||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
|
||||
import { detailServiceQueryOptions } from '../hooks/useProductData'
|
||||
import { NextPage } from 'next'
|
||||
import DetailService from '../DetailService'
|
||||
import Loading from './loading'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
const SingleService: NextPage<PageProps> = async ({ params }) => {
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>page</div>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<SingleServicePage id={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
async function SingleServicePage({ id }: { id: string }) {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
await Promise.all([
|
||||
queryClient.prefetchQuery(detailServiceQueryOptions(id)),
|
||||
])
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<DetailService id={id} />
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default SingleService
|
||||
@@ -0,0 +1,109 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import Button from '../../../components/Button'
|
||||
import { ArrowLeft } from 'iconsax-react'
|
||||
import { NumberFormat } from '../../../config/func'
|
||||
import Link from 'next/link'
|
||||
import { DanakService } from '../types/ProductTypes'
|
||||
import RadioGroup from '@/components/RadioGroup'
|
||||
import Image from 'next/image'
|
||||
|
||||
type Props = {
|
||||
data: DanakService,
|
||||
}
|
||||
|
||||
const ServiceHeader: FC<Props> = (props: Props) => {
|
||||
|
||||
const { data } = props
|
||||
const [planSelected, setPlanSelected] = useState<string>(data?.subscriptionPlans[0]?.id)
|
||||
const [price, setPrice] = useState<number>(data?.subscriptionPlans[0]?.price)
|
||||
const [finalPrice, setFinalPrice] = useState<number>(0)
|
||||
// const buyService = useBuyService()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const plan: any = data?.subscriptionPlans.find((item) => item.id === planSelected)
|
||||
|
||||
setFinalPrice(plan.finalPrice)
|
||||
setPrice(plan?.price)
|
||||
|
||||
}, [data?.subscriptionPlans, planSelected])
|
||||
|
||||
|
||||
return (
|
||||
<div className='w-full p-6 bg-white rounded-3xl flex xl:flex-row flex-col items-end xl:justify-between'>
|
||||
<div className='flex w-full xl:gap-8 gap-4'>
|
||||
<div className='xl:size-[70px] xl:min-w-[70px] min-w-[50px] size-[50px] rounded-xl'>
|
||||
<Image alt='icon' width={70} height={70} src={data?.icon} className='w-full h-full rounded-xl' />
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{data?.name}
|
||||
</div>
|
||||
|
||||
<p className='text-description mt-1 text-[11px]'>
|
||||
{data?.description}
|
||||
</p>
|
||||
<div className='hidden xl:flex gap-5 mt-5'>
|
||||
<RadioGroup
|
||||
items={data?.subscriptionPlans?.map((item) => {
|
||||
return {
|
||||
label: item?.name,
|
||||
value: item?.id
|
||||
}
|
||||
})}
|
||||
onChange={setPlanSelected}
|
||||
selected={planSelected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full flex-col justify-between xl:items-end'>
|
||||
<div className='xl:hidden flex gap-5 mt-5'>
|
||||
<RadioGroup
|
||||
items={data?.subscriptionPlans?.map((item) => {
|
||||
return {
|
||||
label: item?.name,
|
||||
value: item?.id
|
||||
}
|
||||
})}
|
||||
onChange={setPlanSelected}
|
||||
selected={planSelected}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex gap-4 mt-5 xl:mt-0 items-center justify-between xl:justify-normal'>
|
||||
<div className='text-xs whitespace-nowrap flex gap-1 items-center'>
|
||||
<div className='flex gap-2'>
|
||||
{
|
||||
finalPrice !== price &&
|
||||
<div className='line-through text-xs text-description'>
|
||||
{NumberFormat(Number(price))}
|
||||
</div>
|
||||
}
|
||||
<div>{NumberFormat(Number(price))}</div>
|
||||
</div>
|
||||
<span>تومان</span>
|
||||
</div>
|
||||
<Link target='_blank' href={`https://console.danakcorp.com/services/detail/${data?.id}`}>
|
||||
<Button
|
||||
// onClick={handleBuy}
|
||||
// isLoading={buyService.isPending}
|
||||
className='bg-description xl:max-w-full max-w-[100px] text-xs h-8 bg-opacity-20 text-black px-4'
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
<div>
|
||||
خرید
|
||||
</div>
|
||||
<ArrowLeft className='size-4' color='black' />
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServiceHeader
|
||||
@@ -0,0 +1,39 @@
|
||||
import Image from 'next/image'
|
||||
import { FC } from 'react'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
|
||||
const SWIPER_SLIDE_STYLE = {
|
||||
overflow: 'visible !important',
|
||||
// width: 220,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
images: { id: string, imageUrl: string }[]
|
||||
}
|
||||
|
||||
const ServiceImages: FC<Props> = ({ images }) => {
|
||||
return (
|
||||
<div className='w-full mt-8'>
|
||||
<Swiper
|
||||
className='w-full'
|
||||
spaceBetween={20} // فاصله پیشفرض بین اسلایدها
|
||||
slidesPerView='auto'
|
||||
>
|
||||
|
||||
{
|
||||
images.map((item) => {
|
||||
return (
|
||||
<SwiperSlide key={item.id} className='max-w-[38%]' style={SWIPER_SLIDE_STYLE}>
|
||||
<div className='bg-white bg-opacity-50 w-full p-2 rounded-2xl'>
|
||||
<Image alt={item.imageUrl} width={2000} height={1000} src={item.imageUrl} className='w-full h-auto rounded-2xl' />
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
)
|
||||
})
|
||||
}
|
||||
</Swiper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServiceImages
|
||||
@@ -33,3 +33,14 @@ export const categoriesQueryOptions = {
|
||||
queryKey: ["service-categories"],
|
||||
queryFn: () => api.getCategoriesPublic(),
|
||||
};
|
||||
|
||||
export const useGetDetailService = (serviceId: string) =>
|
||||
useQuery({
|
||||
queryKey: ["service-detail", serviceId],
|
||||
queryFn: () => api.getDetailService(serviceId),
|
||||
});
|
||||
|
||||
export const detailServiceQueryOptions = (serviceId: string) => ({
|
||||
queryKey: ["service-detail", serviceId],
|
||||
queryFn: () => api.getDetailService(serviceId),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from "@/config/axios";
|
||||
import {
|
||||
CategoryResponse,
|
||||
DanakServiceSuggestedResponse,
|
||||
ServiceDetailResponse,
|
||||
ServiceResponse,
|
||||
} from "../types/ProductTypes";
|
||||
|
||||
@@ -29,3 +30,10 @@ export const getDanakServiceSuggested = async () => {
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getDetailService = async (serviceId: string) => {
|
||||
const { data } = await axios.get<ServiceDetailResponse>(
|
||||
`/danak-services/${serviceId}/public`
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { DanakSuggestService } from "@/app/home/types/HomeTypes";
|
||||
import { ApiResponse } from "@/shared/types/SharedTypes";
|
||||
|
||||
export interface ReviewItem {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
title: string;
|
||||
comment: string;
|
||||
rating: number;
|
||||
status: string;
|
||||
user: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
profilePic: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ServiceResponse = ApiResponse<{
|
||||
services: DanakSuggestService[];
|
||||
nextPage: boolean;
|
||||
@@ -25,3 +40,51 @@ export type ServiceCategoryType = {
|
||||
export type DanakServiceSuggestedResponse = ApiResponse<{
|
||||
danakServices: DanakSuggestService[];
|
||||
}>;
|
||||
|
||||
export interface DanakService {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
title: string;
|
||||
isDanakSuggest: boolean;
|
||||
description: string;
|
||||
author: string;
|
||||
createDate: string;
|
||||
userCount: number;
|
||||
serviceLanguage: string;
|
||||
softwareLanguage: string;
|
||||
metaDescription: string;
|
||||
isActive: boolean;
|
||||
link: string;
|
||||
icon: string;
|
||||
category: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
images: {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
imageUrl: string;
|
||||
}[];
|
||||
subscriptionPlans: {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
price: number;
|
||||
isActive: boolean;
|
||||
}[];
|
||||
reviews: ReviewItem[];
|
||||
// reviews: any[]; // replace with Review[] if review structure is defined
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export type ServiceDetailResponse = ApiResponse<{
|
||||
danakService: DanakService;
|
||||
reviews: [];
|
||||
deletedAt: string | null;
|
||||
averageRating: number;
|
||||
}>;
|
||||
|
||||
Reference in New Issue
Block a user