feat: connect UI texts, address CRUD, OTP autofocus and profile to backend
This commit is contained in:
parent
4e2c8d5cfe
commit
467532713c
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.git
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
backend
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
6
backend/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.git
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
4
backend/.prettierrc
Normal file
4
backend/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
38
backend/Dockerfile
Normal file
38
backend/Dockerfile
Normal 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
98
backend/README.md
Normal 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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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
35
backend/eslint.config.mjs
Normal 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
8
backend/nest-cli.json
Normal 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
10608
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
88
backend/package.json
Normal file
88
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
260
backend/prisma/migrations/20260526145407_init/migration.sql
Normal file
260
backend/prisma/migrations/20260526145407_init/migration.sql
Normal 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;
|
||||
@ -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")
|
||||
);
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
242
backend/prisma/schema.prisma
Normal file
242
backend/prisma/schema.prisma
Normal 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
212
backend/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
22
backend/src/app.controller.spec.ts
Normal file
22
backend/src/app.controller.spec.ts
Normal 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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
backend/src/app.controller.ts
Normal file
12
backend/src/app.controller.ts
Normal 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
16
backend/src/app.module.ts
Normal 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 {}
|
||||
8
backend/src/app.service.ts
Normal file
8
backend/src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
27
backend/src/auth/auth.controller.ts
Normal file
27
backend/src/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
backend/src/auth/auth.module.ts
Normal file
22
backend/src/auth/auth.module.ts
Normal 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 {}
|
||||
66
backend/src/auth/auth.service.ts
Normal file
66
backend/src/auth/auth.service.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
10
backend/src/auth/dto/send-otp.dto.ts
Normal file
10
backend/src/auth/dto/send-otp.dto.ts
Normal 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;
|
||||
}
|
||||
16
backend/src/auth/dto/verify-otp.dto.ts
Normal file
16
backend/src/auth/dto/verify-otp.dto.ts
Normal 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;
|
||||
}
|
||||
12
backend/src/auth/jwt-auth.guard.ts
Normal file
12
backend/src/auth/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
backend/src/auth/jwt.strategy.ts
Normal file
23
backend/src/auth/jwt.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
backend/src/common/filters/http-exception.filter.ts
Normal file
19
backend/src/common/filters/http-exception.filter.ts
Normal 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
28
backend/src/main.ts
Normal 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();
|
||||
28
backend/src/orders/dto/create-order.dto.ts
Normal file
28
backend/src/orders/dto/create-order.dto.ts
Normal 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[];
|
||||
}
|
||||
31
backend/src/orders/orders.controller.ts
Normal file
31
backend/src/orders/orders.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
backend/src/orders/orders.module.ts
Normal file
9
backend/src/orders/orders.module.ts
Normal 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 {}
|
||||
69
backend/src/orders/orders.service.ts
Normal file
69
backend/src/orders/orders.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
backend/src/pets/dto/create-pet.dto.ts
Normal file
44
backend/src/pets/dto/create-pet.dto.ts
Normal 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;
|
||||
}
|
||||
4
backend/src/pets/dto/update-pet.dto.ts
Normal file
4
backend/src/pets/dto/update-pet.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreatePetDto } from './create-pet.dto';
|
||||
|
||||
export class UpdatePetDto extends PartialType(CreatePetDto) {}
|
||||
44
backend/src/pets/pets.controller.ts
Normal file
44
backend/src/pets/pets.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
backend/src/pets/pets.module.ts
Normal file
9
backend/src/pets/pets.module.ts
Normal 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 {}
|
||||
61
backend/src/pets/pets.service.ts
Normal file
61
backend/src/pets/pets.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal 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 {}
|
||||
13
backend/src/prisma/prisma.service.ts
Normal file
13
backend/src/prisma/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
19
backend/src/products/dto/get-products.dto.ts
Normal file
19
backend/src/products/dto/get-products.dto.ts
Normal 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;
|
||||
}
|
||||
28
backend/src/products/products.controller.ts
Normal file
28
backend/src/products/products.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
backend/src/products/products.module.ts
Normal file
9
backend/src/products/products.module.ts
Normal 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 {}
|
||||
49
backend/src/products/products.service.ts
Normal file
49
backend/src/products/products.service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
9
backend/src/redis/redis.module.ts
Normal file
9
backend/src/redis/redis.module.ts
Normal 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 {}
|
||||
34
backend/src/redis/redis.service.ts
Normal file
34
backend/src/redis/redis.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
backend/src/settings/settings.controller.ts
Normal file
51
backend/src/settings/settings.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/settings/settings.module.ts
Normal file
12
backend/src/settings/settings.module.ts
Normal 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 {}
|
||||
46
backend/src/settings/settings.service.ts
Normal file
46
backend/src/settings/settings.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
44
backend/src/users/dto/address.dto.ts
Normal file
44
backend/src/users/dto/address.dto.ts
Normal 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;
|
||||
}
|
||||
24
backend/src/users/dto/update-profile.dto.ts
Normal file
24
backend/src/users/dto/update-profile.dto.ts
Normal 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;
|
||||
}
|
||||
64
backend/src/users/users.controller.ts
Normal file
64
backend/src/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
backend/src/users/users.module.ts
Normal file
10
backend/src/users/users.module.ts
Normal 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 {}
|
||||
111
backend/src/users/users.service.ts
Normal file
111
backend/src/users/users.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
29
backend/test/app.e2e-spec.ts
Normal file
29
backend/test/app.e2e-spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
9
backend/test/jest-e2e.json
Normal file
9
backend/test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
backend/tsconfig.json
Normal file
25
backend/tsconfig.json
Normal 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
53
docker-compose.yml
Normal 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
21
nginx.conf
Normal 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
140
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
54
src/App.tsx
54
src/App.tsx
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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('-', ' ')))
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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}</>;
|
||||
|
||||
|
||||
@ -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
23
src/services/api.ts
Normal 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
102
src/services/authService.ts
Normal 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();
|
||||
79
src/services/orderService.ts
Normal file
79
src/services/orderService.ts
Normal 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();
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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: () => {
|
||||
|
||||
59
src/store/settingsStore.ts
Normal file
59
src/store/settingsStore.ts
Normal 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;
|
||||
}
|
||||
}));
|
||||
@ -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" }
|
||||
)
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -22,5 +22,7 @@
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "backend", "dist"]
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user