add api
This commit is contained in:
gofnnp 2025-06-16 12:07:30 +04:00
parent db1dd03f41
commit 342af0c71b
39 changed files with 644 additions and 119 deletions

8
.prettierignore Normal file
View File

@ -0,0 +1,8 @@
# Ignore artifacts:
build
.next/
dist
node_modules/
# Ignore files:
package-lock.json

7
.prettierrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "es5"
}

View File

@ -1,7 +1,18 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "aura-node.s3.eu-west-2.amazonaws.com",
pathname: "/**",
},
],
},
};
export default nextConfig;

17
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"prettier": "^3.5.3",
"typescript": "^5"
}
},
@ -4626,6 +4627,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

@ -13,7 +13,8 @@
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sass": "^1.89.2"
"sass": "^1.89.2",
"zod": "^3.25.64"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -22,6 +23,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"prettier": "^3.5.3",
"typescript": "^5"
}
}

View File

@ -0,0 +1,6 @@
.coreError {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}

32
src/app/(core)/error.tsx Normal file
View File

@ -0,0 +1,32 @@
'use client';
import Button from '@/components/ui/Button/Button';
import Typography from '@/components/ui/Typography/Typography';
import { useEffect } from 'react';
import styles from "./error.module.scss"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className={styles.coreError}>
<Typography as='h2' size='xl' weight='bold'>Something went wrong!</Typography>
<Typography as='p' align='center'>{error.message}</Typography>
<Button
onClick={
() => reset()
}
>
<Typography color='white'>Try again</Typography>
</Button>
</div>
);
}

View File

@ -0,0 +1,5 @@
.coreSpinnerContainer {
width: 100%;
display: flex;
justify-content: center;
}

View File

@ -0,0 +1,10 @@
import Spinner from "@/components/ui/Spinner/Spinner";
import styles from "./loading.module.scss"
export default function Loading() {
return (
<div className={styles.coreSpinnerContainer}>
<Spinner />
</div>
);
}

View File

@ -1,16 +1,32 @@
import { Suspense } from "react";
import Horoscope from "@/components/Horoscope/Horoscope";
import styles from "./page.module.scss"
import AdvisersSection from "@/components/AdvisersSection/AdvisersSection";
import CompatibilitySection from "@/components/CompatibilitySection/CompatibilitySection";
import MeditationSection from "@/components/MeditationSection/MeditationSection";
import PalmSection from "@/components/PalmSection/PalmSection";
import styles from "./page.module.scss";
import AdvisersSection, { AdvisersSectionSkeleton } from "@/components/AdvisersSection/AdvisersSection";
import CompatibilitySection, { CompatibilitySectionSkeleton } from "@/components/CompatibilitySection/CompatibilitySection";
import MeditationSection, { MeditationSectionSkeleton } from "@/components/MeditationSection/MeditationSection";
import PalmSection, { PalmSectionSkeleton } from "@/components/PalmSection/PalmSection";
import { loadAssistants, loadCompatibility, loadMeditations, loadPalms } from "@/entities/dashboard/loaders";
export default function Home() {
return <section className={styles.page}>
<Horoscope />
<AdvisersSection />
<CompatibilitySection />
<MeditationSection />
<PalmSection />
</section>;
return (
<section className={styles.page}>
<Horoscope />
<Suspense fallback={<AdvisersSectionSkeleton />}>
<AdvisersSection promise={loadAssistants()} />
</Suspense>
<Suspense fallback={<CompatibilitySectionSkeleton />}>
<CompatibilitySection promise={loadCompatibility()} />
</Suspense>
<Suspense fallback={<MeditationSectionSkeleton />}>
<MeditationSection promise={loadMeditations()} />
</Suspense>
<Suspense fallback={<PalmSectionSkeleton />}>
<PalmSection promise={loadPalms()} />
</Suspense>
</section>
);
}

View File

@ -8,6 +8,8 @@
position: relative;
overflow: hidden;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.content {
@ -46,12 +48,6 @@
display: flex;
align-items: center;
gap: 4px;
&>.stars {
display: flex;
align-items: center;
gap: 3px;
}
}
}
}

