feat: connect UI texts, address CRUD, OTP autofocus and profile to backend

This commit is contained in:
پارسا آقایی 2026-05-26 21:39:14 +03:30
parent 4e2c8d5cfe
commit 467532713c
82 changed files with 14436 additions and 274 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
.env
.git
.dockerignore
Dockerfile
backend

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
# Build stage
FROM node:22-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

6
backend/.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.env
.git
.dockerignore
Dockerfile

4
backend/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

38
backend/Dockerfile Normal file
View File

@ -0,0 +1,38 @@
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies (including devDependencies)
RUN npm ci
# Copy the rest of the application
COPY . .
# Build the application (compiles TypeScript to dist folder)
# We also generate prisma client here if prisma schema is present.
RUN npm run build
# Production image
FROM node:22-alpine
WORKDIR /app
# Copy only package files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production
# Copy built artifacts from the builder stage
COPY --from=builder /app/dist ./dist
# If we have prisma later, we need to copy generated client:
# COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
EXPOSE 3000
# Start the application
CMD ["node", "dist/main"]

98
backend/README.md Normal file
View File

@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

35
backend/eslint.config.mjs Normal file
View File

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
backend/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

10608
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

88
backend/package.json Normal file
View File

@ -0,0 +1,88 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.4",
"@prisma/client": "^5.22.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"ioredis": "^5.11.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.12.4",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}

View File

@ -0,0 +1,260 @@
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"first_name" VARCHAR(100) NOT NULL,
"last_name" VARCHAR(100) NOT NULL,
"email" VARCHAR(150) NOT NULL,
"mobile" VARCHAR(15),
"role" VARCHAR(30) NOT NULL DEFAULT 'User_PetOwner',
"wallet_balance" DECIMAL(15,2) NOT NULL DEFAULT 0.00,
"charity_donation_total" DECIMAL(15,2) NOT NULL DEFAULT 0.00,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_addresses" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"title" VARCHAR(100) NOT NULL,
"receptor_name" VARCHAR(150) NOT NULL,
"phone" VARCHAR(15) NOT NULL,
"province" VARCHAR(100) NOT NULL,
"city" VARCHAR(100) NOT NULL,
"detail" TEXT NOT NULL,
"zip_code" VARCHAR(10) NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_addresses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wallet_transactions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"amount" DECIMAL(15,2) NOT NULL,
"type" VARCHAR(20) NOT NULL,
"status" VARCHAR(20) NOT NULL,
"transaction_reference" VARCHAR(100),
"description" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wallet_transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "products" (
"id" UUID NOT NULL,
"art_no" VARCHAR(20) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"scientific_tagline" VARCHAR(250),
"description" TEXT NOT NULL,
"short_description" TEXT,
"category" VARCHAR(100) NOT NULL,
"category_slug" VARCHAR(100) NOT NULL,
"price_value" DECIMAL(15,2) NOT NULL,
"price_display" VARCHAR(50) NOT NULL,
"unit" VARCHAR(50) NOT NULL,
"package_size" DECIMAL(10,2) NOT NULL,
"dosage_logic" TEXT,
"suitable_for" VARCHAR(15) NOT NULL,
"image_url" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_ingredients" (
"product_id" UUID NOT NULL,
"ingredient" VARCHAR(150) NOT NULL,
CONSTRAINT "product_ingredients_pkey" PRIMARY KEY ("product_id","ingredient")
);
-- CreateTable
CREATE TABLE "product_symptoms" (
"product_id" UUID NOT NULL,
"symptom" VARCHAR(150) NOT NULL,
CONSTRAINT "product_symptoms_pkey" PRIMARY KEY ("product_id","symptom")
);
-- CreateTable
CREATE TABLE "pets" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"type" VARCHAR(10) NOT NULL,
"breed" VARCHAR(100) NOT NULL,
"age" INTEGER NOT NULL,
"weight" DECIMAL(5,2) NOT NULL,
"activity_level" VARCHAR(15) NOT NULL,
"image_url" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "pets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pet_medical_conditions" (
"pet_id" UUID NOT NULL,
"condition" VARCHAR(150) NOT NULL,
CONSTRAINT "pet_medical_conditions_pkey" PRIMARY KEY ("pet_id","condition")
);
-- CreateTable
CREATE TABLE "reminders" (
"id" UUID NOT NULL,
"pet_id" UUID NOT NULL,
"product_id" UUID,
"title" VARCHAR(150) NOT NULL,
"time" VARCHAR(5) NOT NULL,
"frequency" VARCHAR(20) NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reminders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "reminder_completions" (
"id" UUID NOT NULL,
"reminder_id" UUID NOT NULL,
"completed_date" DATE NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reminder_completions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "health_logs" (
"id" UUID NOT NULL,
"pet_id" UUID NOT NULL,
"appetite" VARCHAR(15) NOT NULL,
"energy" VARCHAR(15) NOT NULL,
"digestion" VARCHAR(15) NOT NULL,
"note" TEXT,
"logged_date" DATE NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "health_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "coupons" (
"id" UUID NOT NULL,
"code" VARCHAR(50) NOT NULL,
"discount_value" DECIMAL(15,2) NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "coupons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "orders" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"coupon_id" UUID,
"total_amount" DECIMAL(15,2) NOT NULL,
"charity_donation" DECIMAL(15,2) NOT NULL DEFAULT 0.00,
"status" VARCHAR(30) NOT NULL DEFAULT 'processing',
"tracking_number" VARCHAR(100),
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "order_items" (
"id" UUID NOT NULL,
"order_id" UUID NOT NULL,
"product_id" UUID,
"quantity" INTEGER NOT NULL,
"dose_qty" DECIMAL(10,2),
"dose_unit" VARCHAR(50),
CONSTRAINT "order_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_mobile_key" ON "users"("mobile");
-- CreateIndex
CREATE INDEX "user_addresses_user_id_idx" ON "user_addresses"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "wallet_transactions_transaction_reference_key" ON "wallet_transactions"("transaction_reference");
-- CreateIndex
CREATE UNIQUE INDEX "products_art_no_key" ON "products"("art_no");
-- CreateIndex
CREATE INDEX "products_category_slug_idx" ON "products"("category_slug");
-- CreateIndex
CREATE INDEX "products_suitable_for_idx" ON "products"("suitable_for");
-- CreateIndex
CREATE INDEX "reminders_pet_id_idx" ON "reminders"("pet_id");
-- CreateIndex
CREATE INDEX "reminder_completions_reminder_id_completed_date_idx" ON "reminder_completions"("reminder_id", "completed_date");
-- CreateIndex
CREATE UNIQUE INDEX "reminder_completions_reminder_id_completed_date_key" ON "reminder_completions"("reminder_id", "completed_date");
-- CreateIndex
CREATE UNIQUE INDEX "coupons_code_key" ON "coupons"("code");
-- CreateIndex
CREATE UNIQUE INDEX "orders_tracking_number_key" ON "orders"("tracking_number");
-- AddForeignKey
ALTER TABLE "user_addresses" ADD CONSTRAINT "user_addresses_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wallet_transactions" ADD CONSTRAINT "wallet_transactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_ingredients" ADD CONSTRAINT "product_ingredients_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_symptoms" ADD CONSTRAINT "product_symptoms_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pets" ADD CONSTRAINT "pets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pet_medical_conditions" ADD CONSTRAINT "pet_medical_conditions_pet_id_fkey" FOREIGN KEY ("pet_id") REFERENCES "pets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_pet_id_fkey" FOREIGN KEY ("pet_id") REFERENCES "pets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reminder_completions" ADD CONSTRAINT "reminder_completions_reminder_id_fkey" FOREIGN KEY ("reminder_id") REFERENCES "reminders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "health_logs" ADD CONSTRAINT "health_logs_pet_id_fkey" FOREIGN KEY ("pet_id") REFERENCES "pets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_coupon_id_fkey" FOREIGN KEY ("coupon_id") REFERENCES "coupons"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "ui_texts" (
"key" VARCHAR(100) NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "ui_texts_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "scientific_terms" (
"key" VARCHAR(100) NOT NULL,
"term" VARCHAR(150) NOT NULL,
"definition" TEXT NOT NULL,
"wiki_id" VARCHAR(50) NOT NULL,
CONSTRAINT "scientific_terms_pkey" PRIMARY KEY ("key")
);

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -0,0 +1,242 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid()) @db.Uuid
firstName String @map("first_name") @db.VarChar(100)
lastName String @map("last_name") @db.VarChar(100)
email String @unique @db.VarChar(150)
mobile String? @unique @db.VarChar(15)
role String @default("User_PetOwner") @db.VarChar(30)
walletBalance Decimal @default(0.00) @map("wallet_balance") @db.Decimal(15, 2)
charityDonationTotal Decimal @default(0.00) @map("charity_donation_total") @db.Decimal(15, 2)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz()
addresses UserAddress[]
walletTransactions WalletTransaction[]
pets Pet[]
orders Order[]
@@map("users")
}
model UserAddress {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
title String @db.VarChar(100)
receptorName String @map("receptor_name") @db.VarChar(150)
phone String @db.VarChar(15)
province String @db.VarChar(100)
city String @db.VarChar(100)
detail String @db.Text
zipCode String @map("zip_code") @db.VarChar(10)
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("user_addresses")
}
model WalletTransaction {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
amount Decimal @db.Decimal(15, 2)
type String @db.VarChar(20) // deposit, withdrawal
status String @db.VarChar(20) // pending, completed, failed
transactionReference String? @unique @map("transaction_reference") @db.VarChar(100)
description String? @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("wallet_transactions")
}
model Product {
id String @id @default(uuid()) @db.Uuid
artNo String @unique @map("art_no") @db.VarChar(20)
name String @db.VarChar(200)
scientificTagline String? @map("scientific_tagline") @db.VarChar(250)
description String @db.Text
shortDescription String? @map("short_description") @db.Text
category String @db.VarChar(100)
categorySlug String @map("category_slug") @db.VarChar(100)
priceValue Decimal @map("price_value") @db.Decimal(15, 2)
priceDisplay String @map("price_display") @db.VarChar(50)
unit String @db.VarChar(50)
packageSize Decimal @map("package_size") @db.Decimal(10, 2)
dosageLogic String? @map("dosage_logic") @db.Text
suitableFor String @map("suitable_for") @db.VarChar(15) // سگ, گربه, هر دو
imageUrl String @map("image_url") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
ingredients ProductIngredient[]
symptoms ProductSymptom[]
reminders Reminder[]
orderItems OrderItem[]
@@index([categorySlug])
@@index([suitableFor])
@@map("products")
}
model ProductIngredient {
productId String @map("product_id") @db.Uuid
ingredient String @db.VarChar(150)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@id([productId, ingredient])
@@map("product_ingredients")
}
model ProductSymptom {
productId String @map("product_id") @db.Uuid
symptom String @db.VarChar(150)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@id([productId, symptom])
@@map("product_symptoms")
}
model Pet {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
name String @db.VarChar(100)
type String @db.VarChar(10) // سگ, گربه
breed String @db.VarChar(100)
age Int
weight Decimal @db.Decimal(5, 2)
activityLevel String @map("activity_level") @db.VarChar(15) // کم, متوسط, زیاد
imageUrl String? @map("image_url") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medicalConditions PetMedicalCondition[]
reminders Reminder[]
healthLogs HealthLog[]
@@map("pets")
}
model PetMedicalCondition {
petId String @map("pet_id") @db.Uuid
condition String @db.VarChar(150)
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
@@id([petId, condition])
@@map("pet_medical_conditions")
}
model Reminder {
id String @id @default(uuid()) @db.Uuid
petId String @map("pet_id") @db.Uuid
productId String? @map("product_id") @db.Uuid
title String @db.VarChar(150)
time String @db.VarChar(5) // 08:30
frequency String @db.VarChar(20) // روزانه, هفتگی
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
completions ReminderCompletion[]
@@index([petId])
@@map("reminders")
}
model ReminderCompletion {
id String @id @default(uuid()) @db.Uuid
reminderId String @map("reminder_id") @db.Uuid
completedDate DateTime @map("completed_date") @db.Date
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
reminder Reminder @relation(fields: [reminderId], references: [id], onDelete: Cascade)
@@unique([reminderId, completedDate])
@@index([reminderId, completedDate])
@@map("reminder_completions")
}
model HealthLog {
id String @id @default(uuid()) @db.Uuid
petId String @map("pet_id") @db.Uuid
appetite String @db.VarChar(15)
energy String @db.VarChar(15)
digestion String @db.VarChar(15)
note String? @db.Text
loggedDate DateTime @default(now()) @map("logged_date") @db.Date
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
@@map("health_logs")
}
model Coupon {
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(50)
discountValue Decimal @map("discount_value") @db.Decimal(15, 2)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
orders Order[]
@@map("coupons")
}
model Order {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
couponId String? @map("coupon_id") @db.Uuid
totalAmount Decimal @map("total_amount") @db.Decimal(15, 2)
charityDonation Decimal @default(0.00) @map("charity_donation") @db.Decimal(15, 2)
status String @default("processing") @db.VarChar(30) // processing, shipped, delivered
trackingNumber String? @unique @map("tracking_number") @db.VarChar(100)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
coupon Coupon? @relation(fields: [couponId], references: [id])
orderItems OrderItem[]
@@map("orders")
}
model OrderItem {
id String @id @default(uuid()) @db.Uuid
orderId String @map("order_id") @db.Uuid
productId String? @map("product_id") @db.Uuid
quantity Int
doseQty Decimal? @map("dose_qty") @db.Decimal(10, 2)
doseUnit String? @map("dose_unit") @db.VarChar(50)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@map("order_items")
}
model UiText {
key String @id @db.VarChar(100)
value String @db.Text
@@map("ui_texts")
}
model ScientificTerm {
key String @id @db.VarChar(100)
term String @db.VarChar(150)
definition String @db.Text
wikiId String @map("wiki_id") @db.VarChar(50)
@@map("scientific_terms")
}

212
backend/prisma/seed.ts Normal file
View File

@ -0,0 +1,212 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import * as vm from 'vm';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database with products...');
const productsFilePath = path.join(__dirname, '..', '..', 'src', 'data', 'products.ts');
const fileContent = fs.readFileSync(productsFilePath, 'utf-8');
const jsCode = ts.transpile(fileContent, {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2018,
});
const context = { exports: {} };
vm.createContext(context);
vm.runInContext(jsCode, context);
const PRODUCTS = (context.exports as any).PRODUCTS;
if (!PRODUCTS || !Array.isArray(PRODUCTS)) {
console.error('Could not load PRODUCTS from src/data/products.ts');
process.exit(1);
}
for (const productData of PRODUCTS) {
const p = await prisma.product.upsert({
where: { artNo: productData.artNo },
update: {},
create: {
artNo: productData.artNo,
name: productData.name,
scientificTagline: productData.scientificTagline,
description: productData.description,
shortDescription: productData.shortDescription || '',
category: productData.category,
categorySlug: productData.categorySlug || 'general',
priceValue: productData.priceValue,
priceDisplay: productData.price,
unit: productData.unit,
packageSize: productData.packageSize,
dosageLogic: productData.dosage_logic,
suitableFor: productData.suitableFor,
imageUrl: productData.image,
}
});
if (productData.main_ingredients) {
for (const ing of productData.main_ingredients) {
await prisma.productIngredient.upsert({
where: { productId_ingredient: { productId: p.id, ingredient: ing } },
update: {},
create: { productId: p.id, ingredient: ing }
});
}
}
if (productData.symptoms) {
for (const sym of productData.symptoms) {
await prisma.productSymptom.upsert({
where: { productId_symptom: { productId: p.id, symptom: sym } },
update: {},
create: { productId: p.id, symptom: sym }
});
}
}
console.log(`Seeded product: ${p.name}`);
}
// 1. Seed UI Texts
console.log('Seeding UI Texts...');
const uiTexts = {
hero_badge: "تخصص دارویی از آلمان",
hero_title: "تخصص آلمانی در خدمت\nسلامت پت‌های خانگی",
hero_desc: "بیش از ۴۰ سال تجربه نوآورانه در تولید مکمل‌های درمانی با بالاترین استاندارد کیفی «گرید دارویی اختصاصی». راهکار هوشمند برای هر نیاز بالینی.",
hero_btn_advisor: "دستیار سلامت پت",
hero_btn_products: "مشاهده محصولات",
hero_stat_founded: "۱۹۸۴ سال تأسیس",
hero_stat_agencies: "نمایندگی فعال",
hero_stat_german_formula: "فرمول آلمانی",
hero_image_badge: "سرآمد علمی در پزشکی پت‌ها",
hero_image_title: "مکمل‌های تایید شده دامپزشکی",
hero_quality_standard: "استاندارد کیفی آلمان",
cta_title: "می‌خواهید بدانید کدام محصول برای پت شما مناسب‌تر است؟",
cta_subtitle: "تیم متخصص دامپزشکی کانینا ایران آماده پاسخگویی به سوالات شماست.",
cta_btn_free: "دریافت رژیم مکمل رایگان",
cta_btn_products: "ورود به محصولات تخصصی",
about_teaser_badge: "میراث ما از آلمان",
about_teaser_title: "چرا برند آلمانی Canina مرجع دامپزشکان است؟",
about_teaser_feat1_title: "مواد اولیه نایاب",
about_teaser_feat1_desc: "استفاده از پودر صدف لب‌سبز اصل نیوزیلند و مواد ارگانیک با گرید دارویی.",
about_teaser_feat2_title: "فاقد مواد نگهدارنده",
about_teaser_feat2_desc: "تمامی محصولات ۱۰۰٪ طبیعی و فاقد رنگ‌های مصنوعی و طعم‌دهنده‌های شیمیایی هستند.",
about_teaser_feat3_title: "تاییدیه اروپا",
about_teaser_feat3_desc: "مطابق با سخت‌گیرانه‌ترین استانداردهای ایمنی مواد غذایی و دارویی در اتحادیه اروپا.",
about_teaser_quality_title: "کیفیت",
about_teaser_quality_desc: "استانداردهای فوق‌دارویی",
ingredients_wiki: JSON.stringify([
{
id: "green-mussel",
name: "صدف لب‌سبز (Perna Canaliculus)",
description: "این صدف بومی سواحل بکر نیوزیلند است و تنها منبع طبیعی حاوی گلیکوزآمین‌گلیکان‌ها (GAG) در غلظت بسیار بالا محسوب می‌شود.",
benefits: [
"بازسازی بافت غضروفی تخریب شده",
"خواص ضدالتهابی طبیعی برای رباط‌ها",
"تامین اسیدهای چرب امگا ۳ خاص"
]
},
{
id: "silver",
name: "نقره میکروسیلور (Microsilver)",
description: "ذرات نقره خالص با ساختار اسفنجی که به دلیل اندازه ذرات درشت، جذب خون نمی‌شوند اما اثر آنتی‌باکتریال پایداری بر سطح غشاها دارند.",
benefits: [
"مبارزه با باکتری‌های مقاوم در دهان و پوست",
"عدم آسیب به فلور طبیعی بدن",
"جایگزین ایمن برای آنتی‌بیوتیک‌های موضعی"
]
},
{
id: "colostrum",
name: "آغوز (Colostrum)",
description: "اولین شیر مادر که حاوی دوز بالایی از ایمونوگلوبولین‌ها، ویتامین‌ها و مواد معدنی برای فعال‌سازی فوری سیستم ایمنی است.",
benefits: [
"تقویت سد دفاعی روده",
"انتقال مستقیم آنتی‌بادی به نوزادان",
"تسریع نقاهت بعد از جرافی"
]
},
{
id: "peat-extract",
name: "عصاره پیت (Peat Extract)",
description: "ماده‌ای کاملا طبیعی حاصل از تجزیه گیاهان در طول هزاران سال که غنی از اسیدهای هومیک و مواد معدنی است.",
benefits: [
"جذب سموم در دستگاه گوارش",
"تنظیم فلور روده",
"بهبود اشتها و هضم"
]
}
]),
medical_options: JSON.stringify([
{ id: "joint_surgery", label: "جراحی مفاصل", condition: "جراحی مفاصل" },
{ id: "recent_birth", label: "زایمان اخیر", condition: "زایمان اخیر" },
{ id: "pregnancy", label: "بارداری", condition: "بارداری" },
{ id: "digestion", label: "مشکلات گوارشی", condition: "مشکلات گوارشی" },
{ id: "hair_loss", label: "ریزش موی شدید", condition: "ریزش موی شدید" },
{ id: "appetite", label: "بی‌اشتهایی", condition: "بی‌اشتهایی" }
])
};
for (const [key, value] of Object.entries(uiTexts)) {
await prisma.uiText.upsert({
where: { key },
update: { value },
create: { key, value }
});
}
// 2. Seed Scientific Terms
console.log('Seeding Scientific Terms...');
const termsFilePath = path.join(__dirname, '..', '..', 'src', 'data', 'scientificTerms.ts');
if (fs.existsSync(termsFilePath)) {
const termsContent = fs.readFileSync(termsFilePath, 'utf-8');
const termsJsCode = ts.transpile(termsContent, {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2018,
});
const termsContext = { exports: {} };
vm.createContext(termsContext);
vm.runInContext(termsJsCode, termsContext);
const SCIENTIFIC_TERMS = (termsContext.exports as any).SCIENTIFIC_TERMS;
if (SCIENTIFIC_TERMS) {
for (const [key, termData] of Object.entries(SCIENTIFIC_TERMS)) {
const td = termData as any;
await prisma.scientificTerm.upsert({
where: { key },
update: {
term: td.term,
definition: td.definition,
wikiId: td.wikiId || 'general'
},
create: {
key,
term: td.term,
definition: td.definition,
wikiId: td.wikiId || 'general'
}
});
}
}
}
console.log('Seeding completed successfully!');
}
main()
.catch((e) => {
console.error('Error seeding data:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

16
backend/src/app.module.ts Normal file
View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { ProductsModule } from './products/products.module';
import { UsersModule } from './users/users.module';
import { RedisModule } from './redis/redis.module';
import { AuthModule } from './auth/auth.module';
import { PetsModule } from './pets/pets.module';
import { OrdersModule } from './orders/orders.module';
import { SettingsModule } from './settings/settings.module';
@Module({
imports: [PrismaModule, RedisModule, ProductsModule, UsersModule, AuthModule, PetsModule, OrdersModule, SettingsModule],
controllers: [],
providers: [],
})
export class AppModule {}

View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,27 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
import { SendOtpDto } from './dto/send-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('send-otp')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'ارسال کد تایید پیامکی' })
@ApiResponse({ status: 200, description: 'کد با موفقیت ارسال شد' })
sendOtp(@Body() sendOtpDto: SendOtpDto) {
return this.authService.sendOtp(sendOtpDto);
}
@Post('verify-otp')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'تایید کد پیامکی و ورود/ثبت‌نام' })
@ApiResponse({ status: 200, description: 'ورود موفق به همراه توکن' })
verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) {
return this.authService.verifyOtp(verifyOtpDto);
}
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'super-secret-key',
signOptions: { expiresIn: '7d' },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@ -0,0 +1,66 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import { SendOtpDto } from './dto/send-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private redisService: RedisService,
) {}
async sendOtp(sendOtpDto: SendOtpDto) {
const { phoneNumber } = sendOtpDto;
const code = Math.floor(10000 + Math.random() * 90000).toString();
console.log(`[Mock SMS] Sending OTP ${code} to ${phoneNumber}`);
await this.redisService.set(`otp:${phoneNumber}`, code, 120);
return { success: true, message: 'کد تایید ارسال شد', code };
}
async verifyOtp(verifyOtpDto: VerifyOtpDto) {
const { phoneNumber, code } = verifyOtpDto;
const savedCode = await this.redisService.get(`otp:${phoneNumber}`);
if (!savedCode) {
throw new BadRequestException({ message: 'کد تایید منقضی شده است', error: 'OTP_EXPIRED' });
}
if (savedCode !== code) {
throw new BadRequestException({ message: 'کد تایید اشتباه است', error: 'OTP_INVALID' });
}
await this.redisService.del(`otp:${phoneNumber}`);
let user = await this.prisma.user.findUnique({ where: { mobile: phoneNumber } });
if (!user) {
user = await this.prisma.user.create({
data: {
mobile: phoneNumber,
firstName: 'کاربر',
lastName: 'جدید',
email: `${phoneNumber}@temp.local`,
},
});
}
const payload = { sub: user.id, phoneNumber: user.mobile };
const accessToken = this.jwtService.sign(payload);
return {
success: true,
data: {
user,
accessToken,
},
};
}
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
export class SendOtpDto {
@ApiProperty({ description: 'شماره موبایل کاربر', example: '09123456789' })
@IsNotEmpty()
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل نامعتبر است' })
phoneNumber: string;
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class VerifyOtpDto {
@ApiProperty({ description: 'شماره موبایل کاربر', example: '09123456789' })
@IsNotEmpty()
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل نامعتبر است' })
phoneNumber: string;
@ApiProperty({ description: 'کد تایید پیامک شده', example: '12345' })
@IsNotEmpty()
@IsString()
@Length(5, 5, { message: 'کد تایید باید ۵ رقم باشد' })
code: string;
}

View File

@ -0,0 +1,12 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید');
}
return user;
}
}

View File

@ -0,0 +1,23 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'super-secret-key',
});
}
async validate(payload: any) {
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('کاربر یافت نشد');
}
return user;
}
}

View File

@ -0,0 +1,19 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const exceptionResponse: any = exception.getResponse();
response.status(status).json({
success: false,
message: typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse.message || 'خطای سرور'),
code: exceptionResponse.error || (status === 400 ? 'BAD_REQUEST' : 'ERROR'),
details: typeof exceptionResponse === 'object' ? exceptionResponse : {}
});
}
}

28
backend/src/main.ts Normal file
View File

@ -0,0 +1,28 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.enableCors();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter());
const config = new DocumentBuilder()
.setTitle('Canina Iran API')
.setDescription('API Documentation for Canina Iran Pet Health & Supplement Platform')
.setVersion('1.0.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@ -0,0 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsArray, ValidateNested, IsNumber, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
class OrderItemDto {
@ApiProperty({ description: 'ID محصول' })
@IsNotEmpty()
@IsString()
productId: string;
@ApiProperty({ description: 'تعداد محصول' })
@IsNotEmpty()
@IsNumber()
quantity: number;
}
export class CreateOrderDto {
@ApiPropertyOptional({ description: 'ID حیوان خانگی مرتبط' })
@IsOptional()
@IsString()
petId?: string;
@ApiProperty({ description: 'لیست اقلام سفارش', type: [OrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}

View File

@ -0,0 +1,31 @@
import { Controller, Get, Post, Body, Param, UseGuards, Req } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
@ApiTags('Orders')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
@ApiOperation({ summary: 'ثبت سفارش جدید' })
create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) {
return this.ordersService.create(req.user.id, createOrderDto);
}
@Get()
@ApiOperation({ summary: 'لیست سفارش‌های کاربر' })
findAll(@Req() req: any) {
return this.ordersService.findAllByUser(req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'جزئیات یک سفارش' })
findOne(@Req() req: any, @Param('id') id: string) {
return this.ordersService.findOne(id, req.user.id);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';
@Module({
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}

View File

@ -0,0 +1,69 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto';
@Injectable()
export class OrdersService {
constructor(private prisma: PrismaService) {}
async create(userId: string, createOrderDto: CreateOrderDto) {
let totalAmount = 0;
const orderItems = [];
for (const item of createOrderDto.items) {
const product = await this.prisma.product.findUnique({ where: { id: item.productId } });
if (!product) {
throw new NotFoundException(`محصول با شناسه ${item.productId} یافت نشد`);
}
totalAmount += Number(product.priceValue) * item.quantity;
orderItems.push({
productId: product.id,
quantity: item.quantity,
});
}
if (orderItems.length === 0) {
throw new BadRequestException('سبد خرید خالی است');
}
return this.prisma.order.create({
data: {
userId,
totalAmount,
status: 'processing',
orderItems: {
create: orderItems,
},
},
include: {
orderItems: {
include: { product: true }
}
}
});
}
async findAllByUser(userId: string) {
return this.prisma.order.findMany({
where: { userId },
include: {
orderItems: { include: { product: true } },
},
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string, userId: string) {
const order = await this.prisma.order.findFirst({
where: { id, userId },
include: {
orderItems: { include: { product: true } },
},
});
if (!order) {
throw new NotFoundException('سفارش یافت نشد');
}
return order;
}
}

View File

@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsOptional, IsNumber, IsBoolean, IsDateString } from 'class-validator';
export class CreatePetDto {
@ApiProperty({ description: 'نام حیوان خانگی', example: 'بادی' })
@IsNotEmpty()
@IsString()
name: string;
@ApiProperty({ description: 'نوع (سگ/گربه)', example: 'سگ' })
@IsNotEmpty()
@IsString()
type: string;
@ApiPropertyOptional({ description: 'نژاد', example: 'ژرمن شپرد' })
@IsOptional()
@IsString()
breed?: string;
@ApiPropertyOptional({ description: 'جنسیت', example: 'نر' })
@IsOptional()
@IsString()
gender?: string;
@ApiPropertyOptional({ description: 'وزن (کیلوگرم)', example: 25.5 })
@IsOptional()
@IsNumber()
weight?: number;
@ApiPropertyOptional({ description: 'تاریخ تولد' })
@IsOptional()
@IsDateString()
birthDate?: string;
@ApiPropertyOptional({ description: 'عقیم شده است؟', example: false })
@IsOptional()
@IsBoolean()
isNeutered?: boolean;
@ApiPropertyOptional({ description: 'سوابق پزشکی مختصر' })
@IsOptional()
@IsString()
medicalHistory?: string;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePetDto } from './create-pet.dto';
export class UpdatePetDto extends PartialType(CreatePetDto) {}

View File

@ -0,0 +1,44 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Req } from '@nestjs/common';
import { PetsService } from './pets.service';
import { CreatePetDto } from './dto/create-pet.dto';
import { UpdatePetDto } from './dto/update-pet.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
@ApiTags('Pets')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('pets')
export class PetsController {
constructor(private readonly petsService: PetsService) {}
@Post()
@ApiOperation({ summary: 'ثبت حیوان خانگی جدید' })
create(@Req() req: any, @Body() createPetDto: CreatePetDto) {
return this.petsService.create(req.user.id, createPetDto);
}
@Get()
@ApiOperation({ summary: 'لیست حیوانات خانگی کاربر' })
findAll(@Req() req: any) {
return this.petsService.findAllByUser(req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'دریافت جزئیات یک حیوان خانگی' })
findOne(@Req() req: any, @Param('id') id: string) {
return this.petsService.findOne(id, req.user.id);
}
@Patch(':id')
@ApiOperation({ summary: 'بروزرسانی اطلاعات حیوان خانگی' })
update(@Req() req: any, @Param('id') id: string, @Body() updatePetDto: UpdatePetDto) {
return this.petsService.update(id, req.user.id, updatePetDto);
}
@Delete(':id')
@ApiOperation({ summary: 'حذف حیوان خانگی' })
remove(@Req() req: any, @Param('id') id: string) {
return this.petsService.remove(id, req.user.id);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PetsService } from './pets.service';
import { PetsController } from './pets.controller';
@Module({
controllers: [PetsController],
providers: [PetsService],
})
export class PetsModule {}

View File

@ -0,0 +1,61 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePetDto } from './dto/create-pet.dto';
import { UpdatePetDto } from './dto/update-pet.dto';
@Injectable()
export class PetsService {
constructor(private prisma: PrismaService) {}
async create(userId: string, createPetDto: CreatePetDto) {
return this.prisma.pet.create({
data: {
name: createPetDto.name,
type: createPetDto.type,
breed: createPetDto.breed || '',
activityLevel: 'متوسط', // Default
age: 1, // Default
weight: createPetDto.weight || 0,
userId,
},
});
}
async findAllByUser(userId: string) {
return this.prisma.pet.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string, userId: string) {
const pet = await this.prisma.pet.findFirst({
where: { id, userId },
include: { reminders: true, healthLogs: true },
});
if (!pet) {
throw new NotFoundException('حیوان خانگی یافت نشد');
}
return pet;
}
async update(id: string, userId: string, updatePetDto: UpdatePetDto) {
await this.findOne(id, userId); // Ensure it exists and belongs to user
return this.prisma.pet.update({
where: { id },
data: {
name: updatePetDto.name,
type: updatePetDto.type,
breed: updatePetDto.breed,
weight: updatePetDto.weight,
},
});
}
async remove(id: string, userId: string) {
await this.findOne(id, userId);
return this.prisma.pet.delete({
where: { id },
});
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,13 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,19 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsEnum } from 'class-validator';
export class GetProductsDto {
@ApiPropertyOptional({ description: 'Filter by category slug' })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: 'Filter by pet type', enum: ['سگ', 'گربه', 'all'] })
@IsOptional()
@IsEnum(['سگ', 'گربه', 'all'])
petType?: string;
@ApiPropertyOptional({ description: 'Search query string' })
@IsOptional()
@IsString()
query?: string;
}

View File

@ -0,0 +1,28 @@
import { Controller, Get, Query, Param, NotFoundException } from '@nestjs/common';
import { ProductsService } from './products.service';
import { GetProductsDto } from './dto/get-products.dto';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@ApiTags('Products')
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
@ApiOperation({ summary: 'List Products' })
@ApiResponse({ status: 200, description: 'List of products' })
findAll(@Query() query: GetProductsDto) {
return this.productsService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get Product by ID' })
@ApiResponse({ status: 200, description: 'Product details' })
async findOne(@Param('id') id: string) {
const product = await this.productsService.findOne(id);
if (!product) {
throw new NotFoundException(`Product with ID ${id} not found`);
}
return product;
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ProductsService } from './products.service';
import { ProductsController } from './products.controller';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}

View File

@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { GetProductsDto } from './dto/get-products.dto';
@Injectable()
export class ProductsService {
constructor(private prisma: PrismaService) {}
async findAll(filters: GetProductsDto) {
const { category, petType, query } = filters;
const whereClause: any = {};
if (category) {
whereClause.categorySlug = category;
}
if (petType && petType !== 'all') {
whereClause.suitableFor = { in: [petType, 'هر دو'] };
}
if (query) {
whereClause.OR = [
{ name: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
];
}
const products = await this.prisma.product.findMany({
where: whereClause,
include: {
ingredients: true,
symptoms: true,
},
});
return products;
}
async findOne(id: string) {
return this.prisma.product.findUnique({
where: { id },
include: {
ingredients: true,
symptoms: true,
},
});
}
}

View File

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@ -0,0 +1,34 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
private client: Redis;
onModuleInit() {
this.client = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 16379,
});
}
onModuleDestroy() {
this.client.disconnect();
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds);
} else {
await this.client.set(key, value);
}
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
}

View File

@ -0,0 +1,51 @@
import { Controller, Get, Patch, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
@ApiTags('Settings')
@Controller('settings')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get('ui-texts')
@ApiOperation({ summary: 'دریافت تمامی متون رابط کاربری' })
@ApiResponse({ status: 200, description: 'تمامی متون بازگردانده شدند' })
getUiTexts() {
return this.settingsService.getUiTexts();
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Patch('ui-texts/:key')
@ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری' })
@ApiResponse({ status: 200, description: 'متن با موفقیت به‌روزرسانی شد' })
updateUiText(@Param('key') key: string, @Body('value') value: string) {
return this.settingsService.updateUiText(key, value);
}
@Get('scientific-terms')
@ApiOperation({ summary: 'دریافت تمامی اصطلاحات علمی' })
@ApiResponse({ status: 200, description: 'تمامی اصطلاحات علمی بازگردانده شدند' })
getScientificTerms() {
return this.settingsService.getScientificTerms();
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Put('scientific-terms/:key')
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی' })
@ApiResponse({ status: 200, description: 'اصطلاح علمی ثبت یا ویرایش شد' })
upsertScientificTerm(@Param('key') key: string, @Body() data: any) {
return this.settingsService.upsertScientificTerm(key, data);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Delete('scientific-terms/:key')
@ApiOperation({ summary: 'حذف یک اصطلاح علمی' })
@ApiResponse({ status: 200, description: 'اصطلاح علمی حذف شد' })
deleteScientificTerm(@Param('key') key: string) {
return this.settingsService.deleteScientificTerm(key);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SettingsService } from './settings.service';
import { SettingsController } from './settings.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class SettingsService {
constructor(private prisma: PrismaService) {}
async getUiTexts() {
return this.prisma.uiText.findMany();
}
async updateUiText(key: string, value: string) {
return this.prisma.uiText.upsert({
where: { key },
update: { value },
create: { key, value },
});
}
async getScientificTerms() {
return this.prisma.scientificTerm.findMany();
}
async upsertScientificTerm(key: string, data: any) {
return this.prisma.scientificTerm.upsert({
where: { key },
update: {
term: data.term,
definition: data.definition,
wikiId: data.wikiId || 'general',
},
create: {
key,
term: data.term,
definition: data.definition,
wikiId: data.wikiId || 'general',
},
});
}
async deleteScientificTerm(key: string) {
return this.prisma.scientificTerm.delete({
where: { key },
});
}
}

View File

@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
export class AddressDto {
@ApiProperty({ description: 'عنوان آدرس (مثلا خانه، کار)' })
@IsString()
@IsNotEmpty()
title: string;
@ApiProperty({ description: 'نام تحویل‌گیرنده' })
@IsString()
@IsNotEmpty()
receptorName: string;
@ApiProperty({ description: 'شماره تلفن گیرنده' })
@IsString()
@IsNotEmpty()
phone: string;
@ApiProperty({ description: 'استان' })
@IsString()
@IsNotEmpty()
province: string;
@ApiProperty({ description: 'شهر' })
@IsString()
@IsNotEmpty()
city: string;
@ApiProperty({ description: 'جزئیات آدرس پستی' })
@IsString()
@IsNotEmpty()
detail: string;
@ApiProperty({ description: 'کد پستی' })
@IsString()
@IsNotEmpty()
zipCode: string;
@ApiPropertyOptional({ description: 'انتخاب به عنوان آدرس پیش‌فرض' })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}

View File

@ -0,0 +1,24 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsEmail } from 'class-validator';
export class UpdateProfileDto {
@ApiPropertyOptional({ description: 'نام کاربر' })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ description: 'نام خانوادگی کاربر' })
@IsOptional()
@IsString()
lastName?: string;
@ApiPropertyOptional({ description: 'ایمیل کاربر' })
@IsOptional()
@IsEmail({}, { message: 'ایمیل نامعتبر است' })
email?: string;
@ApiPropertyOptional({ description: 'شماره موبایل کاربر' })
@IsOptional()
@IsString()
mobile?: string;
}

View File

@ -0,0 +1,64 @@
import { Controller, Get, Patch, Post, Delete, Body, Param, UseGuards, Req } from '@nestjs/common';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { AddressDto } from './dto/address.dto';
@ApiTags('Users')
@ApiBearerAuth()
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@UseGuards(JwtAuthGuard)
@Get('profile')
@ApiOperation({ summary: 'دریافت پروفایل کاربر فعلی' })
getProfile(@Req() req: any) {
return this.usersService.findById(req.user.id);
}
@UseGuards(JwtAuthGuard)
@Patch('profile')
@ApiOperation({ summary: 'ویرایش پروفایل کاربر فعلی' })
@ApiResponse({ status: 200, description: 'پروفایل با موفقیت ویرایش شد' })
updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) {
return this.usersService.update(req.user.id, updateProfileDto);
}
@UseGuards(JwtAuthGuard)
@Post('addresses')
@ApiOperation({ summary: 'ایجاد آدرس جدید برای کاربر' })
@ApiResponse({ status: 201, description: 'آدرس جدید با موفقیت ایجاد شد' })
addAddress(@Req() req: any, @Body() addressDto: AddressDto) {
return this.usersService.addAddress(req.user.id, addressDto);
}
@UseGuards(JwtAuthGuard)
@Patch('addresses/:addressId')
@ApiOperation({ summary: 'ویرایش آدرس کاربر' })
@ApiResponse({ status: 200, description: 'آدرس با موفقیت ویرایش شد' })
updateAddress(
@Req() req: any,
@Param('addressId') addressId: string,
@Body() addressDto: AddressDto,
) {
return this.usersService.updateAddress(req.user.id, addressId, addressDto);
}
@UseGuards(JwtAuthGuard)
@Delete('addresses/:addressId')
@ApiOperation({ summary: 'حذف آدرس کاربر' })
@ApiResponse({ status: 200, description: 'آدرس با موفقیت حذف شد' })
deleteAddress(@Req() req: any, @Param('addressId') addressId: string) {
return this.usersService.deleteAddress(req.user.id, addressId);
}
@UseGuards(JwtAuthGuard)
@Patch('addresses/:addressId/default')
@ApiOperation({ summary: 'انتخاب آدرس به عنوان پیش‌فرض' })
@ApiResponse({ status: 200, description: 'آدرس به عنوان پیش‌فرض ثبت شد' })
setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) {
return this.usersService.setDefaultAddress(req.user.id, addressId);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,111 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async findById(id: string) {
return this.prisma.user.findUnique({
where: { id },
include: {
pets: true,
orders: {
include: {
orderItems: {
include: { product: true }
}
}
},
addresses: true,
walletTransactions: true
}
});
}
async update(id: string, data: any) {
return this.prisma.user.update({
where: { id },
data,
include: {
pets: true,
orders: {
include: {
orderItems: {
include: { product: true }
}
}
},
addresses: true,
walletTransactions: true
}
});
}
async addAddress(userId: string, data: any) {
if (data.isDefault) {
await this.prisma.userAddress.updateMany({
where: { userId },
data: { isDefault: false },
});
}
return this.prisma.userAddress.create({
data: {
title: data.title,
receptorName: data.receptorName,
phone: data.phone,
province: data.province,
city: data.city,
detail: data.detail,
zipCode: data.zipCode,
isDefault: data.isDefault || false,
userId: userId,
},
});
}
async updateAddress(userId: string, addressId: string, data: any) {
if (data.isDefault) {
await this.prisma.userAddress.updateMany({
where: { userId, NOT: { id: addressId } },
data: { isDefault: false },
});
}
return this.prisma.userAddress.update({
where: { id: addressId, userId },
data: {
title: data.title,
receptorName: data.receptorName,
phone: data.phone,
province: data.province,
city: data.city,
detail: data.detail,
zipCode: data.zipCode,
isDefault: data.isDefault || false,
},
});
}
async deleteAddress(userId: string, addressId: string) {
return this.prisma.userAddress.delete({
where: { id: addressId, userId },
});
}
async setDefaultAddress(userId: string, addressId: string) {
await this.prisma.userAddress.updateMany({
where: { userId },
data: { isDefault: false },
});
return this.prisma.userAddress.update({
where: { id: addressId, userId },
data: { isDefault: true },
});
}
async findByPhone(phoneNumber: string) {
return this.prisma.user.findUnique({
where: { mobile: phoneNumber },
});
}
}