View File

@ -3,44 +3,38 @@ import Button from "../ui/Button/Button"
import Card from "../ui/Card/Card"
import Typography from "../ui/Typography/Typography"
import styles from "./AdviserCard.module.scss"
import { Assistant } from "@/entities/dashboard/types"
import Stars from "../ui/Stars/Stars"
export default function AdviserCard() {
type AdviserCardProps = Assistant;
export default function AdviserCard({
name,
photoUrl,
rating,
reviewCount,
description
}: AdviserCardProps) {
return (
<Card className={clsx(styles.card, styles.adviserCard)} style={{ backgroundImage: `url(/adviser-card.png)` }}>
<Card className={clsx(styles.card, styles.adviserCard)} style={{ backgroundImage: `url(${photoUrl})` }}>
<div className={styles.content}>
<div className={styles.info}>
<div className={styles.name}>
<Typography color="white" weight="bold">
Marta
{name}
</Typography>
<div className={styles.indicator} />
</div>
<Typography className={styles.description} color="white" weight="medium" size="xs">
Astrologer - 7 years
{description}
</Typography>
<div className={styles.rating}>
<Typography color="white" weight="medium" size="xs">
4.8
{rating}
</Typography>
<div className={styles.stars}>
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.57382 0.781219C4.49102 0.609363 4.31603 0.5 4.12387 0.5C3.9317 0.5 3.75828 0.609363 3.67392 0.781219L2.66934 2.84817L0.425835 3.17939C0.238356 3.20751 0.082123 3.33874 0.0243168 3.51841C-0.0334894 3.69808 0.0133805 3.8965 0.147741 4.02929L1.77569 5.64005L1.39135 7.91636C1.36011 8.10384 1.43822 8.29444 1.5929 8.40537C1.74757 8.51629 1.95223 8.53035 2.12096 8.4413L4.12543 7.37111L6.1299 8.4413C6.29863 8.53035 6.5033 8.51785 6.65797 8.40537C6.81264 8.29288 6.89075 8.10384 6.85951 7.91636L6.47361 5.64005L8.10156 4.02929C8.23592 3.8965 8.28435 3.69808 8.22498 3.51841C8.16561 3.33874 8.01094 3.20751 7.82346 3.17939L5.5784 2.84817L4.57382 0.781219Z" fill="#FFD600" />
</svg>
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.57382 0.781219C4.49102 0.609363 4.31603 0.5 4.12387 0.5C3.9317 0.5 3.75828 0.609363 3.67392 0.781219L2.66934 2.84817L0.425835 3.17939C0.238356 3.20751 0.082123 3.33874 0.0243168 3.51841C-0.0334894 3.69808 0.0133805 3.8965 0.147741 4.02929L1.77569 5.64005L1.39135 7.91636C1.36011 8.10384 1.43822 8.29444 1.5929 8.40537C1.74757 8.51629 1.95223 8.53035 2.12096 8.4413L4.12543 7.37111L6.1299 8.4413C6.29863 8.53035 6.5033 8.51785 6.65797 8.40537C6.81264 8.29288 6.89075 8.10384 6.85951 7.91636L6.47361 5.64005L8.10156 4.02929C8.23592 3.8965 8.28435 3.69808 8.22498 3.51841C8.16561 3.33874 8.01094 3.20751 7.82346 3.17939L5.5784 2.84817L4.57382 0.781219Z" fill="#FFD600" />
</svg>
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.57382 0.781219C4.49102 0.609363 4.31603 0.5 4.12387 0.5C3.9317 0.5 3.75828 0.609363 3.67392 0.781219L2.66934 2.84817L0.425835 3.17939C0.238356 3.20751 0.082123 3.33874 0.0243168 3.51841C-0.0334894 3.69808 0.0133805 3.8965 0.147741 4.02929L1.77569 5.64005L1.39135 7.91636C1.36011 8.10384 1.43822 8.29444 1.5929 8.40537C1.74757 8.51629 1.95223 8.53035 2.12096 8.4413L4.12543 7.37111L6.1299 8.4413C6.29863 8.53035 6.5033 8.51785 6.65797 8.40537C6.81264 8.29288 6.89075 8.10384 6.85951 7.91636L6.47361 5.64005L8.10156 4.02929C8.23592 3.8965 8.28435 3.69808 8.22498 3.51841C8.16561 3.33874 8.01094 3.20751 7.82346 3.17939L5.5784 2.84817L4.57382 0.781219Z" fill="#FFD600" />
</svg>
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.57382 0.781219C4.49102 0.609363 4.31603 0.5 4.12387 0.5C3.9317 0.5 3.75828 0.609363 3.67392 0.781219L2.66934 2.84817L0.425835 3.17939C0.238356 3.20751 0.082123 3.33874 0.0243168 3.51841C-0.0334894 3.69808 0.0133805 3.8965 0.147741 4.02929L1.77569 5.64005L1.39135 7.91636C1.36011 8.10384 1.43822 8.29444 1.5929 8.40537C1.74757 8.51629 1.95223 8.53035 2.12096 8.4413L4.12543 7.37111L6.1299 8.4413C6.29863 8.53035 6.5033 8.51785 6.65797 8.40537C6.81264 8.29288 6.89075 8.10384 6.85951 7.91636L6.47361 5.64005L8.10156 4.02929C8.23592 3.8965 8.28435 3.69808 8.22498 3.51841C8.16561 3.33874 8.01094 3.20751 7.82346 3.17939L5.5784 2.84817L4.57382 0.781219Z" fill="#FFD600" />
</svg>
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.57382 0.781219C4.49102 0.609363 4.31603 0.5 4.12387 0.5C3.9317 0.5 3.75828 0.609363 3.67392 0.781219L2.66934 2.84817L0.425835 3.17939C0.238356 3.20751 0.082123 3.33874 0.0243168 3.51841C-0.0334894 3.69808 0.0133805 3.8965 0.147741 4.02929L1.77569 5.64005L1.39135 7.91636C1.36011 8.10384 1.43822 8.29444 1.5929 8.40537C1.74757 8.51629 1.95223 8.53035 2.12096 8.4413L4.12543 7.37111L6.1299 8.4413C6.29863 8.53035 6.5033 8.51785 6.65797 8.40537C6.81264 8.29288 6.89075 8.10384 6.85951 7.91636L6.47361 5.64005L8.10156 4.02929C8.23592 3.8965 8.28435 3.69808 8.22498 3.51841C8.16561 3.33874 8.01094 3.20751 7.82346 3.17939L5.5784 2.84817L4.57382 0.781219Z" fill="#FFD600" />
</svg>
</div>
<Stars rating={rating} />
<Typography color="white" weight="medium" size="xs">
(5762)
({reviewCount})
</Typography>
</div>
</div>

View File

@ -9,4 +9,8 @@
.grid {
padding-right: 16px;
}
.advisersSkeleton.skeleton {
height: 486px;
}

View File

@ -1,29 +1,31 @@
import { Assistant } from "@/entities/dashboard/types";
import AdviserCard from "../AdviserCard/AdviserCard";
import Grid from "../ui/Grid/Grid"
import Section from "../ui/Section/Section"
import styles from "./AdvisersSection.module.scss"
import { use } from "react";
import Skeleton from "../ui/Skeleton/Skeleton";
import clsx from "clsx";
const advisers = [
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
{ name: "Marta", img: "/marta.jpg", rating: 4.8, years: 7 },
];
export default function AdvisersSection({ promise }: { promise: Promise<Assistant[]> }) {
const assistants = use(promise);
const columns = Math.ceil(assistants?.length / 2);
export default function AdvisersSection() {
return (
<Section title="Advisers" contentClassName={styles.sectionContent}>
<Grid columns={5} className={styles.grid}>
{advisers.map((adviser, index) => (
<AdviserCard key={index} {...adviser} />
<Grid columns={columns} className={styles.grid}>
{assistants.map((adviser) => (
<AdviserCard key={adviser._id} {...adviser} />
))}
</Grid>
</Section>
)
}
export function AdvisersSectionSkeleton() {
return (
<Section title="Advisers" contentClassName={styles.sectionContent}>
<Skeleton className={clsx(styles.advisersSkeleton, styles.skeleton)} />
</Section>
)
}

View File

@ -13,4 +13,9 @@
display: flex;
flex-direction: column;
justify-content: space-between;
}
.compatibilityImage {
object-fit: cover;
object-position: center;
}

View File

@ -4,28 +4,36 @@ import Typography from "../ui/Typography/Typography"
import styles from "./CompatibilityCard.module.scss"
import { IconName } from "../ui/Icon/Icon";
import MetaLabel from "../ui/MetaLabel/MetaLabel";
import { CompatibilityAction } from "@/entities/dashboard/types";
export default function CompatibilityCard() {
type CompatibilityCardProps = CompatibilityAction;
export default function CompatibilityCard({
imageUrl,
title,
type,
minutes
}: CompatibilityCardProps) {
return (
<Card className={styles.card}>
<Image
className={styles.logo}
src="/compatibility-card.png"
className={styles.compatibilityImage}
src={imageUrl}
alt="Compatibility image"
width={120}
height={110}
/>
<div className={styles.content}>
<Typography size="lg" weight="medium">
Compatibility
<Typography size="lg" weight="medium" align="left">
{title}
</Typography>
<MetaLabel iconLabelProps={{
iconProps: {
name: IconName.Article,
},
children: <Typography color="secondary">Article</Typography>
children: <Typography color="secondary">{type}</Typography>
}}>
5 min
{minutes} min
</MetaLabel>
</div>
</Card>

View File

@ -7,4 +7,8 @@
.grid {
padding-right: 16px;
}
.compatibilitySkeleton.skeleton {
height: 236px;
}

View File

@ -1,29 +1,31 @@
import { CompatibilityAction } from "@/entities/dashboard/types";
import CompatibilityCard from "../CompatibilityCard/CompatibilityCard";
import Grid from "../ui/Grid/Grid"
import Section from "../ui/Section/Section"
import styles from "./CompatibilitySection.module.scss"
import { use } from "react";
import Skeleton from "../ui/Skeleton/Skeleton";
import clsx from "clsx";
const compatibilities = [
{ title: "Compatibility" },
{ title: "Compatibility" },
{ title: "Compatibility" },
{ title: "Compatibility" },
{ title: "Compatibility" },
{ title: "Compatibility" },
{ title: "Compatibility" },
{ title: "Compatibility" },
{ title: "Compatibility" },
{ title: "Compatibility" },
];
export default function CompatibilitySection({ promise }: { promise: Promise<CompatibilityAction[]> }) {
const compatibilities = use(promise);
const columns = Math.ceil(compatibilities?.length / 2);
export default function CompatibilitySection() {
return (
<Section title="Compatibility" contentClassName={styles.sectionContent}>
<Grid columns={5} className={styles.grid}>
{compatibilities.map((compatibility, index) => (
<CompatibilityCard key={index} {...compatibility} />
<Grid columns={columns} className={styles.grid}>
{compatibilities.map((compatibility) => (
<CompatibilityCard key={compatibility._id} {...compatibility} />
))}
</Grid>
</Section>
)
}
export function CompatibilitySectionSkeleton() {
return (
<Section title="Compatibility" contentClassName={styles.sectionContent}>
<Skeleton className={clsx(styles.compatibilitySkeleton, styles.skeleton)} />
</Section>
)
}

View File

@ -33,4 +33,9 @@
transform: rotate(180deg);
}
}
}
.meditationImage {
object-fit: cover;
object-position: center;
}

View File

@ -5,13 +5,21 @@ import styles from "./MeditationCard.module.scss"
import Icon, { IconName } from "../ui/Icon/Icon";
import MetaLabel from "../ui/MetaLabel/MetaLabel";
import Button from "../ui/Button/Button";
import { Meditation } from "@/entities/dashboard/types";
export default function MeditationCard() {
type MeditationCardProps = Meditation;
export default function MeditationCard({
imageUrl,
title,
type,
minutes
}: MeditationCardProps) {
return (
<Card className={styles.card}>
<Image
className={styles.logo}
src="/meditation-card.png"
className={styles.meditationImage}
src={imageUrl}
alt="Meditation image"
width={342}
height={216}
@ -19,7 +27,7 @@ export default function MeditationCard() {
<div className={styles.content}>
<div className={styles.info}>
<Typography size="lg" weight="regular">
Reset
{title}
</Typography>
<MetaLabel iconLabelProps={{
iconProps: {
@ -30,9 +38,9 @@ export default function MeditationCard() {
height: 25
}
},
children: <Typography color="secondary">Therapy</Typography>
children: <Typography color="secondary">{type}</Typography>
}}>
15 min
{minutes} min
</MetaLabel>
</div>
<Button className={styles.button}>

View File

@ -7,4 +7,8 @@
.grid {
padding-right: 16px;
}
.meditationSkeleton.skeleton {
height: 308px;
}

View File

@ -1,24 +1,31 @@
import { Meditation } from "@/entities/dashboard/types";
import MeditationCard from "../MeditationCard/MeditationCard";
import Grid from "../ui/Grid/Grid"
import Section from "../ui/Section/Section"
import styles from "./MeditationSection.module.scss"
import { use } from "react";
import Skeleton from "../ui/Skeleton/Skeleton";
import clsx from "clsx";
const meditations = [
{ title: "Meditation" },
{ title: "Meditation" },
{ title: "Meditation" },
{ title: "Meditation" },
{ title: "Meditation" },
];
export default function MeditationSection({ promise }: { promise: Promise<Meditation[]> }) {
const meditations = use(promise);
const columns = meditations?.length;
export default function MeditationSection() {
return (
<Section title="Meditations" contentClassName={styles.sectionContent}>
<Grid columns={5} className={styles.grid}>
{meditations.map((meditation, index) => (
<MeditationCard key={index} {...meditation} />
<Grid columns={columns} className={styles.grid}>
{meditations.map((meditation) => (
<MeditationCard key={meditation._id} {...meditation} />
))}
</Grid>
</Section>
)
}
export function MeditationSectionSkeleton() {
return (
<Section title="Meditations" contentClassName={styles.sectionContent}>
<Skeleton className={clsx(styles.meditationSkeleton, styles.skeleton)} />
</Section>
)
}

View File

@ -25,4 +25,9 @@
align-items: flex-start;
gap: 15px;
}
}
.palmImage {
object-fit: cover;
object-position: center;
}

View File

@ -4,14 +4,22 @@ import Typography from "../ui/Typography/Typography"
import styles from "./PalmCard.module.scss"
import { IconName } from "../ui/Icon/Icon";
import MetaLabel from "../ui/MetaLabel/MetaLabel";
import { PalmAction } from "@/entities/dashboard/types";
export default function PalmCard() {
type PalmCardProps = PalmAction;
export default function PalmCard({
imageUrl,
title,
type,
minutes
}: PalmCardProps) {
return (
<Card className={styles.card}>
<div className={styles.image}>
<Image
className={styles.logo}
src="/palm-card.png"
className={styles.palmImage}
src={imageUrl}
alt="Palm image"
width={99}
height={123}
@ -20,7 +28,7 @@ export default function PalmCard() {
<div className={styles.content}>
<div className={styles.info}>
<Typography size="lg" align="left">
Код рождения в линиях
{title}
</Typography>
<MetaLabel iconLabelProps={{
iconProps: {
@ -31,9 +39,9 @@ export default function PalmCard() {
height: 25
}
},
children: <Typography color="secondary">Article</Typography>
children: <Typography color="secondary">{type}</Typography>
}}>
5 min
{minutes} min
</MetaLabel>
</div>
</div>

View File

@ -7,4 +7,8 @@
.grid {
padding-right: 16px;
}
.palmSkeleton.skeleton {
height: 227px;
}

View File

@ -1,24 +1,31 @@
import { PalmAction } from "@/entities/dashboard/types";
import PalmCard from "../PalmCard/PalmCard";
import Grid from "../ui/Grid/Grid"
import Section from "../ui/Section/Section"
import styles from "./PalmSection.module.scss"
import { use } from "react";
import Skeleton from "../ui/Skeleton/Skeleton";
import clsx from "clsx";
const palms = [
{ title: "Palm" },
{ title: "Palm" },
{ title: "Palm" },
{ title: "Palm" },
{ title: "Palm" },
];
export default function PalmSection({ promise }: { promise: Promise<PalmAction[]> }) {
const palms = use(promise);
const columns = palms?.length;
export default function PalmSection() {
return (
<Section title="Palm" contentClassName={styles.sectionContent}>
<Grid columns={5} className={styles.grid}>
{palms.map((palm, index) => (
<PalmCard key={index} {...palm} />
<Grid columns={columns} className={styles.grid}>
{palms.map((palm) => (
<PalmCard key={palm._id} {...palm} />
))}
</Grid>
</Section>
)
}
export function PalmSectionSkeleton() {
return (
<Section title="Palm" contentClassName={styles.sectionContent}>
<Skeleton className={clsx(styles.palmSkeleton, styles.skeleton)} />
</Section>
)
}

View File

@ -1,4 +1,4 @@
import { ReactNode } from "react";
import { CSSProperties, ReactNode } from "react";
import NotificationIcon from "./icons/Notification";
import clsx from "clsx";
import SearchIcon from "./icons/Search";
@ -6,6 +6,7 @@ import MenuIcon from "./icons/Menu";
import ArticleIcon from "./icons/Article";
import VideoIcon from "./icons/Video";
import ChevronIcon from "./icons/Chevron";
import StarIcon from "./icons/Star";
export enum IconName {
Notification,
@ -13,7 +14,8 @@ export enum IconName {
Menu,
Article,
Video,
Chevron
Chevron,
Star
};
const icons: Record<IconName, React.ComponentType<React.SVGProps<SVGSVGElement>>> = {
@ -23,6 +25,7 @@ const icons: Record<IconName, React.ComponentType<React.SVGProps<SVGSVGElement>>
[IconName.Article]: ArticleIcon,
[IconName.Video]: VideoIcon,
[IconName.Chevron]: ChevronIcon,
[IconName.Star]: StarIcon,
};
export type IconProps = {
@ -35,6 +38,8 @@ export type IconProps = {
className?: string;
children?: ReactNode;
cursor?: "pointer" | "auto"
iconStyle?: CSSProperties
style?: CSSProperties
};
export default function Icon({
@ -47,17 +52,22 @@ export default function Icon({
className,
children,
cursor = "pointer",
style,
...rest
}: IconProps) {
const Component = icons[name];
return (
<span style={{ position: "relative", display: "inline-block", cursor, width: size.width, height: size.height }} className={clsx(className)}>
<span style={{ position: "relative", display: "inline-block", cursor, width: size.width, height: size.height, ...style }} className={clsx(className)}>
<Component
width={size.width}
height={size.height}
color={color}
aria-hidden="true"
{...rest}
style={{
display: "block",
...rest.iconStyle
}}
/>
{children}
</span>

View File

@ -0,0 +1,8 @@
import { SVGProps } from "react";
export default function StarIcon(props: SVGProps<SVGSVGElement>) {
return <svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M4.57382 0.781219C4.49102 0.609363 4.31603 0.5 4.12387 0.5C3.9317 0.5 3.75828 0.609363 3.67392 0.781219L2.66934 2.84817L0.425835 3.17939C0.238356 3.20751 0.082123 3.33874 0.0243168 3.51841C-0.0334894 3.69808 0.0133805 3.8965 0.147741 4.02929L1.77569 5.64005L1.39135 7.91636C1.36011 8.10384 1.43822 8.29444 1.5929 8.40537C1.74757 8.51629 1.95223 8.53035 2.12096 8.4413L4.12543 7.37111L6.1299 8.4413C6.29863 8.53035 6.5033 8.51785 6.65797 8.40537C6.81264 8.29288 6.89075 8.10384 6.85951 7.91636L6.47361 5.64005L8.10156 4.02929C8.23592 3.8965 8.28435 3.69808 8.22498 3.51841C8.16561 3.33874 8.01094 3.20751 7.82346 3.17939L5.5784 2.84817L4.57382 0.781219Z" fill="currentColor" />
</svg>
}

View File

@ -0,0 +1,26 @@
.skeleton {
width: 100%;
height: 100%;
background-color: #e5e7eb;
border-radius: 24px;
position: relative;
overflow: hidden;
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.6) 50%,
transparent 100%);
transform: translateX(-100%);
animation: shimmer 1.6s infinite;
}
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}

View File

@ -0,0 +1,12 @@
import type { CSSProperties } from "react";
import clsx from "clsx";
import styles from "./Skeleton.module.scss";
interface SkeletonProps {
className?: string;
style?: CSSProperties;
}
export default function Skeleton({ className, style }: SkeletonProps) {
return <div className={clsx(styles.skeleton, className)} style={style} />;
}

View File

@ -0,0 +1,33 @@
import clsx from "clsx";
import styles from "./styles.module.scss";
import { CSSProperties } from "react";
export interface SpinnerProps {
size?: number | string;
color?: string;
thickness?: number;
className?: string;
style?: CSSProperties;
}
export default function Spinner({
size = 28,
color = "currentColor",
thickness = 3,
className,
style,
}: SpinnerProps) {
const resolvedSize = typeof size === "number" ? `${size}px` : size;
return (
<span
className={clsx(styles.spinner, className)}
style={{
"--spinner-size": resolvedSize,
"--spinner-color": color,
"--spinner-thickness": `${thickness}px`,
...style,
} as CSSProperties}
/>
);
}

View File

@ -0,0 +1,19 @@
.spinner {
--spinner-size: 32px;
--spinner-color: currentColor;
--spinner-thickness: 3px;
width: var(--spinner-size);
height: var(--spinner-size);
border: var(--spinner-thickness) solid var(--spinner-color);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,17 @@
.stars {
display: flex;
align-items: center;
gap: 3px;
}
.star {
position: relative;
display: inline-block;
line-height: 0;
}
.starFill {
position: absolute;
inset: 0;
overflow: hidden;
}

View File

@ -0,0 +1,48 @@
import Icon, { IconName } from "../Icon/Icon";
import clsx from "clsx";
import styles from "./Stars.module.scss";
interface StarsProps {
rating?: number;
size?: number;
className?: string;
}
export default function Stars({ rating = 5, size = 9, className }: StarsProps) {
const total = 5;
const fills = Array.from({ length: total }).map((_, i) => {
const diff = rating - i;
return Math.max(Math.min(diff, 1), 0) * 100;
});
return (
<div className={clsx(styles.stars, className)}>
{fills.map((fill, i) => (
<span
key={i}
className={styles.star}
style={{ width: size, height: size }}
>
<Icon
name={IconName.Star}
size={{ width: size, height: size }}
color="#E5E7EB"
cursor="auto"
/>
{fill > 0 && (
<span className={styles.starFill} style={{ width: `${fill}%` }}>
<Icon
name={IconName.Star}
size={{ width: size, height: size }}
color="#FFD600"
cursor="auto"
/>
</span>
)}
</span>
))}
</div>
);
}

View File

@ -0,0 +1,12 @@
import { http } from "@/shared/api/httpClient";
import { DashboardSchema, DashboardData } from "./types";
import { delay } from "@/shared/utils/delay";
export const getDashboard = async () => {
await delay(3_000);
return http.get<DashboardData>("/dashboard", {
tags: ["dashboard"],
schema: DashboardSchema,
});
}

View File

@ -0,0 +1,17 @@
import { cache } from "react";
import { getDashboard } from "./api";
export const loadDashboard = cache(getDashboard);
export const loadAssistants = cache(() =>
loadDashboard().then(d => d.assistants)
);
export const loadCompatibility = cache(() =>
loadDashboard().then(d => d.compatibilityActions)
);
export const loadMeditations = cache(() =>
loadDashboard().then(d => d.meditations)
);
export const loadPalms = cache(() =>
loadDashboard().then(d => d.palmActions)
);

View File

@ -0,0 +1,83 @@
import { z } from "zod";
/* ---------- Assistant ---------- */
export const AssistantSchema = z.object({
_id: z.string(),
name: z.string(),
description: z.string(),
rating: z.number(),
reviewCount: z.number(),
experience: z.number(),
readings: z.number(),
category: z.string(),
price: z.number(),
gender: z.enum(["male", "female"]),
photoUrl: z.string().url(),
externalId: z.string(),
clientSource: z.string(),
createdAt: z.string(), // ISO-строка даты
updatedAt: z.string(),
});
export type Assistant = z.infer<typeof AssistantSchema>;
/* ---------- Field (для compatibilityActions) ---------- */
export const FieldSchema = z.object({
_id: z.string(),
actionId: z.string(),
key: z.string(),
title: z.string(),
inputType: z.string(), // text | date | time …
model: z.string().optional(), // присутствует не всегда
property: z.string().optional(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Field = z.infer<typeof FieldSchema>;
/* ---------- CompatibilityAction ---------- */
export const CompatibilityActionSchema = z.object({
_id: z.string(),
title: z.string(),
minutes: z.number(),
type: z.string(),
imageUrl: z.string().url(),
prompt: z.string(),
fields: z.array(FieldSchema),
createdAt: z.string(),
updatedAt: z.string(),
});
export type CompatibilityAction = z.infer<typeof CompatibilityActionSchema>;
/* ---------- PalmAction ---------- */
export const PalmActionSchema = z.object({
_id: z.string(),
title: z.string(),
minutes: z.number(),
type: z.string(),
imageUrl: z.string().url(),
prompt: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type PalmAction = z.infer<typeof PalmActionSchema>;
/* ---------- Meditation ---------- */
export const MeditationSchema = z.object({
_id: z.string(),
title: z.string(),
minutes: z.number(),
type: z.string(),
imageUrl: z.string().url(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Meditation = z.infer<typeof MeditationSchema>;
/* ---------- Итоговый ответ /dashboard ---------- */
export const DashboardSchema = z.object({
assistants: z.array(AssistantSchema),
compatibilityActions: z.array(CompatibilityActionSchema),
palmActions: z.array(PalmActionSchema),
meditations: z.array(MeditationSchema),
});
export type DashboardData = z.infer<typeof DashboardSchema>;

View File

@ -0,0 +1,61 @@
import { z } from "zod";
export class ApiError extends Error {
constructor(
public status: number,
public data: unknown,
message = "API Error"
) {
super(message);
this.name = "ApiError";
}
}
type RequestOpts = Omit<RequestInit, "method" | "body"> & {
tags?: string[]; // next.js cache-tag
query?: Record<string, unknown>; // query-string
schema?: z.ZodTypeAny; // runtime validation
};
class HttpClient {
constructor(private baseUrl: string) { }
private buildUrl(path: string, query?: Record<string, unknown>) {
const url = new URL(path, this.baseUrl);
if (query)
Object.entries(query).forEach(([k, v]) =>
url.searchParams.append(k, String(v))
);
return url.toString();
}
private async request<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
opts: RequestOpts = {},
body?: unknown
): Promise<T> {
const { tags = [], schema, query, ...rest } = opts;
const res = await fetch(this.buildUrl(path, query), {
method,
body: body ? JSON.stringify(body) : undefined,
headers: { "Content-Type": "application/json" },
next: { revalidate: 3600, tags },
...rest,
});
const payload = await res.json().catch(() => null);
if (!res.ok) throw new ApiError(res.status, payload);
const data = payload as T;
return schema ? schema.parse(data) : data;
}
get = <T>(p: string, o?: RequestOpts) => this.request<T>("GET", p, o);
post = <T>(p: string, b: unknown, o?: RequestOpts) => this.request<T>("POST", p, o, b);
patch = <T>(p: string, b: unknown, o?: RequestOpts) => this.request<T>("PATCH", p, o, b);
delete = <T>(p: string, o?: RequestOpts) => this.request<T>("DELETE", p, o);
}
export const http = new HttpClient(process.env.NEXT_PUBLIC_API_URL!);

View File

@ -0,0 +1,2 @@
export const delay = (ms: number) =>
new Promise<void>((r) => setTimeout(r, ms));