View File

@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterEach(async () => {
await app.close();
});
});

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
backend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"noFallthroughCasesInSwitch": true
}
}

53
docker-compose.yml Normal file
View File

@ -0,0 +1,53 @@
version: '3.8'
services:
db:
image: postgres:16-alpine
container_name: canina_db
restart: always
environment:
POSTGRES_USER: canina
POSTGRES_PASSWORD: caninapassword
POSTGRES_DB: caninadb
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
container_name: canina_redis
restart: always
ports:
- "16379:6379"
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: canina_backend
restart: always
environment:
- DATABASE_URL=postgresql://canina:caninapassword@db:5432/caninadb?schema=public
- REDIS_HOST=redis
- REDIS_PORT=6379
- PORT=3000
ports:
- "3000:3000"
depends_on:
- db
- redis
frontend:
build:
context: .
dockerfile: Dockerfile
container_name: canina_frontend
restart: always
ports:
- "3001:80"
depends_on:
- backend
volumes:
postgres_data:

21
nginx.conf Normal file
View File

@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Support for SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

140
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"axios": "^1.16.1",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
@ -1671,6 +1672,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
@ -1708,6 +1715,43 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -1885,6 +1929,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -1960,6 +2016,15 @@
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -2087,6 +2152,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@ -2292,6 +2372,42 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@ -2516,6 +2632,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@ -3268,6 +3399,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",

View File

@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"dev": "vite --port=5173 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
@ -14,6 +14,7 @@
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"axios": "^1.16.1",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",

View File

@ -11,6 +11,8 @@ import { Toaster } from "sonner";
import { Product } from "./data/products";
import { useCartStore } from "./store/cartStore";
import { usePetStore } from "./store/usePetStore";
import { useUserStore } from "./store/userStore";
import { useSettingsStore } from "./store/settingsStore";
import { NetworkBanner } from './components/NetworkBanner';
import { ErrorBoundary } from './components/ErrorBoundary';
import { NotFoundPage } from './components/ErrorPages';
@ -64,14 +66,29 @@ export default function App() {
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const { fetchProfile, isLoggedIn: storeIsLoggedIn } = useUserStore();
const fetchSettings = useSettingsStore(state => state.fetchSettings);
const getText = useSettingsStore(state => state.getText);
useEffect(() => {
const init = async () => {
// Simulating initial data load
await new Promise(resolve => setTimeout(resolve, 800));
await fetchSettings();
const token = localStorage.getItem('accessToken');
if (token) {
try {
await fetchProfile();
} catch (e) {
console.error("Auth init failed:", e);
}
}
setIsLoading(false);
};
init();
}, []);
}, [fetchProfile, fetchSettings]);
useEffect(() => {
setIsLoggedIn(storeIsLoggedIn);
}, [storeIsLoggedIn]);
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
const params = new URLSearchParams(window.location.search);
@ -154,15 +171,8 @@ export default function App() {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const finalizeOrder = () => {
const activePet = getActivePet();
const orderId = addOrder({
items,
total: getTotal(),
petId: activePet?.id
});
const finalizeOrder = (orderId: string) => {
setLastOrderId(orderId);
clearCart();
setCurrentView("order-success");
window.scrollTo({ top: 0, behavior: 'smooth' });
};
@ -296,20 +306,20 @@ export default function App() {
className="rounded-[3rem] shadow-2xl relative z-10 grayscale-[10%]"
/>
<div className="absolute -bottom-6 -left-6 bg-canina-blue text-white p-8 rounded-3xl shadow-xl z-20">
<div className="text-4xl font-black mb-1 italic">کیفیت</div>
<div className="text-xs uppercase tracking-widest font-bold opacity-80">استانداردهای فوقدارویی</div>
<div className="text-4xl font-black mb-1 italic">{getText('about_teaser_quality_title', 'کیفیت')}</div>
<div className="text-xs uppercase tracking-widest font-bold opacity-80">{getText('about_teaser_quality_desc', 'استانداردهای فوق‌دارویی')}</div>
</div>
</div>
<div>
<div className="text-canina-blue text-xs font-bold uppercase tracking-[0.2em] mb-4">میراث ما از آلمان</div>
<div className="text-canina-blue text-xs font-bold uppercase tracking-[0.2em] mb-4">{getText('about_teaser_badge', 'میراث ما از آلمان')}</div>
<h2 className="text-3xl lg:text-5xl font-black text-medical-gray-900 leading-[1.2] mb-8">
چرا برند آلمانی <span className="text-canina-blue italic">Canina</span> مرجع دامپزشکان است؟
{getText('about_teaser_title', 'چرا برند آلمانی Canina مرجع دامپزشکان است؟')}
</h2>
<div className="space-y-6">
{[
{ title: "مواد اولیه نایاب", desc: "استفاده از پودر صدف لب‌سبز اصل نیوزیلند و مواد ارگانیک با گرید دارویی." },
{ title: "فاقد مواد نگهدارنده", desc: "تمامی محصولات ۱۰۰٪ طبیعی و فاقد رنگ‌های مصنوعی و طعم‌دهنده‌های شیمیایی هستند." },
{ title: "تاییدیه اروپا", desc: "مطابق با سخت‌گیرانه‌ترین استانداردهای ایمنی مواد غذایی و دارویی در اتحادیه اروپا." }
{ title: getText('about_teaser_feat1_title', 'مواد اولیه نایاب'), desc: getText('about_teaser_feat1_desc', 'استفاده از پودر صدف لب‌سبز اصل نیوزیلند و مواد ارگانیک با گرید دارویی.') },
{ title: getText('about_teaser_feat2_title', 'فاقد مواد نگهدارنده'), desc: getText('about_teaser_feat2_desc', 'تمامی محصولات ۱۰۰٪ طبیعی و فاقد رنگ‌های مصنوعی و طعم‌دهنده‌های شیمیایی هستند.') },
{ title: getText('about_teaser_feat3_title', 'تاییدیه اروپا'), desc: getText('about_teaser_feat3_desc', 'مطابق با سخت‌گیرانه‌ترین استانداردهای ایمنی مواد غذایی و دارویی در اتحادیه اروپا.') }
].map((item, idx) => (
<div key={idx} className="flex gap-4">
<div className="w-12 h-12 rounded-2xl bg-medical-gray-50 flex items-center justify-center flex-shrink-0 text-canina-blue font-bold">
@ -373,20 +383,20 @@ export default function App() {
</div>
<div className="max-w-4xl mx-auto px-4 text-center relative z-10">
<h2 className="text-3xl lg:text-5xl font-black text-white mb-8 leading-tight">
میخواهید بدانید کدام محصول برای پت شما مناسبتر است؟
{getText('cta_title', 'می‌خواهید بدانید کدام محصول برای پت شما مناسب‌تر است؟')}
</h2>
<p className="text-white/80 text-lg mb-10 leading-relaxed">
تیم متخصص دامپزشکی کانینا ایران آماده پاسخگویی به سوالات شماست.
{getText('cta_subtitle', 'تیم متخصص دامپزشکی کانینا ایران آماده پاسخگویی به سوالات شماست.')}
</p>
<div className="flex flex-wrap justify-center gap-4">
<button className="bg-white text-canina-blue px-12 py-5 rounded-full font-black text-xl hover:scale-105 transition-transform shadow-2xl shadow-black/20">
دریافت رژیم مکمل رایگان
{getText('cta_btn_free', 'دریافت رژیم مکمل رایگان')}
</button>
<button
onClick={() => navigateToShop()}
className="bg-canina-blue/20 backdrop-blur-md border-2 border-white text-white px-12 py-5 rounded-full font-black text-xl hover:bg-white hover:text-canina-blue transition-all"
>
ورود به محصولات تخصصی
{getText('cta_btn_products', 'ورود به محصولات تخصصی')}
</button>
</div>
</div>

View File

@ -102,7 +102,7 @@ export default function AddressModal({ isOpen, onClose, onSave, editingAddress }
phone: normalizeDigits(formData.phone),
zipCode: normalizeDigits(formData.zipCode),
id: editingAddress?.id || Math.random().toString(36).substr(2, 9),
isDefault: editingAddress?.isDefault || false
isDefault: formData.isDefault
});
toast.success(editingAddress ? "تغییرات آدرس با موفقیت ذخیره شد" : "آدرس جدید با موفقیت اضافه شد");
setIsSubmitting(false);

View File

@ -1,8 +1,10 @@
import React from "react";
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, User, Building2, Heart, ShieldCheck } from "lucide-react";
import { X, User, Building2, Heart, ShieldCheck, Phone, Key, LogIn, ArrowRight, RefreshCw } from "lucide-react";
import { useUserStore, UserRole } from "../store/userStore";
import { authService } from "../services/authService";
import { toast } from "sonner";
import { toPersian } from "../lib/utils";
interface AuthModalProps {
isOpen: boolean;
@ -10,20 +12,145 @@ interface AuthModalProps {
}
export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const { setRole, setLoggedIn } = useUserStore();
const { setRole, setLoggedIn, fetchProfile } = useUserStore();
const [view, setView] = useState<"quick" | "phone" | "otp">("quick");
const [phoneNumber, setPhoneNumber] = useState("");
const [otpCode, setOtpCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const otpInputRef = useRef<HTMLInputElement>(null);
const handleQuickLogin = (role: UserRole) => {
// TODO: [BACKEND_API] POST /api/auth/login | Payload: { role: UserRole, mobile?: string } | Expected: { token: string, user: UserProfile } | Errors: [401]
setRole(role);
setLoggedIn(role !== "User_Guest");
toast.success(`خوش آمدید! در نقش ${role === "User_Partner" ? 'همکار' : role === 'User_PetOwner' ? 'صاحب پت' : 'مهمان'} وارد شدید.`);
onClose();
useEffect(() => {
if (view === "otp") {
const timer = setTimeout(() => {
otpInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [view]);
// Reset modal state on open/close
useEffect(() => {
if (!isOpen) {
setView("quick");
setPhoneNumber("");
setOtpCode("");
setIsLoading(false);
setCountdown(0);
}
}, [isOpen]);
// Cooldown countdown timer
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
const handleQuickLogin = async (targetRole: UserRole) => {
if (targetRole === "User_Guest") {
setRole("User_Guest");
setLoggedIn(false);
toast.success("به عنوان کاربر مهمان وارد شدید.");
onClose();
return;
}
setIsLoading(true);
const testPhone = targetRole === "User_PetOwner" ? "09121111111" : "09122222222";
try {
// Step 1: Send OTP to test phone
const sendRes = await authService.sendOtp(testPhone);
const code = (sendRes as any).code;
if (!code) {
throw new Error("کد تایید تستی تولید نشد");
}
// Step 2: Verify OTP
const verifyRes = await authService.verifyOtp(testPhone, code);
if (verifyRes.success) {
// Force state sync and fetch profile
setRole(targetRole);
await fetchProfile();
toast.success(`ورود سریع موفق! در نقش ${targetRole === "User_Partner" ? 'همکار' : 'صاحب پت'} وارد شدید.`);
onClose();
}
} catch (err: any) {
toast.error(`خطا در ورود سریع: ${err.message}`);
} finally {
setIsLoading(false);
}
};
const handleSendOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanPhone = phoneNumber.trim();
if (!/^09\d{9}$/.test(cleanPhone)) {
toast.error("شماره موبایل نامعتبر است");
return;
}
setIsLoading(true);
try {
const res = await authService.sendOtp(cleanPhone);
if ((res as any).code) {
toast.info(`کد تایید (تست): ${(res as any).code}`, { duration: 10000 });
} else {
toast.success("کد تایید پیامک شد");
}
setView("otp");
setCountdown(120);
} catch (err: any) {
toast.error(err.message || "خطا در ارسال کد تایید");
} finally {
setIsLoading(false);
}
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanCode = otpCode.trim();
if (cleanCode.length !== 5) {
toast.error("کد باید ۵ رقم باشد");
return;
}
setIsLoading(true);
try {
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
if (response.success) {
await fetchProfile();
toast.success("ورود موفقیت‌آمیز بود");
onClose();
}
} catch (err: any) {
toast.error(err.message || "کد تایید نامعتبر است");
} finally {
setIsLoading(false);
}
};
const handleResendOtp = async () => {
if (countdown > 0) return;
setIsLoading(true);
try {
await authService.sendOtp(phoneNumber.trim());
toast.success("کد تایید جدید ارسال شد");
setCountdown(120);
} catch (err: any) {
toast.error(err.message || "خطا در ارسال مجدد کد");
} finally {
setIsLoading(false);
}
};
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -31,63 +158,225 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60]"
onClick={onClose}
/>
{/* Modal content */}
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl z-[70] overflow-hidden border border-medical-gray-100"
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl z-[70] overflow-hidden border border-medical-gray-100 font-vazir"
dir="rtl"
>
{/* Top highlight bar */}
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-canina-blue to-indigo-500" />
<div className="p-8 relative">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-6 left-6 p-2 rounded-full hover:bg-medical-gray-50 text-medical-gray-400 transition-colors"
className="absolute top-6 left-6 p-2 rounded-full hover:bg-medical-gray-50 text-medical-gray-400 hover:text-medical-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
<div className="flex flex-col items-center text-center mb-10 pt-4">
<div className="w-20 h-20 bg-canina-blue/10 rounded-3xl flex items-center justify-center mb-6">
{/* Title Header */}
<div className="flex flex-col items-center text-center mb-8 pt-4">
<div className="w-20 h-20 bg-canina-blue/10 rounded-3xl flex items-center justify-center mb-4 shadow-inner">
<ShieldCheck className="w-10 h-10 text-canina-blue" />
</div>
<h2 className="text-3xl font-black text-medical-gray-900 font-vazir mb-3">ورود به دنیای کانینا</h2>
<p className="text-medical-gray-500 font-medium font-vazir max-w-[280px]">
برای دسترسی به پرونده سلامت و تخفیفات اختصاصی وارد شوید
<h2 className="text-3xl font-black text-medical-gray-900 mb-2 italic">
ورود به دنیای کانینا
</h2>
<p className="text-medical-gray-500 text-sm font-bold max-w-[280px]">
{view === "otp"
? `کد تایید ارسال شده به شماره ${toPersian(phoneNumber)} را وارد کنید`
: "برای دسترسی به پرونده سلامت و سفارشات وارد شوید"}
</p>
</div>
<div className="space-y-4">
<button className="w-full bg-medical-gray-50 border border-medical-gray-100 rounded-2xl p-4 flex items-center justify-between group hover:border-canina-blue transition-all">
<span className="font-bold text-sm text-medical-gray-600 font-vazir">ورود با شماره موبایل</span>
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center shadow-sm text-canina-blue"></div>
</button>
</div>
{/* Content Panel */}
<AnimatePresence mode="wait">
{view === "quick" && (
<motion.div
key="quick-view"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-6"
>
<button
onClick={() => setView("phone")}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-5 flex items-center justify-between group hover:border-canina-blue transition-all cursor-pointer shadow-sm"
>
<div className="flex items-center gap-3">
<Phone className="w-5 h-5 text-canina-blue" />
<span className="font-black text-sm text-medical-gray-700">ورود با شماره موبایل</span>
</div>
<div className="w-8 h-8 rounded-xl bg-white border border-medical-gray-100 flex items-center justify-center shadow-sm text-canina-blue group-hover:bg-canina-blue group-hover:text-white transition-all"></div>
</button>
<div className="mt-12 pt-8 border-t border-medical-gray-50">
<p className="text-[10px] font-black text-medical-gray-300 uppercase tracking-widest text-center mb-6 font-vazir">ورود سریع (جهت تست)</p>
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => handleQuickLogin("User_Guest")}
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group"
<div className="pt-6 border-t border-medical-gray-100">
<p className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest text-center mb-4">ورود سریع تستی (با دیتابیس واقعی)</p>
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => handleQuickLogin("User_Guest")}
disabled={isLoading}
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group cursor-pointer disabled:opacity-50"
>
<User className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
<span className="text-[10px] font-black text-medical-gray-700">مهمان</span>
</button>
<button
onClick={() => handleQuickLogin("User_PetOwner")}
disabled={isLoading}
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group cursor-pointer disabled:opacity-50"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 text-canina-blue animate-spin" />
) : (
<Heart className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
)}
<span className="text-[10px] font-black text-medical-gray-700">صاحب پت</span>
</button>
<button
onClick={() => handleQuickLogin("User_Partner")}
disabled={isLoading}
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group cursor-pointer disabled:opacity-50"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 text-canina-blue animate-spin" />
) : (
<Building2 className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
)}
<span className="text-[10px] font-black text-medical-gray-700">همکار</span>
</button>
</div>
</div>
</motion.div>
)}
{view === "phone" && (
<motion.form
key="phone-view"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleSendOtp}
className="space-y-6"
>
<User className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
<span className="text-[10px] font-bold font-vazir">مهمان</span>
</button>
<button
onClick={() => handleQuickLogin("User_PetOwner")}
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group"
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">شماره موبایل</label>
<button
type="button"
onClick={() => setView("quick")}
className="text-[10px] font-black text-canina-blue hover:underline"
>
برگشت به ورود سریع
</button>
</div>
<div className="relative">
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
type="tel"
maxLength={11}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-lg text-left tracking-widest"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || phoneNumber.length < 11}
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 animate-spin" />
) : (
<>
<LogIn className="w-6 h-6" />
ارسال کد تایید
</>
)}
</button>
</motion.form>
)}
{view === "otp" && (
<motion.form
key="otp-view"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleVerifyOtp}
className="space-y-6"
>
<Heart className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
<span className="text-[10px] font-bold font-vazir">صاحب پت</span>
</button>
<button
onClick={() => handleQuickLogin("User_Partner")}
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group"
>
<Building2 className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
<span className="text-[10px] font-bold font-vazir">همکار</span>
</button>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">کد تایید پیامکی</label>
<button
type="button"
onClick={() => setView("phone")}
className="text-[10px] font-black text-canina-blue flex items-center gap-1 hover:underline"
>
<ArrowRight className="w-3 h-3" />
تغییر شماره
</button>
</div>
<div className="relative">
<Key className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
ref={otpInputRef}
type="text"
maxLength={5}
placeholder="کد ۵ رقمی"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-black text-2xl text-center tracking-[0.5em]"
/>
</div>
</div>
<div className="text-center">
{countdown > 0 ? (
<span className="text-xs font-bold text-medical-gray-400">
ارسال مجدد کد پس از {toPersian(countdown)} ثانیه
</span>
) : (
<button
type="button"
onClick={handleResendOtp}
disabled={isLoading}
className="text-xs font-black text-canina-blue hover:underline flex items-center gap-1.5 justify-center mx-auto"
>
<RefreshCw className="w-3.5 h-3.5" />
ارسال مجدد کد تایید
</button>
)}
</div>
<button
type="submit"
disabled={isLoading || otpCode.length < 5}
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 animate-spin" />
) : (
<>
<LogIn className="w-6 h-6" />
تایید کد و ورود
</>
)}
</button>
</motion.form>
)}
</AnimatePresence>
</div>
</motion.div>
</>

View File

@ -22,7 +22,7 @@ import { useUserStore } from "../store/userStore";
import { PRODUCTS } from "../data/products";
import { toast } from "sonner";
export default function CheckoutPage({ onBack, onComplete }: { onBack: () => void; onComplete: () => void }) {
export default function CheckoutPage({ onBack, onComplete }: { onBack: () => void; onComplete: (orderId: string) => void }) {
const { items, getTotal, getSubtotal, getDiscount, isSubscribed, charityDonation, setCharityDonation, addOrder, clearCart } = useCartStore();
const { getActivePet, updatePet } = usePetStore();
const { profile, updateProfile } = useUserStore();
@ -44,13 +44,13 @@ export default function CheckoutPage({ onBack, onComplete }: { onBack: () => voi
}
};
const handleFinalize = () => {
const handleFinalize = async () => {
setLoading(true);
const activePet = getActivePet();
setTimeout(() => {
// 1. Register the order
addOrder({
try {
// 1. Register the order in the backend
const orderId = await addOrder({
items: [...items],
total: getTotal(),
charityDonation: charityDonation,
@ -64,7 +64,7 @@ export default function CheckoutPage({ onBack, onComplete }: { onBack: () => voi
});
}
// 2. Update pet consumptions for the refill logic
// 3. Update pet consumptions for the refill logic
if (activePet) {
const newConsumptions = [...(activePet.consumptions || [])];
@ -85,11 +85,14 @@ export default function CheckoutPage({ onBack, onComplete }: { onBack: () => voi
updatePet(activePet.id, { consumptions: newConsumptions });
}
setLoading(false);
clearCart();
onComplete();
onComplete(orderId);
toast.success("سفارش شما با موفقیت ثبت شد و به پرونده سلامت همدمتان اضافه شد!");
}, 2000);
} catch (err: any) {
toast.error(err.message || "خطا در ثبت سفارش. لطفاً مجدداً تلاش کنید.");
} finally {
setLoading(false);
}
};
if (items.length === 0) {

View File

@ -2,6 +2,7 @@ import { motion, useMotionValue, useTransform, animate } from "motion/react";
import { ChevronLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { toPersian } from "../lib/utils";
import { useSettingsStore } from "../store/settingsStore";
function StatCounter({ target }: { target: number }) {
const [displayValue, setDisplayValue] = useState(0);
@ -59,6 +60,10 @@ function Typewriter({ text }: { text: string }) {
}
export default function Hero({ onProfileClick, onShopNavigate }: { onProfileClick?: () => void, onShopNavigate?: () => void }) {
const getText = useSettingsStore(state => state.getText);
const title = getText('hero_title', "تخصص آلمانی در خدمت\nسلامت پت‌های خانگی");
const titleParts = title.split('\n');
return (
<section className="relative overflow-hidden bg-white py-20 lg:py-32 border-b border-medical-gray-100">
{/* Background patterns */}
@ -77,17 +82,23 @@ export default function Hero({ onProfileClick, onShopNavigate }: { onProfileClic
>
<div className="inline-flex items-center gap-2 px-3 py-1 bg-canina-blue/5 border border-canina-blue/10 rounded-full mb-6">
<span className="w-2 h-2 rounded-full bg-canina-blue animate-pulse" />
<span className="text-canina-blue text-xs font-bold uppercase tracking-widest leading-none">تخصص دارویی از آلمان</span>
<span className="text-canina-blue text-xs font-bold uppercase tracking-widest leading-none">{getText('hero_badge', "تخصص دارویی از آلمان")}</span>
</div>
<h1 className="text-4xl lg:text-7xl font-black text-medical-gray-900 leading-[1.1] mb-6">
تخصص آلمانی در خدمت <br />
<span className="text-canina-blue font-vazir">سلامت پتهای خانگی</span>
{titleParts[0]} <br />
{titleParts[1] && <span className="text-canina-blue font-vazir">{titleParts[1]}</span>}
</h1>
<p className="text-lg lg:text-xl text-medical-gray-600 mb-10 max-w-2xl mx-auto lg:mx-0 leading-relaxed font-vazir">
بیش از <span className="font-bold text-canina-blue">۴۰ سال</span> تجربه نوآورانه در تولید مکملهای درمانی با بالاترین استاندارد کیفی «گرید دارویی اختصاصی». راهکار هوشمند برای هر نیاز بالینی.
</p>
<p
className="text-lg lg:text-xl text-medical-gray-600 mb-10 max-w-2xl mx-auto lg:mx-0 leading-relaxed font-vazir animate-fade-in"
dangerouslySetInnerHTML={{
__html: getText(
'hero_desc',
'بیش از <span className="font-bold text-canina-blue">۴۰ سال</span> تجربه نوآورانه در تولید مکمل‌های درمانی با بالاترین استاندارد کیفی «گرید دارویی اختصاصی». راهکار هوشمند برای هر نیاز بالینی.'
)
}}
/>
<div className="flex flex-wrap justify-center lg:justify-start gap-4">
<button
@ -99,14 +110,14 @@ export default function Hero({ onProfileClick, onShopNavigate }: { onProfileClic
}}
className="bg-medical-gray-900 text-white px-10 py-5 rounded-full font-bold text-lg hover:bg-canina-blue hover:shadow-2xl transition-all flex items-center gap-2 group font-vazir"
>
دستیار سلامت پت
{getText('hero_btn_advisor', "دستیار سلامت پت")}
<ChevronLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
</button>
<button
onClick={onShopNavigate}
className="bg-white border-2 border-medical-gray-200 text-medical-gray-700 px-10 py-5 rounded-full font-bold text-lg hover:border-canina-blue hover:text-canina-blue transition-all font-vazir"
>
مشاهده محصولات
{getText('hero_btn_products', "مشاهده محصولات")}
</button>
</div>
</motion.div>
@ -119,11 +130,11 @@ export default function Hero({ onProfileClick, onShopNavigate }: { onProfileClic
className="grid grid-cols-3 gap-8 mt-16 pt-8 border-t border-medical-gray-100"
>
<div className="flex flex-col items-center lg:items-end">
<Typewriter text="۱۹۸۴ سال تأسیس" />
<Typewriter text={getText('hero_stat_founded', "۱۹۸۴ سال تأسیس")} />
</div>
<div className="flex flex-col items-center lg:items-end">
<StatCounter target={40} />
<div className="text-xs text-medical-gray-500 font-bold uppercase tracking-tight mt-3 font-vazir">نمایندگی فعال</div>
<div className="text-xs text-medical-gray-500 font-bold uppercase tracking-tight mt-3 font-vazir">{getText('hero_stat_agencies', "نمایندگی فعال")}</div>
</div>
<div className="flex flex-col items-center lg:items-end">
<div className="relative overflow-hidden group">
@ -139,7 +150,7 @@ export default function Hero({ onProfileClick, onShopNavigate }: { onProfileClic
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 z-20 pointer-events-none"
/>
</div>
<div className="text-xs text-medical-gray-500 font-bold uppercase tracking-tight mt-3 font-vazir">فرمول آلمانی</div>
<div className="text-xs text-medical-gray-500 font-bold uppercase tracking-tight mt-3 font-vazir">{getText('hero_stat_german_formula', "فرمول آلمانی")}</div>
</div>
</motion.div>
</div>
@ -160,8 +171,8 @@ export default function Hero({ onProfileClick, onShopNavigate }: { onProfileClic
referrerPolicy="no-referrer"
/>
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-black/80 to-transparent text-white text-right">
<div className="text-sm font-medium mb-1 opacity-80 uppercase tracking-widest text-[10px]">سرآمد علمی در پزشکی پتها</div>
<div className="text-xl font-bold italic tracking-tighter">مکملهای تایید شده دامپزشکی</div>
<div className="text-sm font-medium mb-1 opacity-80 uppercase tracking-widest text-[10px]">{getText('hero_image_badge', "سرآمد علمی در پزشکی پت‌ها")}</div>
<div className="text-xl font-bold italic tracking-tighter">{getText('hero_image_title', "مکمل‌های تایید شده دامپزشکی")}</div>
</div>
</div>
</motion.div>
@ -174,7 +185,7 @@ export default function Hero({ onProfileClick, onShopNavigate }: { onProfileClic
>
<div className="flex flex-col items-center">
<span className="text-2xl lg:text-3xl font-black text-canina-blue leading-none tracking-tighter">DE</span>
<span className="text-[8px] lg:text-[10px] font-bold text-medical-gray-500 uppercase tracking-widest mt-1">استاندارد کیفی آلمان</span>
<span className="text-[8px] lg:text-[10px] font-bold text-medical-gray-500 uppercase tracking-widest mt-1">{getText('hero_quality_standard', "استاندارد کیفی آلمان")}</span>
</div>
</motion.div>
</div>

View File

@ -1,8 +1,24 @@
import { useMemo } from "react";
import { INGREDIENTS_WIKI, PRODUCTS, Product } from "../data/products";
import { motion } from "motion/react";
import { FlaskConical, CheckCircle2, ChevronRight, ChevronLeft, Beaker } from "lucide-react";
import { useSettingsStore } from "../store/settingsStore";
export default function IngredientWiki({ onProductClick, onBack }: { onProductClick: (p: Product) => void, onBack?: () => void }) {
const texts = useSettingsStore(state => state.texts);
const ingredientsWiki = useMemo(() => {
const jsonStr = texts['ingredients_wiki'];
if (jsonStr) {
try {
return JSON.parse(jsonStr) as typeof INGREDIENTS_WIKI;
} catch (e) {
console.error("Failed to parse ingredients_wiki from DB setting:", e);
}
}
return INGREDIENTS_WIKI;
}, [texts]);
return (
<div className="bg-white py-12 px-4 overflow-hidden font-vazir" dir="rtl">
<div className="max-w-7xl mx-auto">
@ -45,7 +61,7 @@ export default function IngredientWiki({ onProductClick, onBack }: { onProductCl
</div>
<div className="space-y-32">
{INGREDIENTS_WIKI.map((ing, idx) => {
{ingredientsWiki.map((ing, idx) => {
const hasIngredient = PRODUCTS.filter(p =>
p.main_ingredients.some(mi => mi.toLowerCase().includes(ing.name.toLowerCase())) ||
p.main_ingredients.some(mi => mi.toLowerCase().includes(ing.id.replace('-', ' ')))

View File

@ -1,6 +1,10 @@
import React, { useState } from "react";
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, ShieldCheck, Mail, Lock, LogIn, UserPlus } from "lucide-react";
import { X, ShieldCheck, Phone, Key, LogIn, ArrowRight, RefreshCw } from "lucide-react";
import { authService } from "../services/authService";
import { useUserStore } from "../store/userStore";
import { toast } from "sonner";
import { toPersian } from "../lib/utils";
interface LoginModalProps {
isOpen: boolean;
@ -11,12 +15,110 @@ interface LoginModalProps {
}
export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdvisorContext }: LoginModalProps) {
const [view, setView] = useState<"login" | "register">("login");
const [step, setStep] = useState<"phone" | "otp">("phone");
const [phoneNumber, setPhoneNumber] = useState("");
const [otpCode, setOtpCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const { fetchProfile } = useUserStore();
const otpInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (step === "otp") {
const timer = setTimeout(() => {
otpInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [step]);
// Reset modal state when closed or opened
useEffect(() => {
if (!isOpen) {
setStep("phone");
setPhoneNumber("");
setOtpCode("");
setIsLoading(false);
setCountdown(0);
}
}, [isOpen]);
// Countdown timer for OTP resend
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
const handleSendOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanPhone = phoneNumber.trim();
if (!/^09\d{9}$/.test(cleanPhone)) {
toast.error("شماره موبایل باید با ۰۹ شروع شده و ۱۱ رقم باشد");
return;
}
setIsLoading(true);
try {
const res = await authService.sendOtp(cleanPhone);
if ((res as any).code) {
toast.info(`کد تایید (تست): ${(res as any).code}`, { duration: 10000 });
} else {
toast.success("کد تایید پیامک شد");
}
setStep("otp");
setCountdown(120); // 2 minutes cooldown
} catch (err: any) {
toast.error(err.message || "خطا در ارسال کد تایید");
} finally {
setIsLoading(false);
}
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanCode = otpCode.trim();
if (cleanCode.length !== 5) {
toast.error("کد تایید باید ۵ رقم باشد");
return;
}
setIsLoading(true);
try {
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
if (response.success) {
await fetchProfile();
toast.success("ورود با موفقیت انجام شد");
onLogin();
onClose();
}
} catch (err: any) {
toast.error(err.message || "کد تایید اشتباه است");
} finally {
setIsLoading(false);
}
};
const handleResendOtp = async () => {
if (countdown > 0) return;
setIsLoading(true);
try {
await authService.sendOtp(phoneNumber.trim());
toast.success("کد تایید جدید ارسال شد");
setCountdown(120);
} catch (err: any) {
toast.error(err.message || "خطا در ارسال مجدد کد تایید");
} finally {
setIsLoading(false);
}
};
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -24,80 +126,159 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
onClick={onClose}
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-md"
/>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="bg-white w-full max-w-md rounded-[3.5rem] p-10 relative z-10 shadow-2xl overflow-hidden font-vazir"
className="bg-white w-full max-w-md rounded-[3.5rem] p-10 relative z-10 shadow-2xl overflow-hidden font-vazir border border-medical-gray-100"
dir="rtl"
>
<div className="absolute top-0 left-0 w-full h-2 bg-canina-blue" />
{/* Top color indicator */}
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-canina-blue to-indigo-500" />
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-8 left-8 text-medical-gray-400 hover:text-medical-gray-600 transition-colors"
className="absolute top-8 left-8 p-2 rounded-full hover:bg-medical-gray-50 text-medical-gray-400 hover:text-medical-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
<div className="text-center mb-10">
<div className="w-20 h-20 bg-canina-blue/10 rounded-[2rem] flex items-center justify-center mx-auto mb-6">
{/* Header info */}
<div className="text-center mb-8">
<div className="w-20 h-20 bg-canina-blue/10 rounded-[2.5rem] flex items-center justify-center mx-auto mb-6 shadow-inner">
<ShieldCheck className="w-10 h-10 text-canina-blue" />
</div>
<h3 className="text-3xl font-black text-medical-gray-900 mb-2 italic">
{view === "login" ? "ورود به کانی‌نا" : "عضویت در کانی‌نا"}
ورود به کانینا
</h3>
{isAdvisorContext ? (
<p className="text-medical-gray-500 font-bold leading-relaxed px-4">
تحلیل سلامت <span className="text-canina-blue">{petName || "همدم شما"}</span> آماده است! برای مشاهده رژیم مکمل پیشنهادی و ذخیره دائمی شناسنامه، وارد شوید.
<p className="text-medical-gray-500 font-bold leading-relaxed px-2 text-sm">
تحلیل سلامت <span className="text-canina-blue">{petName || "همدم شما"}</span> آماده است! برای مشاهده رژیم مکمل پیشنهادی و ذخیره شناسنامه، وارد شوید.
</p>
) : (
<p className="text-medical-gray-500 font-bold">برای دسترسی به پنل مدیریت سلامت، وارد شوید</p>
<p className="text-medical-gray-500 font-bold text-sm">برای دسترسی به پنل مدیریت سلامت، وارد شوید</p>
)}
</div>
<div className="space-y-4 mb-8">
<div className="relative">
<Mail className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
type="email"
placeholder="ایمیل یا شماره موبایل"
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold"
/>
</div>
<div className="relative">
<Lock className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
type="password"
placeholder="رمز عبور"
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold"
/>
</div>
{view === "login" && (
<div className="text-left">
<button className="text-xs font-black text-canina-blue hover:underline italic">رمز عبور را فراموش کردهاید؟</button>
</div>
)}
</div>
<button
onClick={onLogin}
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 mb-6"
>
{view === "login" ? <LogIn className="w-6 h-6" /> : <UserPlus className="w-6 h-6" />}
{view === "login" ? "ورود به حساب" : "ایجاد حساب کاربری"}
</button>
<div className="text-center">
<p className="text-xs text-medical-gray-400 font-bold">
{view === "login" ? "هنوز حساب ندارید؟" : "قبلاً ثبت‌نام کرده‌اید؟"}{' '}
<button
onClick={() => setView(view === "login" ? "register" : "login")}
className="text-canina-blue font-black hover:underline"
{/* Step-based Forms */}
<AnimatePresence mode="wait">
{step === "phone" ? (
<motion.form
key="phone-form"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleSendOtp}
className="space-y-6"
>
{view === "login" ? "عضویت رایگان" : "ورود"}
</button>
</p>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block">شماره موبایل</label>
<div className="relative">
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
type="tel"
maxLength={11}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 focus:border-canina-blue outline-none font-bold text-lg text-left tracking-widest"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || phoneNumber.length < 11}
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 animate-spin" />
) : (
<>
<LogIn className="w-6 h-6" />
ارسال کد تایید
</>
)}
</button>
</motion.form>
) : (
<motion.form
key="otp-form"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleVerifyOtp}
className="space-y-6"
>
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">کد تایید پیامکی</label>
<button
type="button"
onClick={() => setStep("phone")}
className="text-[10px] font-black text-canina-blue flex items-center gap-1 hover:underline"
>
<ArrowRight className="w-3 h-3" />
ویرایش شماره
</button>
</div>
<div className="relative">
<Key className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
ref={otpInputRef}
type="text"
maxLength={5}
placeholder="کد ۵ رقمی"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 focus:border-canina-blue outline-none font-black text-2xl text-center tracking-[0.5em]"
/>
</div>
<p className="text-[10px] text-medical-gray-400 font-bold text-center mt-2">
کد تایید به شماره {toPersian(phoneNumber)} ارسال گردید.
</p>
</div>
<div className="flex justify-center">
{countdown > 0 ? (
<span className="text-xs font-bold text-medical-gray-400 font-vazir">
ارسال مجدد کد پس از {toPersian(countdown)} ثانیه
</span>
) : (
<button
type="button"
onClick={handleResendOtp}
disabled={isLoading}
className="text-xs font-black text-canina-blue hover:underline flex items-center gap-1.5"
>
<RefreshCw className="w-3.5 h-3.5" />
ارسال مجدد کد تایید
</button>
)}
</div>
<button
type="submit"
disabled={isLoading || otpCode.length < 5}
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 animate-spin" />
) : (
<>
<LogIn className="w-6 h-6" />
ورود و تایید حساب
</>
)}
</button>
</motion.form>
)}
</AnimatePresence>
</motion.div>
</div>
)}

View File

@ -42,6 +42,7 @@ const ICON_MAP: Record<string, any> = {
import { toPersian, cn } from "../lib/utils";
import { Product, PRODUCTS } from "../data/products";
import { SCIENTIFIC_TERMS } from "../data/scientificTerms";
import { useSettingsStore } from "../store/settingsStore";
import { create } from "zustand";
import { useCartStore } from "../store/cartStore";
import { usePetStore } from "../store/usePetStore";
@ -78,6 +79,13 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
const activePet = getActivePet();
const { petType, weight, setPetType, setWeight } = useCalculatorStore();
const { addItem } = useCartStore();
const scientificTerms = useSettingsStore(state => state.scientificTerms);
const termKeys = useMemo(() => {
return Array.from(new Set([
...Object.keys(scientificTerms),
...Object.keys(SCIENTIFIC_TERMS)
]));
}, [scientificTerms]);
// Re-hydrate product to ensure methods like calculateDosage exist
const fullProduct = useMemo(() => {
@ -237,7 +245,7 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-widest mb-6 font-vazir">مواد تشکیلدهنده برتر</h4>
<div className="flex flex-wrap gap-3">
{product.main_ingredients.map((ing, idx) => {
const termKey = Object.keys(SCIENTIFIC_TERMS).find(key => ing.includes(key));
const termKey = termKeys.find(key => ing.includes(key));
return (
<div key={idx} className="bg-medical-gray-50 border border-medical-gray-100 px-5 py-3 rounded-2xl flex items-center gap-3 group hover:border-canina-blue/30 transition-all">
<span className="text-sm font-bold text-medical-gray-700 font-vazir">

View File

@ -1,5 +1,6 @@
import React, { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { useSettingsStore } from "../store/settingsStore";
import {
Dog,
Cat,
@ -36,6 +37,19 @@ const MEDICAL_OPTIONS = [
];
export default function SmartAdvisor({ onComplete }: SmartAdvisorProps) {
const texts = useSettingsStore(state => state.texts);
const medicalOptions = React.useMemo(() => {
const jsonStr = texts['medical_options'];
if (jsonStr) {
try {
return JSON.parse(jsonStr) as typeof MEDICAL_OPTIONS;
} catch (e) {
console.error("Failed to parse medical_options JSON:", e);
}
}
return MEDICAL_OPTIONS;
}, [texts]);
const [step, setStep] = useState(1);
// Step 1 Data
@ -238,7 +252,7 @@ export default function SmartAdvisor({ onComplete }: SmartAdvisorProps) {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{MEDICAL_OPTIONS.map((opt) => (
{medicalOptions.map((opt) => (
<button
key={opt.id}
onClick={() => {

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Info, ExternalLink } from 'lucide-react';
import { SCIENTIFIC_TERMS } from '../data/scientificTerms';
import { useSettingsStore } from '../store/settingsStore';
interface TooltipProps {
termKey: string;
@ -11,7 +12,8 @@ interface TooltipProps {
export default function Tooltip({ termKey, children, onWikiNavigate }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const termData = SCIENTIFIC_TERMS[termKey];
const scientificTerms = useSettingsStore(state => state.scientificTerms);
const termData = scientificTerms[termKey] || SCIENTIFIC_TERMS[termKey];
if (!termData) return <>{children}</>;

View File

@ -5,6 +5,7 @@ import { OrderRowSkeleton } from "./Skeleton";
import { useUserStore, Address } from "../store/userStore";
import { useCartStore } from "../store/cartStore";
import { toPersian, cn } from "../lib/utils";
import { toast } from "sonner";
import OrderDetailsModal from "./OrderDetailsModal";
import AddressModal from "./AddressModal";
import DeleteConfirmModal from "./DeleteConfirmModal";
@ -34,16 +35,59 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => vo
// Wallet State
const [isTopUpModalOpen, setIsTopUpModalOpen] = useState(false);
// Profile State
const [isEditing, setIsEditing] = useState(false);
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [profileForm, setProfileForm] = useState({
firstName: profile.firstName || "",
lastName: profile.lastName || "",
email: profile.email || "",
mobile: profile.mobile || ""
});
// Sync profile data on change
React.useEffect(() => {
setProfileForm({
firstName: profile.firstName || "",
lastName: profile.lastName || "",
email: profile.email || "",
mobile: profile.mobile || ""
});
}, [profile]);
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
if (!isEditing) return;
setIsSavingProfile(true);
try {
await useUserStore.getState().updateProfile({
firstName: profileForm.firstName,
lastName: profileForm.lastName,
email: profileForm.email
});
setIsEditing(false);
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
} catch (err: any) {
toast.error(err.message || "خطا در ویرایش اطلاعات");
} finally {
setIsSavingProfile(false);
}
};
const handleLogout = () => {
logout();
onBack();
};
const handleSaveAddress = (addr: Address) => {
if (editingAddress) {
updateAddress(addr.id, addr);
} else {
addAddress(addr);
const handleSaveAddress = async (addr: Address) => {
try {
if (editingAddress) {
await updateAddress(addr.id, addr);
} else {
await addAddress(addr);
}
} catch (err) {
toast.error("خطا در ثبت آدرس");
}
setEditingAddress(null);
};
@ -53,9 +97,14 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => vo
setIsDeleteModalOpen(true);
};
const handleConfirmDelete = () => {
const handleConfirmDelete = async () => {
if (addressToDelete) {
deleteAddress(addressToDelete.id);
try {
await deleteAddress(addressToDelete.id);
toast.success("آدرس با موفقیت حذف شد");
} catch (err) {
toast.error("خطا در حذف آدرس");
}
setAddressToDelete(null);
}
};
@ -185,23 +234,120 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => vo
{activeTab === "profile" && (
<div>
<h3 className="text-2xl font-black text-medical-gray-900 mb-8 italic">اطلاعات فردی</h3>
<div className="grid md:grid-cols-2 gap-8">
<div className="space-y-2">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">نام</label>
<input type="text" readOnly value={profile.firstName} className="w-full bg-medical-gray-50 border border-medical-gray-100 p-4 rounded-2xl font-bold outline-none" />
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">نام خانوادگی</label>
<input type="text" readOnly value={profile.lastName} className="w-full bg-medical-gray-50 border border-medical-gray-100 p-4 rounded-2xl font-bold outline-none" />
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">ایمیل</label>
<input type="text" readOnly value={profile.email} className="w-full bg-medical-gray-50 border border-medical-gray-100 p-4 rounded-2xl font-bold outline-none" />
</div>
</div>
<button className="mt-12 bg-medical-gray-900 text-white px-10 py-4 rounded-2xl font-black hover:bg-canina-blue transition-all">
ویرایش اطلاعات
</button>
<form onSubmit={handleSaveProfile} className="space-y-8">
<div className="grid md:grid-cols-2 gap-8">
<div className="space-y-2">
<label htmlFor="firstName" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">نام</label>
<input
type="text"
id="firstName"
name="firstName"
autoComplete="given-name"
required
readOnly={!isEditing}
value={isEditing ? profileForm.firstName : profile.firstName}
onChange={e => setProfileForm({ ...profileForm, firstName: e.target.value })}
className={cn(
"w-full border p-4 rounded-2xl font-bold outline-none transition-all",
isEditing
? "bg-white border-canina-blue/30 focus:ring-2 focus:ring-canina-blue/20"
: "bg-medical-gray-50 border-medical-gray-100"
)}
/>
</div>
<div className="space-y-2">
<label htmlFor="lastName" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">نام خانوادگی</label>
<input
type="text"
id="lastName"
name="lastName"
autoComplete="family-name"
required
readOnly={!isEditing}
value={isEditing ? profileForm.lastName : profile.lastName}
onChange={e => setProfileForm({ ...profileForm, lastName: e.target.value })}
className={cn(
"w-full border p-4 rounded-2xl font-bold outline-none transition-all",
isEditing
? "bg-white border-canina-blue/30 focus:ring-2 focus:ring-canina-blue/20"
: "bg-medical-gray-50 border-medical-gray-100"
)}
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">ایمیل</label>
<input
type="email"
id="email"
name="email"
autoComplete="email"
required
readOnly={!isEditing}
value={isEditing ? profileForm.email : profile.email}
onChange={e => setProfileForm({ ...profileForm, email: e.target.value })}
className={cn(
"w-full border p-4 rounded-2xl font-bold outline-none transition-all text-left",
isEditing
? "bg-white border-canina-blue/30 focus:ring-2 focus:ring-canina-blue/20"
: "bg-medical-gray-50 border-medical-gray-100"
)}
dir="ltr"
/>
</div>
<div className="space-y-2">
<label htmlFor="mobile" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">شماره موبایل</label>
<input
type="tel"
id="mobile"
name="mobile"
autoComplete="tel"
readOnly
disabled
value={toPersian(profile.mobile || "")}
className="w-full bg-medical-gray-50 border border-medical-gray-100 p-4 rounded-2xl font-bold outline-none text-left opacity-75 cursor-not-allowed"
dir="ltr"
/>
</div>
</div>
<div className="flex gap-4 mt-8">
{isEditing ? (
<>
<button
type="submit"
disabled={isSavingProfile}
className="bg-canina-blue text-white px-10 py-4 rounded-2xl font-black hover:bg-indigo-700 transition-all flex items-center gap-2 disabled:opacity-50"
>
{isSavingProfile && <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />}
ذخیره تغییرات
</button>
<button
type="button"
onClick={() => {
setProfileForm({
firstName: profile.firstName || "",
lastName: profile.lastName || "",
email: profile.email || "",
mobile: profile.mobile || ""
});
setIsEditing(false);
}}
className="bg-medical-gray-50 text-medical-gray-500 border border-medical-gray-200 px-10 py-4 rounded-2xl font-black hover:bg-medical-gray-100 transition-all"
>
انصراف
</button>
</>
) : (
<button
type="button"
onClick={() => setIsEditing(true)}
className="bg-medical-gray-900 text-white px-10 py-4 rounded-2xl font-black hover:bg-canina-blue transition-all"
>
ویرایش اطلاعات
</button>
)}
</div>
</form>
</div>
)}
@ -338,7 +484,14 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => vo
</div>
{!addr.isDefault && (
<button
onClick={() => setDefaultAddress(addr.id)}
onClick={async () => {
try {
await setDefaultAddress(addr.id);
toast.success("آدرس پیش‌فرض با موفقیت تغییر کرد");
} catch (err) {
toast.error("خطا در تغییر آدرس پیش‌فرض");
}
}}
className="mr-auto text-[10px] font-black text-canina-blue hover:text-medical-gray-900 uppercase tracking-widest whitespace-nowrap"
>
انتخاب به عنوان پیشفرض

23
src/services/api.ts Normal file
View File

@ -0,0 +1,23 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor to add auth token in the future
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
export default api;

102
src/services/authService.ts Normal file
View File

@ -0,0 +1,102 @@
import api from './api';
export interface User {
id: string;
firstName: string;
lastName: string;
email: string;
mobile: string | null;
role: string;
walletBalance: number;
charityDonationTotal: number;
createdAt: string;
updatedAt: string;
pets?: any[];
orders?: any[];
}
export interface AuthResponse {
success: boolean;
data: {
user: User;
accessToken: string;
};
}
export class AuthService {
private static instance: AuthService;
private constructor() {}
public static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
/**
* Send SMS verification code (OTP)
*/
public async sendOtp(phoneNumber: string): Promise<{ success: boolean; message: string }> {
try {
const response = await api.post('/auth/send-otp', { phoneNumber });
return response.data;
} catch (error: any) {
const message = error.response?.data?.message || 'خطا در ارسال کد تایید';
throw new Error(Array.isArray(message) ? message[0] : message);
}
}
/**
* Verify SMS verification code and login/register
*/
public async verifyOtp(phoneNumber: string, code: string): Promise<AuthResponse> {
try {
const response = await api.post('/auth/verify-otp', { phoneNumber, code });
const { data } = response.data;
if (data?.accessToken) {
localStorage.setItem('accessToken', data.accessToken);
}
return response.data;
} catch (error: any) {
const message = error.response?.data?.message || 'کد تایید نامعتبر است';
throw new Error(Array.isArray(message) ? message[0] : message);
}
}
/**
* Get current user profile
*/
public async getProfile(): Promise<User> {
try {
const response = await api.get('/users/profile');
return response.data;
} catch (error: any) {
const message = error.response?.data?.message || 'خطا در دریافت اطلاعات کاربری';
throw new Error(Array.isArray(message) ? message[0] : message);
}
}
/**
* Update current user profile
*/
public async updateProfile(profileData: Partial<User>): Promise<User> {
try {
const response = await api.patch('/users/profile', profileData);
return response.data;
} catch (error: any) {
const message = error.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری';
throw new Error(Array.isArray(message) ? message[0] : message);
}
}
/**
* Logout user and clear tokens
*/
public logout(): void {
localStorage.removeItem('accessToken');
}
}
export const authService = AuthService.getInstance();

View File

@ -0,0 +1,79 @@
import api from './api';
import { CartItem } from '../store/cartStore';
export interface OrderItem {
id: string;
orderId: string;
productId: string | null;
quantity: number;
product?: any;
}
export interface Order {
id: string;
userId: string;
couponId: string | null;
totalAmount: number;
charityDonation: number;
status: string;
trackingNumber: string | null;
createdAt: string;
orderItems: OrderItem[];
}
export class OrderService {
private static instance: OrderService;
private constructor() {}
public static getInstance(): OrderService {
if (!OrderService.instance) {
OrderService.instance = new OrderService();
}
return OrderService.instance;
}
/**
* Create a new order on the backend
*/
public async createOrder(orderData: {
petId?: string;
items: { productId: string; quantity: number }[];
}): Promise<Order> {
try {
const response = await api.post('/orders', orderData);
return response.data;
} catch (error: any) {
const message = error.response?.data?.message || 'خطا در ثبت سفارش';
throw new Error(Array.isArray(message) ? message[0] : message);
}
}
/**
* Get all orders for the current user
*/
public async getUserOrders(): Promise<Order[]> {
try {
const response = await api.get('/orders');
return response.data;
} catch (error: any) {
const message = error.response?.data?.message || 'خطا در دریافت لیست سفارش‌ها';
throw new Error(Array.isArray(message) ? message[0] : message);
}
}
/**
* Get order details by ID
*/
public async getOrderById(id: string): Promise<Order> {
try {
const response = await api.get(`/orders/${id}`);
return response.data;
} catch (error: any) {
const message = error.response?.data?.message || 'خطا در دریافت جزئیات سفارش';
throw new Error(Array.isArray(message) ? message[0] : message);
}
}
}
export const orderService = OrderService.getInstance();

View File

@ -1,3 +1,4 @@
import api from './api';
import { PRODUCTS, Product, PetType } from "../data/products";
export class ProductService {
@ -12,59 +13,93 @@ export class ProductService {
return ProductService.instance;
}
/**
* Simulates an API call to get all products with optional filters
*/
private mapBackendToFrontend(data: any): Product {
// Find the local static product to inherit functions like calculateDosage
const local = PRODUCTS.find(p => p.artNo === data.artNo);
const safeParse = (val: any, fallback: any) => {
if (!val) return fallback;
if (typeof val === 'string') {
try { return JSON.parse(val); } catch (e) { return fallback; }
}
return val;
};
return {
id: data.id,
artNo: data.artNo,
name: data.name,
scientificTagline: data.scientificTagline,
description: data.description,
shortDescription: data.shortDescription,
price: data.priceDisplay || `${data.priceValue.toLocaleString('fa-IR')} تومان`,
priceValue: Number(data.priceValue),
category: data.category,
categorySlug: data.categorySlug,
unit: data.unit,
packageSize: data.packageSize,
dosage_logic: data.dosageLogic,
benefits: data.benefits,
suitableFor: data.suitableFor as PetType,
storage: data.storage,
specialBadge: data.specialBadge,
onSetOfAction: data.onSetOfAction,
optimisticTemplate: data.optimisticTemplate,
feedingAdvice: data.feedingAdvice,
image: data.image,
main_ingredients: data.ingredients?.map((i: any) => i.ingredient) || [],
symptoms: data.symptoms?.map((s: any) => s.symptom) || [],
keyBenefits: safeParse(data.keyBenefits, []),
expectedResults: safeParse(data.expectedResults, []),
benefitsList: safeParse(data.benefitsList, []),
faqs: safeParse(data.faqs, []),
analysis: safeParse(data.analysis, {}),
specialist: safeParse(data.specialist, null),
relatedProducts: safeParse(data.relatedProducts, []),
contraindications: safeParse(data.contraindications, []),
calculateDosage: local?.calculateDosage || (() => ({ quantity: 0, unit: '', description: '' }))
};
}
public async getProducts(filters?: {
category?: string;
petType?: PetType | "all";
query?: string;
}): Promise<Product[]> {
// TODO: [BACKEND_API] GET /api/products | Payload: { category?: string, petType?: string, query?: string } | Expected: Product[] | Errors: [500]
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 600));
const params = new URLSearchParams();
if (filters?.category && filters.category !== "all") params.append('category', filters.category);
if (filters?.petType && filters.petType !== "all") params.append('petType', filters.petType);
if (filters?.query) params.append('query', filters.query);
let filtered = [...PRODUCTS];
if (filters?.category && filters.category !== "all") {
const target = filters.category.trim().toLowerCase();
console.log("[ProductService] Filtering by Category (Input):", target);
filtered = filtered.filter(p => {
const catName = p.category.trim().toLowerCase();
const catSlug = (p.categorySlug || "").trim().toLowerCase();
const match = catName === target || catSlug === target;
if (match) {
console.log(`[ProductService] Found Match: ${p.name} (Cat: ${catName}, Slug: ${catSlug})`);
}
return match;
});
try {
const response = await api.get(`/products?${params.toString()}`);
return response.data.map((item: any) => this.mapBackendToFrontend(item));
} catch (error) {
console.error("[ProductService] Failed to fetch products:", error);
return [];
}
if (filters?.petType && filters.petType !== "all") {
filtered = filtered.filter(p => p.suitableFor === filters.petType || p.suitableFor === "هر دو");
}
if (filters?.query) {
const q = filters.query.toLowerCase();
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
p.symptoms.some(s => s.toLowerCase().includes(q))
);
}
return filtered;
}
public async getProductById(id: string): Promise<Product | null> {
await new Promise(resolve => setTimeout(resolve, 300));
return PRODUCTS.find(p => p.id === id) || null;
try {
const response = await api.get(`/products/${id}`);
return this.mapBackendToFrontend(response.data);
} catch (error) {
console.error(`[ProductService] Failed to fetch product ${id}:`, error);
return null;
}
}
public async getFeaturedProducts(): Promise<Product[]> {
await new Promise(resolve => setTimeout(resolve, 400));
return PRODUCTS.slice(0, 4);
try {
const response = await api.get('/products');
return response.data.slice(0, 4).map((item: any) => this.mapBackendToFrontend(item));
} catch (error) {
console.error("[ProductService] Failed to fetch featured products:", error);
return [];
}
}
}

View File

@ -1,6 +1,8 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { Product } from "../data/products";
import { orderService } from "../services/orderService";
export interface CartItem {
product: Product;
@ -34,9 +36,10 @@ interface CartStore {
toggleSubscription: () => void;
setCharityDonation: (amount: number) => void;
applyCoupon: (code: string) => boolean;
addOrder: (order: Omit<Order, 'id' | 'date' | 'status' | 'trackingNumber'>) => string;
addOrder: (order: Omit<Order, 'id' | 'date' | 'status' | 'trackingNumber'>) => Promise<string>;
clearCart: () => void;
removeCoupon: () => void;
setOrders: (orders: any[]) => void;
getTotalItems: () => number;
getSubtotal: () => number;
getDiscount: () => number;
@ -93,21 +96,52 @@ export const useCartStore = create<CartStore>()(
}
return false;
},
addOrder: (orderData) => {
// TODO: [BACKEND_API] POST /api/orders | Payload: Omit<Order, 'id' | 'date' | 'status' | 'trackingNumber'> | Expected: Order | Errors: [400, 401]
const id = `CN-${Math.floor(Math.random() * 90000) + 10000}`;
const newOrder: Order = {
...orderData,
id,
date: new Date().toISOString(),
status: 'processing',
trackingNumber: `IR-${Math.floor(Math.random() * 900000) + 100000}`
addOrder: async (orderData) => {
const payload = {
petId: orderData.petId,
items: orderData.items.map(i => ({
productId: i.product.id,
quantity: i.quantity
}))
};
set({ orders: [newOrder, ...get().orders] });
return id;
try {
const backendOrder = await orderService.createOrder(payload);
const newOrder: Order = {
...orderData,
id: backendOrder.id,
date: backendOrder.createdAt,
total: Number(backendOrder.totalAmount),
charityDonation: Number(backendOrder.charityDonation),
status: backendOrder.status as any,
trackingNumber: backendOrder.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
};
set({ orders: [newOrder, ...get().orders] });
return backendOrder.id;
} catch (error: any) {
console.error("Order creation failed on backend:", error);
throw error;
}
},
clearCart: () => set({ items: [], coupon: null }),
removeCoupon: () => set({ coupon: null }),
setOrders: (backendOrders) => {
const mappedOrders: Order[] = backendOrders.map(bo => ({
id: bo.id,
date: bo.createdAt,
items: bo.orderItems?.map((oi: any) => ({
product: oi.product,
quantity: oi.quantity
})) || [],
total: Number(bo.totalAmount),
charityDonation: Number(bo.charityDonation),
status: bo.status,
trackingNumber: bo.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
}));
set({ orders: mappedOrders });
},
getTotalItems: () => get().items.reduce((acc, item) => acc + item.quantity, 0),
getSubtotal: () => get().items.reduce((acc, item) => acc + item.product.priceValue * item.quantity, 0),
getDiscount: () => {

View File

@ -0,0 +1,59 @@
import { create } from "zustand";
import api from "../services/api";
export interface ScientificTerm {
key: string;
term: string;
definition: string;
wikiId: string;
}
interface SettingsStore {
texts: Record<string, string>;
scientificTerms: Record<string, ScientificTerm>;
isLoading: boolean;
fetchSettings: () => Promise<void>;
getText: (key: string, fallback: string) => string;
}
export const useSettingsStore = create<SettingsStore>()((set, get) => ({
texts: {},
scientificTerms: {},
isLoading: false,
fetchSettings: async () => {
set({ isLoading: true });
try {
const [textsRes, termsRes] = await Promise.all([
api.get('/settings/ui-texts'),
api.get('/settings/scientific-terms'),
]);
const textsObj: Record<string, string> = {};
if (Array.isArray(textsRes.data)) {
textsRes.data.forEach((item: { key: string; value: string }) => {
textsObj[item.key] = item.value;
});
}
const termsObj: Record<string, ScientificTerm> = {};
if (Array.isArray(termsRes.data)) {
termsRes.data.forEach((item: ScientificTerm) => {
termsObj[item.key] = item;
});
}
set({ texts: textsObj, scientificTerms: termsObj });
} catch (error) {
console.error("Failed to fetch settings/ui-texts:", error);
} finally {
set({ isLoading: false });
}
},
getText: (key, fallback) => {
const value = get().texts[key];
if (value === undefined || value === null) {
return fallback;
}
return value;
}
}));

View File

@ -51,6 +51,7 @@ interface PetStore {
addReminder: (petId: string, reminder: Omit<Reminder, "id" | "completedDates">) => void;
toggleReminder: (petId: string, reminderId: string, date: string) => void;
addHealthLog: (petId: string, log: Omit<HealthLog, "id" | "date">) => void;
setPets: (pets: any[]) => void;
}
export const usePetStore = create<PetStore>()(
@ -176,6 +177,37 @@ export const usePetStore = create<PetStore>()(
)
}));
},
setPets: (backendPets) => {
const mappedPets: PetProfile[] = backendPets.map(bp => ({
id: bp.id,
name: bp.name,
type: bp.type as any,
breed: bp.breed,
age: Number(bp.age),
weight: Number(bp.weight),
activityLevel: bp.activityLevel as any,
medicalConditions: bp.medicalConditions?.map((mc: any) => mc.condition) || [],
image: bp.imageUrl || undefined,
reminders: bp.reminders?.map((r: any) => ({
id: r.id,
title: r.title,
time: r.time,
frequency: r.frequency as any,
productId: r.productId,
completedDates: r.completions?.map((c: any) => c.completedDate) || []
})) || [],
logs: bp.healthLogs?.map((hl: any) => ({
id: hl.id,
date: hl.loggedDate,
appetite: hl.appetite as any,
energy: hl.energy as any,
digestion: hl.digestion as any,
note: hl.note || undefined
})) || [],
consumptions: []
}));
set({ pets: mappedPets, activePetId: get().activePetId && mappedPets.some(p => p.id === get().activePetId) ? get().activePetId : (mappedPets[0]?.id || null) });
},
}),
{ name: "canina-pets" }
)

View File

@ -1,5 +1,11 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { authService } from "../services/authService";
import api from "../services/api";
import { useCartStore } from "./cartStore";
import { usePetStore } from "./usePetStore";
export type UserRole = "User_Guest" | "User_PetOwner" | "User_Partner";
@ -27,6 +33,7 @@ interface UserProfile {
firstName: string;
lastName: string;
email: string;
mobile: string;
walletBalance: number;
charityDonationTotal: number;
addresses: Address[];
@ -39,13 +46,14 @@ interface UserStore {
profile: UserProfile;
setRole: (role: UserRole) => void;
setLoggedIn: (isLoggedIn: boolean) => void;
updateProfile: (profile: Partial<UserProfile>) => void;
addAddress: (address: Address) => void;
updateAddress: (id: string, address: Address) => void;
deleteAddress: (id: string) => void;
setDefaultAddress: (id: string) => void;
updateProfile: (profile: Partial<UserProfile>) => Promise<void>;
addAddress: (address: Address) => Promise<void>;
updateAddress: (id: string, address: Address) => Promise<void>;
deleteAddress: (id: string) => Promise<void>;
setDefaultAddress: (id: string) => Promise<void>;
topUpWallet: (amount: number) => void;
logout: () => void;
fetchProfile: () => Promise<void>;
}
export const useUserStore = create<UserStore>()(
@ -57,6 +65,7 @@ export const useUserStore = create<UserStore>()(
firstName: "پارسا",
lastName: "آقایی",
email: "parsa.aghaee@gmail.com",
mobile: "09121234567",
walletBalance: 2450000,
charityDonationTotal: 45000,
addresses: [
@ -79,48 +88,61 @@ export const useUserStore = create<UserStore>()(
},
setRole: (role) => set({ role }),
setLoggedIn: (isLoggedIn) => set({ isLoggedIn }),
updateProfile: (updates) => {
// TODO: [BACKEND_API] PATCH /api/user/profile | Payload: Partial<UserProfile> | Expected: UserProfile | Errors: [400, 401]
set((state) => ({ profile: { ...state.profile, ...updates } }));
updateProfile: async (updates) => {
try {
const profileData = await authService.updateProfile(updates);
set((state) => ({
profile: {
...state.profile,
firstName: profileData.firstName,
lastName: profileData.lastName,
email: profileData.email,
mobile: profileData.mobile || "",
}
}));
} catch (error) {
console.error("Failed to update profile:", error);
throw error;
}
},
addAddress: (address) => {
// TODO: [BACKEND_API] POST /api/user/addresses | Payload: Omit<Address, "id"> | Expected: Address | Errors: [400, 401]
set((state) => ({
profile: {
...state.profile,
addresses: [...(state.profile.addresses || []), address]
}
}));
addAddress: async (address) => {
try {
// Remove ID so backend generates UUID
const { id, ...addressData } = address;
await api.post('/users/addresses', addressData);
await useUserStore.getState().fetchProfile();
} catch (error) {
console.error("Failed to add address:", error);
throw error;
}
},
updateAddress: (id, updated) => {
// TODO: [BACKEND_API] PATCH /api/user/addresses/{id} | Payload: Address | Expected: Address | Errors: [400, 401, 404]
set((state) => ({
profile: {
...state.profile,
addresses: state.profile.addresses.map(a => a.id === id ? updated : a)
}
}));
updateAddress: async (id, updated) => {
try {
const { id: _, ...addressData } = updated;
await api.patch(`/users/addresses/${id}`, addressData);
await useUserStore.getState().fetchProfile();
} catch (error) {
console.error("Failed to update address:", error);
throw error;
}
},
deleteAddress: (id) => {
// TODO: [BACKEND_API] DELETE /api/user/addresses/{id} | Expected: { success: boolean } | Errors: [401, 404]
set((state) => ({
profile: {
...state.profile,
addresses: state.profile.addresses.filter(a => a.id !== id)
}
}));
deleteAddress: async (id) => {
try {
await api.delete(`/users/addresses/${id}`);
await useUserStore.getState().fetchProfile();
} catch (error) {
console.error("Failed to delete address:", error);
throw error;
}
},
setDefaultAddress: (id) => {
// TODO: [BACKEND_API] POST /api/user/addresses/{id}/set-default | Expected: { success: boolean } | Errors: [401, 404]
set((state) => ({
profile: {
...state.profile,
addresses: state.profile.addresses.map(a => ({
...a,
isDefault: a.id === id
}))
}
}));
setDefaultAddress: async (id) => {
try {
await api.patch(`/users/addresses/${id}/default`);
await useUserStore.getState().fetchProfile();
} catch (error) {
console.error("Failed to set default address:", error);
throw error;
}
},
topUpWallet: (amount) => {
// TODO: [BACKEND_API] POST /api/user/wallet/top-up | Payload: { amount: number } | Expected: { transactionId: string, newBalance: number } | Errors: [400, 401]
@ -141,8 +163,55 @@ export const useUserStore = create<UserStore>()(
};
})},
logout: () => {
// TODO: [BACKEND_API] POST /api/auth/logout | Expected: { success: boolean } | Errors: [401]
set({ role: "User_Guest", isLoggedIn: false });
authService.logout();
set({ role: "User_Guest", isLoggedIn: false, profile: {
firstName: "",
lastName: "",
email: "",
mobile: "",
walletBalance: 0,
charityDonationTotal: 0,
addresses: [],
transactions: []
}});
},
fetchProfile: async () => {
try {
const profileData = await authService.getProfile();
set({
isLoggedIn: true,
role: (profileData.role as UserRole) || "User_PetOwner",
profile: {
firstName: profileData.firstName,
lastName: profileData.lastName,
email: profileData.email,
mobile: profileData.mobile || "",
walletBalance: Number(profileData.walletBalance),
charityDonationTotal: Number(profileData.charityDonationTotal),
addresses: (profileData as any).addresses || [],
transactions: (profileData as any).walletTransactions?.map((t: any) => ({
id: t.id,
type: t.type === 'deposit' ? 'top_up' : 'purchase',
amount: Number(t.amount),
date: t.createdAt,
status: t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending'
})) || []
}
});
// Sync orders list to CartStore
if (profileData.orders) {
useCartStore.getState().setOrders(profileData.orders);
}
// Sync pets list to PetStore
if (profileData.pets) {
usePetStore.getState().setPets(profileData.pets);
}
} catch (error) {
console.error("Failed to fetch user profile:", error);
authService.logout();
set({ isLoggedIn: false, role: "User_Guest" });
}
},
}),
{ name: "canina-user" }

View File

@ -22,5 +22,7 @@
},
"allowImportingTsExtensions": true,
"noEmit": true
}
},
"include": ["src/**/*", "vite.config.ts"],
"exclude": ["node_modules", "backend", "dist"]
}

View File

@ -21,6 +21,12 @@ export default defineConfig(({mode}) => {
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
};
});