first
26
.dockerignore
Normal file
@ -0,0 +1,26 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
.npm
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.eslintcache
|
||||
.nyc_output
|
||||
coverage
|
||||
.DS_Store
|
||||
*.log
|
||||
build
|
||||
dist
|
||||
.cache
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
.dockerignore
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
3
.env.development
Normal file
@ -0,0 +1,3 @@
|
||||
VITE_API_BASE_URL=https://api.vitrown.abrino.cloud
|
||||
VITE_API_SOCKET_BASE_URL=wss://api.vitrown.abrino.cloud
|
||||
VITE_SUPPORT_PHONE=+989223740993
|
||||
23
.env.example
Normal file
@ -0,0 +1,23 @@
|
||||
# API Configuration
|
||||
# K8s Dev: api.vitrown.abrino.cloud | Production: api.vitrown.com
|
||||
VITE_API_BASE_URL=https://api.vitrown.abrino.cloud
|
||||
VITE_API_SOCKET_BASE_URL=wss://api.vitrown.abrino.cloud
|
||||
VITE_API_URL=https://api.vitrown.abrino.cloud
|
||||
|
||||
# Application Environment
|
||||
VITE_APP_VERSION_TYPE=development
|
||||
NODE_ENV=development
|
||||
|
||||
# Site Configuration
|
||||
# K8s Dev: vitrown.abrino.cloud | Production: vitrown.com
|
||||
VITE_SITE_URL=https://vitrown.abrino.cloud
|
||||
VITE_DOMAIN_URL=https://vitrown.abrino.cloud
|
||||
|
||||
# Session Secret (REQUIRED - Change in production!)
|
||||
REMIX_SECRET=your-secret-key-here
|
||||
|
||||
# Optional Build Configuration
|
||||
VITE_BUILD_TARGET=development
|
||||
|
||||
# Support Configuration
|
||||
VITE_SUPPORT_PHONE=+989223740993
|
||||
21
.env.production
Normal file
@ -0,0 +1,21 @@
|
||||
# Production Environment Configuration
|
||||
NODE_ENV=production
|
||||
|
||||
# API Configuration
|
||||
VITE_API_BASE_URL=https://api.prod.vitrown.com
|
||||
VITE_API_SOCKET_BASE_URL=wss://api.prod.vitrown.com
|
||||
VITE_API_URL=https://api.prod.vitrown.com
|
||||
|
||||
# Application Environment
|
||||
VITE_APP_VERSION_TYPE=production
|
||||
|
||||
# Site Configuration
|
||||
VITE_SITE_URL=https://vitrown.com
|
||||
VITE_DOMAIN_URL=https://vitrown.com
|
||||
|
||||
# Session Secret (REQUIRED - Set via secrets in CI/CD)
|
||||
REMIX_SECRET=change-me-in-production
|
||||
|
||||
# Build Configuration
|
||||
VITE_BUILD_TARGET=production
|
||||
VITE_SUPPORT_PHONE=+989223740993
|
||||
81
.eslintrc.cjs
Normal file
@ -0,0 +1,81 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
},
|
||||
ignorePatterns: ["!**/.server", "!**/.client"],
|
||||
|
||||
// Base config
|
||||
extends: ["eslint:recommended"],
|
||||
|
||||
overrides: [
|
||||
// React
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
plugins: ["react", "jsx-a11y"],
|
||||
extends: [
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
formComponents: ["Form"],
|
||||
linkComponents: [
|
||||
{ name: "Link", linkAttribute: "to" },
|
||||
{ name: "NavLink", linkAttribute: "to" },
|
||||
],
|
||||
"import/resolver": {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Typescript
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: ["@typescript-eslint", "import"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
settings: {
|
||||
"import/internal-regex": "^~/",
|
||||
"import/resolver": {
|
||||
node: {
|
||||
extensions: [".ts", ".tsx"],
|
||||
},
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
extends: [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "warn", // Change from error to warning
|
||||
},
|
||||
},
|
||||
|
||||
// Node
|
||||
{
|
||||
files: [".eslintrc.cjs"],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
111
.gitignore
vendored
Normal file
@ -0,0 +1,111 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# Build outputs
|
||||
/build
|
||||
/dist
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
.cursor/
|
||||
.claude/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
/coverage
|
||||
*.lcov
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
190
DEPLOYMENT.md
Normal file
@ -0,0 +1,190 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses a **Blue-Green deployment** strategy with **automatic rollback** capabilities to ensure zero-downtime deployments and quick recovery from failures.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Blue-Green Deployment
|
||||
- **Blue Service**: Currently serving production traffic
|
||||
- **Green Service**: New deployment being tested
|
||||
- **Traffic Switch**: Instant switchover via Traefik labels
|
||||
- **Automatic Rollback**: Health check failures trigger immediate rollback
|
||||
|
||||
### Key Features
|
||||
- ✅ **Zero-downtime deployments**
|
||||
- ✅ **Automatic health checks**
|
||||
- ✅ **Instant rollback on failure**
|
||||
- ✅ **Backup system with retention**
|
||||
- ✅ **Build verification before traffic switch**
|
||||
- ✅ **Resource limits and security**
|
||||
|
||||
## Deployment Process
|
||||
|
||||
### Automatic Deployment (Recommended)
|
||||
|
||||
1. **Create Release Tag**:
|
||||
```bash
|
||||
git tag 1.0.1-release
|
||||
git push origin 1.0.1-release
|
||||
```
|
||||
|
||||
2. **Monitor Progress**:
|
||||
- GitHub Actions: `https://github.com/sotoontech/Vitron-Front/actions`
|
||||
- Server logs: `ssh root@prod-server 'docker-compose -f /opt/front-prod/Vitron-Front/docker-compose.yml logs -f'`
|
||||
|
||||
### Deployment Steps (Automatic)
|
||||
|
||||
1. **Backup**: Current deployment tagged as backup
|
||||
2. **Build**: New version built in parallel (green/blue)
|
||||
3. **Health Check**: 10 attempts over 100 seconds
|
||||
4. **Traffic Switch**: Instant switchover via Traefik
|
||||
5. **Verification**: Final check on live domain
|
||||
6. **Cleanup**: Old deployment stopped and cleaned
|
||||
|
||||
## Manual Operations
|
||||
|
||||
### Emergency Rollback
|
||||
|
||||
```bash
|
||||
# SSH to production server
|
||||
ssh root@prod-server
|
||||
|
||||
# Go to project directory
|
||||
cd /opt/front-prod/Vitron-Front
|
||||
|
||||
# Rollback to latest backup
|
||||
./scripts/rollback.sh
|
||||
|
||||
# Or rollback to specific backup
|
||||
./scripts/rollback.sh vitron-frontend-backup:20240906_150230
|
||||
```
|
||||
|
||||
### Check Status
|
||||
|
||||
```bash
|
||||
# Check running containers
|
||||
docker ps | grep vitron
|
||||
|
||||
# Check available backups
|
||||
docker images | grep vitron-frontend-backup
|
||||
|
||||
# Check service health
|
||||
docker exec vitron-frontend-blue wget --spider http://localhost:3000/
|
||||
curl -I https://vitrown.com/
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f vitron-frontend-blue
|
||||
docker-compose logs -f vitron-frontend-green
|
||||
```
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
```bash
|
||||
# SSH to server
|
||||
ssh root@prod-server
|
||||
cd /opt/front-prod/Vitron-Front
|
||||
|
||||
# Pull latest changes
|
||||
git fetch --all
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
# Deploy manually
|
||||
docker-compose -f docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Required GitHub Secrets
|
||||
- `PROD_SSH_HOST`: Production server IP/hostname
|
||||
- `PROD_SSH_KEY`: SSH private key for server access
|
||||
- `PROD_REMIX_SECRET`: Strong random string for session encryption
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
VITE_API_BASE_URL=https://api.prod.vitrown.com
|
||||
VITE_API_SOCKET_BASE_URL=wss://api.prod.vitrown.com
|
||||
VITE_API_URL=https://api.prod.vitrown.com
|
||||
VITE_SITE_URL=https://vitrown.com
|
||||
VITE_DOMAIN_URL=https://vitrown.com
|
||||
VITE_APP_VERSION_TYPE=production
|
||||
REMIX_SECRET=[FROM_GITHUB_SECRETS]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Build Failed**
|
||||
```bash
|
||||
# Check build logs
|
||||
docker-compose logs vitron-frontend-[color]
|
||||
|
||||
# Manual build test
|
||||
docker-compose build vitron-frontend-blue
|
||||
```
|
||||
|
||||
2. **Health Check Failed**
|
||||
```bash
|
||||
# Check container status
|
||||
docker exec vitron-frontend-[color] ps aux
|
||||
|
||||
# Check internal health
|
||||
docker exec vitron-frontend-[color] wget --spider http://localhost:3000/
|
||||
|
||||
# Check logs
|
||||
docker exec vitron-frontend-[color] tail -f /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
3. **Rollback Issues**
|
||||
```bash
|
||||
# List available backups
|
||||
docker images | grep backup
|
||||
|
||||
# Manual container start
|
||||
docker run -d --name manual-rollback vitron-frontend-backup:20240906_150230
|
||||
```
|
||||
|
||||
### Health Check Endpoints
|
||||
- Internal: `http://localhost:3000/` (container health)
|
||||
- External: `https://vitrown.com/` (public endpoint)
|
||||
- API: `https://api.prod.vitrown.com/api/health/` (backend health)
|
||||
|
||||
## Security Features
|
||||
|
||||
- **Non-root user**: Containers run as `remix` user (UID 1001)
|
||||
- **Security headers**: X-Frame-Options, X-Content-Type-Options, etc.
|
||||
- **HTTPS enforcement**: Automatic redirect from HTTP
|
||||
- **Resource limits**: Memory limits prevent resource exhaustion
|
||||
- **Network isolation**: Services run in isolated Docker network
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Key Metrics to Monitor
|
||||
- Container health status
|
||||
- Response time on health checks
|
||||
- Memory usage (limit: 512MB)
|
||||
- Disk space (Docker images)
|
||||
- SSL certificate expiry
|
||||
|
||||
### Log Locations
|
||||
- Application logs: `docker-compose logs`
|
||||
- Traefik logs: Check proxy network logs
|
||||
- System logs: `/var/log/syslog` on server
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
- **Automatic**: Every deployment creates timestamped backup
|
||||
- **Retention**: Last 3 backups kept automatically
|
||||
- **Manual backup**: `docker tag vitron-frontend-blue vitron-frontend-backup:manual-$(date +%Y%m%d_%H%M%S)`
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
- **Multi-stage build**: Smaller production images
|
||||
- **Asset optimization**: Terser minification, tree shaking
|
||||
- **Caching**: Docker layer caching, npm ci
|
||||
- **Resource limits**: Prevents memory leaks
|
||||
- **Health checks**: Quick failure detection
|
||||
53
Dockerfile
Normal file
@ -0,0 +1,53 @@
|
||||
# Multi-stage build for production
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install ALL dependencies (including dev) for build
|
||||
RUN npm ci && npm cache clean --force
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Clear any existing build artifacts and build fresh
|
||||
RUN rm -rf build node_modules/.vite node_modules/.cache
|
||||
|
||||
# Build the application
|
||||
RUN NODE_ENV=production npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only production dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S remix -u 1001
|
||||
|
||||
# Change ownership of app directory
|
||||
RUN chown -R remix:nodejs /app
|
||||
USER remix
|
||||
|
||||
# Set production environment
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
|
||||
|
||||
# Start production server
|
||||
CMD ["npm", "start"]
|
||||
20
Dockerfile.dev
Normal file
@ -0,0 +1,20 @@
|
||||
# Development Dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Set development environment
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5173
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
390
app/assets/data/consts.tsx
Normal file
@ -0,0 +1,390 @@
|
||||
export const years = [
|
||||
...Array(104)
|
||||
.fill(1)
|
||||
.map((_, number) => {
|
||||
return {
|
||||
value: 1404 - number,
|
||||
label: JSON.stringify(1404 - number),
|
||||
};
|
||||
}),
|
||||
];
|
||||
export const days = [
|
||||
...Array(31)
|
||||
.fill(1)
|
||||
.map((_, number) => {
|
||||
return {
|
||||
value: number + 1,
|
||||
label: JSON.stringify(number + 1),
|
||||
};
|
||||
}),
|
||||
];
|
||||
export const months = [
|
||||
{
|
||||
value: 1,
|
||||
label: "فروردین",
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: "اردیبهشت",
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: "خرداد",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
label: "تیر",
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: "مرداد",
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
label: "شهریور",
|
||||
},
|
||||
{
|
||||
value: 7,
|
||||
label: "مهر",
|
||||
},
|
||||
{
|
||||
value: 8,
|
||||
label: "آبان",
|
||||
},
|
||||
{
|
||||
value: 9,
|
||||
label: "آذر",
|
||||
},
|
||||
{
|
||||
value: 10,
|
||||
label: "دی",
|
||||
},
|
||||
{
|
||||
value: 11,
|
||||
label: "بهمن",
|
||||
},
|
||||
{
|
||||
value: 12,
|
||||
label: "اسفند",
|
||||
},
|
||||
];
|
||||
export const SearchSuggestionsData = [
|
||||
// Products - Apparel - Men
|
||||
{ type: "product", title: "پیراهن مردانه آستین بلند" },
|
||||
{ type: "product", title: "پیراهن مردانه آستین کوتاه" },
|
||||
{ type: "product", title: "پیراهن مردانه رسمی" },
|
||||
{ type: "product", title: "پیراهن مردانه اسپرت" },
|
||||
{ type: "product", title: "شلوار مردانه جین" },
|
||||
{ type: "product", title: "شلوار مردانه کتان" },
|
||||
{ type: "product", title: "شلوار مردانه پارچه ای" },
|
||||
{ type: "product", title: "شلوارک مردانه" },
|
||||
{ type: "product", title: "کت تک مردانه" },
|
||||
{ type: "product", title: "کت و شلوار مردانه دامادی" },
|
||||
{ type: "product", title: "جلیقه مردانه" },
|
||||
{ type: "product", title: "تیشرت مردانه یقه گرد" },
|
||||
{ type: "product", title: "تیشرت مردانه یقه هفت" },
|
||||
{ type: "product", title: "پولوشرت مردانه" },
|
||||
{ type: "product", title: "هودی مردانه" },
|
||||
{ type: "product", title: "سویشرت مردانه" },
|
||||
{ type: "product", title: "زیرپوش مردانه" },
|
||||
{ type: "product", title: "لباس خواب مردانه" },
|
||||
{ type: "product", title: "جوراب کالج مردانه" },
|
||||
{ type: "product", title: "جوراب ساق بلند مردانه" },
|
||||
{ type: "product", title: "ست لباس ورزشی مردانه" },
|
||||
{ type: "product", title: "شلوار ورزشی مردانه" },
|
||||
{ type: "product", title: "کاپشن چرم مردانه" },
|
||||
{ type: "product", title: "کاپشن زمستانه مردانه" },
|
||||
{ type: "product", title: "بارانی مردانه" },
|
||||
{ type: "product", title: "پالتو فوتر مردانه" },
|
||||
{ type: "product", title: "ژاکت بافتنی مردانه" },
|
||||
{ type: "product", title: "پلیور یقه اسکی مردانه" },
|
||||
|
||||
// Products - Apparel - Women
|
||||
{ type: "product", title: "مانتو مجلسی" },
|
||||
{ type: "product", title: "مانتو اسپرت" },
|
||||
{ type: "product", title: "مانتو اداری" },
|
||||
{ type: "product", title: "مانتو کتی" },
|
||||
{ type: "product", title: "شومیز حریر" },
|
||||
{ type: "product", title: "بلوز زنانه" },
|
||||
{ type: "product", title: "تونیک زنانه" },
|
||||
{ type: "product", title: "سارافون زنانه" },
|
||||
{ type: "product", title: "شلوار جین زنانه" },
|
||||
{ type: "product", title: "شلوار پارچه ای زنانه" },
|
||||
{ type: "product", title: "لگینگ زنانه" },
|
||||
{ type: "product", title: "دامن کوتاه" },
|
||||
{ type: "product", title: "دامن بلند" },
|
||||
{ type: "product", title: "دامن مجلسی" },
|
||||
{ type: "product", title: "پیراهن مجلسی زنانه" },
|
||||
{ type: "product", title: "پیراهن ساحلی" },
|
||||
{ type: "product", title: "لباس شب زنانه" },
|
||||
{ type: "product", title: "سوتین" },
|
||||
{ type: "product", title: "شورت زنانه" },
|
||||
{ type: "product", title: "لباس خواب زنانه" },
|
||||
{ type: "product", title: "گن زنانه" },
|
||||
{ type: "product", title: "جوراب شلواری" },
|
||||
{ type: "product", title: "جوراب نازک زنانه" },
|
||||
{ type: "product", title: "ست لباس ورزشی زنانه" },
|
||||
{ type: "product", title: "تاپ ورزشی زنانه" },
|
||||
{ type: "product", title: "شلوارک ورزشی زنانه" },
|
||||
{ type: "product", title: "کاپشن چرم زنانه" },
|
||||
{ type: "product", title: "کاپشن زمستانه زنانه" },
|
||||
{ type: "product", title: "بارانی زنانه" },
|
||||
{ type: "product", title: "پالتو فوتر زنانه" },
|
||||
{ type: "product", title: "پانچو" },
|
||||
{ type: "product", title: "وست زنانه" },
|
||||
{ type: "product", title: "روپوش پزشکی" },
|
||||
{ type: "product", title: "لباس فرم اداری زنانه" },
|
||||
|
||||
// Products - Apparel - Children & Baby
|
||||
{ type: "product", title: "لباس نوزادی دخترانه" },
|
||||
{ type: "product", title: "لباس نوزادی پسرانه" },
|
||||
{ type: "product", title: "بادی نوزاد" },
|
||||
{ type: "product", title: "رامپر نوزاد" },
|
||||
{ type: "product", title: "ست بیمارستانی نوزاد" },
|
||||
{ type: "product", title: "پیشبند نوزاد" },
|
||||
{ type: "product", title: "کلاه نوزادی" },
|
||||
{ type: "product", title: "تیشرت بچگانه" },
|
||||
{ type: "product", title: "شلوار بچگانه" },
|
||||
{ type: "product", title: "پیراهن دخترانه" },
|
||||
{ type: "product", title: "سارافون دخترانه" },
|
||||
{ type: "product", title: "لباس مجلسی دخترانه" },
|
||||
{ type: "product", title: "کاپشن بچگانه" },
|
||||
{ type: "product", title: "لباس شنا بچگانه" },
|
||||
|
||||
// Products - General Apparel
|
||||
{ type: "product", title: "کلاه کپ" },
|
||||
{ type: "product", title: "کلاه بافتنی" },
|
||||
{ type: "product", title: "شال گردن پشمی" },
|
||||
{ type: "product", title: "شال گردن نخی" },
|
||||
{ type: "product", title: "دستکش چرمی" },
|
||||
{ type: "product", title: "دستکش بافتنی" },
|
||||
{ type: "product", title: "لباس محلی" },
|
||||
{ type: "product", title: "لباس سنتی" },
|
||||
|
||||
// Products - Shoes - Men
|
||||
{ type: "product", title: "کفش چرم مردانه" },
|
||||
{ type: "product", title: "کالج مردانه" },
|
||||
{ type: "product", title: "کفش ورزشی مردانه (رانینگ، فوتبال، بسکتبال)" },
|
||||
{ type: "product", title: "نیم بوت مردانه" },
|
||||
{ type: "product", title: "صندل طبیعت گردی مردانه" },
|
||||
{ type: "product", title: "دمپایی مردانه" },
|
||||
|
||||
// Products - Shoes - Women
|
||||
{ type: "product", title: "کفش چرم زنانه" },
|
||||
{ type: "product", title: "کالج زنانه" },
|
||||
{ type: "product", title: "کفش تخت زنانه" },
|
||||
{ type: "product", title: "کفش لژدار زنانه" },
|
||||
{ type: "product", title: "کفش ورزشی زنانه (فیتنس، یوگا)" },
|
||||
{ type: "product", title: "نیم بوت زنانه" },
|
||||
{ type: "product", title: "صندل مجلسی زنانه" },
|
||||
{ type: "product", title: "دمپایی زنانه" },
|
||||
{ type: "product", title: "پاپوش زنانه" },
|
||||
|
||||
// Products - Shoes - Children
|
||||
{ type: "product", title: "کفش نوزادی" },
|
||||
{ type: "product", title: "کتانی بچگانه" },
|
||||
{ type: "product", title: "صندل بچگانه" },
|
||||
{ type: "product", title: "بوت بچگانه" },
|
||||
|
||||
// Products - Bags - Women
|
||||
{ type: "product", title: "کیف مجلسی زنانه" },
|
||||
{ type: "product", title: "کیف کتابی زنانه (کلاچ)" },
|
||||
{ type: "product", title: "کیف کمری زنانه" },
|
||||
{ type: "product", title: "ساک ورزشی زنانه" },
|
||||
|
||||
// Products - Bags - Men
|
||||
{ type: "product", title: "کیف سامسونت مردانه" },
|
||||
{ type: "product", title: "کیف کمری مردانه" },
|
||||
{ type: "product", title: "ساک ورزشی مردانه" },
|
||||
{ type: "product", title: "کیف لپتاپ" },
|
||||
|
||||
// Products - Bags - General
|
||||
{ type: "product", title: "کوله پشتی مدرسه" },
|
||||
{ type: "product", title: "کوله پشتی کوهنوردی" },
|
||||
{ type: "product", title: "ساک خرید" },
|
||||
|
||||
// Products - Accessories - General
|
||||
{ type: "product", title: "عینک طبی" },
|
||||
{ type: "product", title: "بند عینک" },
|
||||
{ type: "product", title: "دکمه سردست" },
|
||||
{ type: "product", title: "سنجاق سینه" },
|
||||
{ type: "product", title: "سنجاق کراوات" },
|
||||
{ type: "product", title: "گیره مو" },
|
||||
{ type: "product", title: "تل مو" },
|
||||
{ type: "product", title: "تاج عروس" },
|
||||
{ type: "product", title: "پاپوش" },
|
||||
{ type: "product", title: "ساسبند (بند شلوار)" },
|
||||
|
||||
// Shops - General Clothing & Department Stores
|
||||
{
|
||||
type: "shop",
|
||||
id: "1",
|
||||
title: "فروشگاه پوشاک خانواده",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "2",
|
||||
title: "فروشگاه زنجیره ای پوشاک",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1483985988355-763728e1935b?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "3",
|
||||
title: "مرکز خرید پوشاک",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1555529669-e6910eb125b6?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "4",
|
||||
title: "پاساژ لباس",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1591085686350-798c0f9faa7f?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "5",
|
||||
title: "اوت لت پوشاک",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1560243563-062bfc001d68?w=500&auto=format&fit=crop",
|
||||
},
|
||||
|
||||
// Shops - Specific Apparel Types
|
||||
{
|
||||
type: "shop",
|
||||
id: "6",
|
||||
title: "فروشگاه لباس زیر",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1603217041454-99e679540093?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "7",
|
||||
title: "فروشگاه لباس خواب",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1585519105003-c89177b34699?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "8",
|
||||
title: "فروشگاه جوراب",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1613690150715-070f407fff94?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "9",
|
||||
title: "فروشگاه تخصصی مانتو",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1595965781570-496f87b1d3c0?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
title: "فروشگاه کت و شلوار",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1509319117193-57bab727e09d?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "10",
|
||||
title: "فروشگاه لباس کودک و نوزاد",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1520923642038-b42e596efc03?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "11",
|
||||
title: "فروشگاه لباس بارداری",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1581027912560-1a0db3e9983f?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "12",
|
||||
title: "فروشگاه لباس سایز بزرگ",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1607008028064-325720103504?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "13",
|
||||
title: "فروشگاه لباس فرم و کار",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1529338296731-c1290a08ccc4?w=500&auto=format&fit=crop",
|
||||
},
|
||||
|
||||
// Shops - Accessories & More
|
||||
{
|
||||
type: "shop",
|
||||
id: "14",
|
||||
title: "فروشگاه زیورآلات و بدلیجات",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1610041321327-07ac523e5804?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "15",
|
||||
title: "گالری شال و روسری",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1563059269-3a38d1ae50a1?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "16",
|
||||
title: "فروشگاه تخصصی کلاه",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1534215754734-18e1d14349ae?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "17",
|
||||
title: "فروشگاه چمدان و کیف سفر",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1581403341094-189d3f0f8f15?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "18",
|
||||
title: "خیاطی و مزون شخصی دوزی",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1579084806155-11518b23ac96?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "19",
|
||||
title: "خشکشویی و خدمات لباس",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1580828343791-9fe69e70f611?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "20",
|
||||
title: "فروشگاه پارچه",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1549061528-434734449753?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "21",
|
||||
title: "نمایندگی برندهای معروف پوشاک (مثال: زارا، ال سی وایکیکی)",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1551488831-00ddcb6ac6bd?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "22",
|
||||
title: "فروشگاه آنلاین مد و پوشاک",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1462392246756-320a5667a952?w=500&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
type: "shop",
|
||||
id: "23",
|
||||
title: "بازار سنتی پوشاک",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1542359649-31e03cdde49b?w=500&auto=format&fit=crop",
|
||||
},
|
||||
];
|
||||
4
app/assets/icons/cart/basket.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.4" d="M11.361 33.1372C13.021 33.1372 14.381 34.4972 14.381 36.1772C14.381 37.8372 13.021 39.1972 11.361 39.1972C9.68104 39.1972 8.32104 37.8372 8.32104 36.1772C8.32104 34.4972 9.68104 33.1372 11.361 33.1372ZM33.861 33.1372C35.521 33.1372 36.881 34.4972 36.881 36.1772C36.881 37.8372 35.521 39.1972 33.861 39.1972C32.181 39.1972 30.821 37.8372 30.821 36.1772C30.821 34.4972 32.181 33.1372 33.861 33.1372Z" fill="#4C4A50"/>
|
||||
<path d="M2.26148 0.0161727L7.03148 0.736173C7.71148 0.858173 8.21148 1.41617 8.27148 2.09617L8.65148 6.57617C8.71148 7.21817 9.23148 7.69817 9.87148 7.69817H36.8815C38.1015 7.69817 38.9015 8.11817 39.7015 9.03817C40.5015 9.95817 40.6415 11.2782 40.4615 12.4762L38.5615 25.5962C38.2015 28.1182 36.0415 29.9762 33.5015 29.9762H11.6815C9.02148 29.9762 6.82148 27.9362 6.60148 25.2982L4.76148 3.49617L1.74148 2.97617C0.941484 2.83617 0.381484 2.05617 0.521484 1.25617C0.661484 0.436173 1.44148 -0.103827 2.26148 0.0161727ZM30.3015 15.0962H24.7615C23.9215 15.0962 23.2615 15.7562 23.2615 16.5962C23.2615 17.4162 23.9215 18.0962 24.7615 18.0962H30.3015C31.1415 18.0962 31.8015 17.4162 31.8015 16.5962C31.8015 15.7562 31.1415 15.0962 30.3015 15.0962Z" fill="#4C4A50"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
5
app/assets/icons/cart/failure_pay.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.62794 40.8735H38.3939C41.5579 40.8735 43.5439 37.4535 41.9719 34.7055L27.5999 9.5755C26.0179 6.8095 22.0299 6.8075 20.4459 9.5735L6.04994 34.7035C4.47794 37.4515 6.46194 40.8735 9.62794 40.8735Z" stroke="#FF3B30" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24.0049 26.8294V20.6294" stroke="#FF3B30" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M23.99 33H24.01" stroke="#FF3B30" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
3
app/assets/icons/cart/sucess_pay.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M43.602 20C44.5153 24.4826 43.8644 29.1428 41.7577 33.2036C39.6509 37.2643 36.2157 40.4801 32.025 42.3146C27.8342 44.1491 23.1412 44.4915 18.7286 43.2847C14.3159 42.0779 10.4503 39.3948 7.77651 35.6828C5.10268 31.9709 3.7822 27.4545 4.03528 22.8868C4.28837 18.319 6.09971 13.9762 9.16726 10.5823C12.2348 7.18848 16.3731 4.94884 20.8921 4.23689C25.4111 3.52493 30.0375 4.3837 34 6.66998M18 22L24 28L44 7.99998" stroke="#34C759" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 605 B |
BIN
app/assets/icons/filter-dark.png
Normal file
|
After Width: | Height: | Size: 578 B |
BIN
app/assets/icons/filter.png
Normal file
|
After Width: | Height: | Size: 677 B |
4
app/assets/icons/login/verify-banner.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="99" height="70" viewBox="0 0 99 70" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M65.625 0H29.375C26.6172 0 24.375 2.24219 24.375 5V65C24.375 67.7578 26.6172 70 29.375 70H65.625C68.3828 70 70.625 67.7578 70.625 65V5C70.625 2.24219 68.3828 0 65.625 0ZM65 64.375H30V5.625H65V64.375ZM44.375 56.4062C44.375 57.2351 44.7042 58.0299 45.2903 58.616C45.8763 59.202 46.6712 59.5312 47.5 59.5312C48.3288 59.5312 49.1237 59.202 49.7097 58.616C50.2958 58.0299 50.625 57.2351 50.625 56.4062C50.625 55.5774 50.2958 54.7826 49.7097 54.1965C49.1237 53.6105 48.3288 53.2812 47.5 53.2812C46.6712 53.2812 45.8763 53.6105 45.2903 54.1965C44.7042 54.7826 44.375 55.5774 44.375 56.4062Z" fill="#95939A"/>
|
||||
<path d="M84.625 12.8333V12.845M79.9583 12.8333V12.845M89.2917 12.8333V12.845M91.625 4.66667C92.5533 4.66667 93.4435 5.03542 94.0999 5.6918C94.7563 6.34818 95.125 7.23841 95.125 8.16667V17.5C95.125 18.4283 94.7563 19.3185 94.0999 19.9749C93.4435 20.6313 92.5533 21 91.625 21H85.7917L79.9583 24.5V21H77.625C76.6967 21 75.8065 20.6313 75.1501 19.9749C74.4937 19.3185 74.125 18.4283 74.125 17.5V8.16667C74.125 7.23841 74.4937 6.34818 75.1501 5.6918C75.8065 5.03542 76.6967 4.66667 77.625 4.66667H91.625Z" stroke="#147EFB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
app/assets/icons/navbar/buy-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.92631 18.9079C8.75567 18.9079 9.43514 19.6018 9.43514 20.459C9.43514 21.3061 8.75567 22 7.92631 22C7.08697 22 6.4075 21.3061 6.4075 20.459C6.4075 19.6018 7.08697 18.9079 7.92631 18.9079ZM19.1676 18.9079C19.9969 18.9079 20.6764 19.6018 20.6764 20.459C20.6764 21.3061 19.9969 22 19.1676 22C18.3282 22 17.6487 21.3061 17.6487 20.459C17.6487 19.6018 18.3282 18.9079 19.1676 18.9079ZM3.38006 2.00874L5.7632 2.3751C6.10293 2.43735 6.35274 2.72207 6.38272 3.06905L6.57257 5.35499C6.60254 5.68257 6.86234 5.92749 7.18209 5.92749H20.6766C21.2861 5.92749 21.6858 6.1418 22.0855 6.61123C22.4852 7.08067 22.5551 7.7542 22.4652 8.36549L21.5159 15.06C21.3361 16.3469 20.2569 17.2949 18.9879 17.2949H8.08639C6.75742 17.2949 5.65828 16.255 5.54837 14.908L4.62908 3.7834L3.12026 3.51807C2.72057 3.44664 2.44079 3.04864 2.51073 2.64043C2.58068 2.22305 2.97038 1.94649 3.38006 2.00874ZM17.3891 9.70236H14.6213C14.2016 9.70236 13.8719 10.0391 13.8719 10.4677C13.8719 10.8861 14.2016 11.2331 14.6213 11.2331H17.3891C17.8088 11.2331 18.1386 10.8861 18.1386 10.4677C18.1386 10.0391 17.8088 9.70236 17.3891 9.70236Z" fill="#222526"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
app/assets/icons/navbar/buy-outline.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.04352 19.9518C8.75752 19.9518 9.33852 20.5318 9.33852 21.2458C9.33852 21.9598 8.75752 22.5408 8.04352 22.5408C7.32952 22.5408 6.74952 21.9598 6.74952 21.2458C6.74952 20.5318 7.32952 19.9518 8.04352 19.9518ZM19.3238 19.9518C20.0388 19.9518 20.6198 20.5318 20.6198 21.2458C20.6198 21.9598 20.0388 22.5408 19.3238 22.5408C18.6098 22.5408 18.0298 21.9598 18.0298 21.2458C18.0298 20.5318 18.6098 19.9518 19.3238 19.9518ZM3.76772 3.00943L5.84772 3.36943C6.18272 3.42843 6.43772 3.70643 6.46672 4.04643L6.70172 6.84743L7.57557 6.8478C7.71816 6.84787 7.85925 6.84793 7.99884 6.84799L9.60314 6.84875C9.73102 6.84882 9.85747 6.84888 9.98251 6.84895L11.7539 6.84992C11.8652 6.84998 11.9751 6.85005 12.0838 6.85011L13.3251 6.85091C13.4234 6.85097 13.5205 6.85104 13.6164 6.85111L14.7082 6.85191C14.7944 6.85198 14.8794 6.85205 14.9633 6.85211L15.9152 6.85293C15.9901 6.853 16.0639 6.85307 16.1366 6.85314L16.7623 6.85376C16.8287 6.85383 16.894 6.8539 16.9584 6.85397L17.6829 6.85481C17.7394 6.85488 17.795 6.85495 17.8496 6.85502L18.4614 6.85588C18.5088 6.85595 18.5554 6.85602 18.6011 6.85609L18.9899 6.85674C19.0306 6.85681 19.0706 6.85688 19.1097 6.85696L19.5428 6.85784C19.5759 6.85791 19.6083 6.85798 19.64 6.85806L19.9066 6.85872C19.9342 6.8588 19.9611 6.85887 19.9874 6.85895L20.2732 6.85985C20.2946 6.85992 20.3154 6.86 20.3357 6.86007L20.5035 6.86076C20.5205 6.86084 20.5371 6.86091 20.5531 6.86099L20.7229 6.86191C20.7352 6.86199 20.7471 6.86207 20.7586 6.86215L20.8511 6.86285C20.8602 6.86293 20.869 6.863 20.8774 6.86308L20.944 6.86379C20.9504 6.86387 20.9565 6.86395 20.9624 6.86403L21.0077 6.86475C21.012 6.86483 21.016 6.86491 21.0198 6.86499L21.0485 6.86572C21.0511 6.8658 21.0535 6.86588 21.0558 6.86596L21.0766 6.86694L21.0801 6.86718C21.0898 6.86809 21.0927 6.86843 21.0927 6.86843C21.6497 6.94943 22.1397 7.24043 22.4737 7.68843C22.8077 8.13543 22.9477 8.68643 22.8677 9.23843L21.9187 15.7964C21.7397 17.0444 20.6557 17.9854 19.3957 17.9854H8.47472C7.15772 17.9854 6.04272 16.9574 5.93572 15.6424L5.01972 4.74843L3.51272 4.48843C3.10372 4.41643 2.83072 4.02943 2.90072 3.62043C2.97272 3.21143 3.36772 2.94543 3.76772 3.00943ZM7.37495 8.34777L6.82772 8.34743L7.43072 15.5194C7.47472 16.0714 7.92572 16.4854 8.47672 16.4854H19.3937C19.9147 16.4854 20.3597 16.0974 20.4337 15.5824L21.3837 9.02343C21.4057 8.86743 21.3667 8.71143 21.2717 8.58543C21.1777 8.45843 21.0397 8.37643 20.8837 8.35443C20.8765 8.3547 20.8592 8.35495 20.8324 8.35518L20.7236 8.35581C20.7009 8.3559 20.6759 8.35599 20.6486 8.35607L20.0821 8.35693C20.0343 8.35696 19.9846 8.35699 19.9331 8.35702L18.6011 8.35696C18.5268 8.35693 18.4511 8.3569 18.374 8.35687L16.8539 8.35595C16.7628 8.35588 16.6707 8.35581 16.5777 8.35574L15.7176 8.35505C15.6196 8.35497 15.5209 8.35489 15.4215 8.3548L14.5105 8.354C14.4076 8.35391 14.3042 8.35381 14.2004 8.35372L13.5728 8.35314C13.4675 8.35304 13.3619 8.35295 13.256 8.35285L12.2983 8.35195C12.1915 8.35185 12.0847 8.35175 11.9778 8.35165L11.3372 8.35106C11.2306 8.35096 11.1241 8.35086 11.0178 8.35076L10.3826 8.35019C10.2772 8.35009 10.1722 8.35 10.0675 8.34991L9.13699 8.34911C9.03507 8.34902 8.93369 8.34894 8.83293 8.34885L7.65701 8.34796C7.56211 8.34789 7.46807 8.34783 7.37495 8.34777ZM17.7876 10.5437C18.2016 10.5437 18.5376 10.8797 18.5376 11.2937C18.5376 11.7077 18.2016 12.0437 17.7876 12.0437H15.0156C14.6006 12.0437 14.2656 11.7077 14.2656 11.2937C14.2656 10.8797 14.6006 10.5437 15.0156 10.5437H17.7876Z" fill="#95939A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
3
app/assets/icons/navbar/document-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.191 2C19.28 2 21 3.78 21 6.83V17.16C21 20.26 19.28 22 16.191 22H7.81C4.77 22 3 20.26 3 17.16V6.83C3 3.78 4.77 2 7.81 2H16.191ZM8.08 15.74C7.78 15.71 7.49 15.85 7.33 16.11C7.17 16.36 7.17 16.69 7.33 16.95C7.49 17.2 7.78 17.35 8.08 17.31H15.92C16.319 17.27 16.62 16.929 16.62 16.53C16.62 16.12 16.319 15.78 15.92 15.74H8.08ZM15.92 11.179H8.08C7.649 11.179 7.3 11.53 7.3 11.96C7.3 12.39 7.649 12.74 8.08 12.74H15.92C16.35 12.74 16.7 12.39 16.7 11.96C16.7 11.53 16.35 11.179 15.92 11.179ZM11.069 6.65H8.08V6.66C7.649 6.66 7.3 7.01 7.3 7.44C7.3 7.87 7.649 8.22 8.08 8.22H11.069C11.5 8.22 11.85 7.87 11.85 7.429C11.85 7 11.5 6.65 11.069 6.65Z" fill="#222526"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 771 B |
3
app/assets/icons/navbar/document-outline.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9088 2C19.0528 2 21.1648 4.153 21.1648 7.357V16.553C21.1648 19.785 19.1178 21.887 15.9498 21.907L8.25676 21.91C5.11276 21.91 2.99976 19.757 2.99976 16.553V7.357C2.99976 4.124 5.04676 2.023 8.21476 2.004L15.9078 2H15.9088ZM15.9088 3.5L8.21976 3.504C5.89176 3.518 4.49976 4.958 4.49976 7.357V16.553C4.49976 18.968 5.90476 20.41 8.25576 20.41L15.9448 20.407C18.2728 20.393 19.6648 18.951 19.6648 16.553V7.357C19.6648 4.942 18.2608 3.5 15.9088 3.5ZM15.7159 15.4737C16.1299 15.4737 16.4659 15.8097 16.4659 16.2237C16.4659 16.6377 16.1299 16.9737 15.7159 16.9737H8.49586C8.08186 16.9737 7.74586 16.6377 7.74586 16.2237C7.74586 15.8097 8.08186 15.4737 8.49586 15.4737H15.7159ZM15.7159 11.2872C16.1299 11.2872 16.4659 11.6232 16.4659 12.0372C16.4659 12.4512 16.1299 12.7872 15.7159 12.7872H8.49586C8.08186 12.7872 7.74586 12.4512 7.74586 12.0372C7.74586 11.6232 8.08186 11.2872 8.49586 11.2872H15.7159ZM11.2506 7.1104C11.6646 7.1104 12.0006 7.4464 12.0006 7.8604C12.0006 8.2744 11.6646 8.6104 11.2506 8.6104H8.49556C8.08156 8.6104 7.74556 8.2744 7.74556 7.8604C7.74556 7.4464 8.08156 7.1104 8.49556 7.1104H11.2506Z" fill="#95939A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
app/assets/icons/navbar/home-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.63478 20.7733V17.7156C9.63478 16.9351 10.2722 16.3023 11.0584 16.3023H13.9326C14.3102 16.3023 14.6723 16.4512 14.9393 16.7163C15.2063 16.9813 15.3563 17.3408 15.3563 17.7156V20.7733C15.3539 21.0978 15.4821 21.4099 15.7124 21.6402C15.9427 21.8705 16.2561 22 16.5829 22H18.5438C19.4596 22.0023 20.3388 21.6428 20.9872 21.0008C21.6356 20.3588 22 19.487 22 18.5778V9.86686C22 9.13246 21.6721 8.43584 21.1046 7.96467L14.434 2.67587C13.2737 1.74856 11.6111 1.7785 10.4854 2.74698L3.96701 7.96467C3.37274 8.42195 3.01755 9.12064 3 9.86686V18.5689C3 20.4639 4.54738 22 6.45617 22H8.37229C9.05123 22 9.603 21.4562 9.60792 20.7822L9.63478 20.7733Z" fill="#222526"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 771 B |
3
app/assets/icons/navbar/home-outline.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.65722 20.7714V17.7047C9.6572 16.9246 10.2931 16.2908 11.081 16.2856H13.9671C14.7587 16.2856 15.4005 16.9209 15.4005 17.7047V20.7809C15.4003 21.4432 15.9343 21.9845 16.603 22H18.5271C20.4451 22 22 20.4607 22 18.5618V9.83784C21.9898 9.09083 21.6355 8.38935 21.038 7.93303L14.4577 2.6853C13.3049 1.77157 11.6662 1.77157 10.5134 2.6853L3.96203 7.94256C3.36226 8.39702 3.00739 9.09967 3 9.84736V18.5618C3 20.4607 4.55488 22 6.47291 22H8.39696C9.08235 22 9.63797 21.4499 9.63797 20.7714" stroke="#95939A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 682 B |
3
app/assets/icons/navbar/products-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.21196 15.111L2.52496 12.424C2.4126 12.3115 2.34949 12.159 2.34949 12C2.34949 11.841 2.4126 11.6885 2.52496 11.576L5.21196 8.88898C5.32446 8.77662 5.47696 8.71351 5.63596 8.71351C5.79496 8.71351 5.94746 8.77662 6.05996 8.88898L8.74696 11.576C8.85932 11.6885 8.92243 11.841 8.92243 12C8.92243 12.159 8.85932 12.3115 8.74696 12.424L6.05996 15.111C5.94746 15.2233 5.79496 15.2865 5.63596 15.2865C5.47696 15.2865 5.32446 15.2233 5.21196 15.111ZM11.576 21.476L8.88896 18.789C8.83317 18.7333 8.78892 18.6671 8.75872 18.5942C8.72853 18.5214 8.71299 18.4433 8.71299 18.3645C8.71299 18.2856 8.72853 18.2076 8.75872 18.1347C8.78892 18.0619 8.83317 17.9957 8.88896 17.94L11.576 15.253C11.6885 15.1406 11.841 15.0775 12 15.0775C12.159 15.0775 12.3115 15.1406 12.424 15.253L15.111 17.94C15.2233 18.0525 15.2864 18.205 15.2864 18.364C15.2864 18.523 15.2233 18.6755 15.111 18.788L12.424 21.476C12.3115 21.5883 12.159 21.6515 12 21.6515C11.841 21.6515 11.6885 21.5883 11.576 21.476ZM11.576 8.74698L8.88896 6.05998C8.83317 6.00426 8.78892 5.93809 8.75872 5.86525C8.72853 5.79241 8.71299 5.71433 8.71299 5.63548C8.71299 5.55663 8.72853 5.47856 8.75872 5.40572C8.78892 5.33288 8.83317 5.26671 8.88896 5.21098L11.576 2.52398C11.6885 2.41162 11.841 2.34851 12 2.34851C12.159 2.34851 12.3115 2.41162 12.424 2.52398L15.111 5.21098C15.1667 5.26671 15.211 5.33288 15.2412 5.40572C15.2714 5.47856 15.2869 5.55663 15.2869 5.63548C15.2869 5.71433 15.2714 5.79241 15.2412 5.86525C15.211 5.93809 15.1667 6.00426 15.111 6.05998L12.424 8.74698C12.3115 8.85934 12.159 8.92245 12 8.92245C11.841 8.92245 11.6885 8.85934 11.576 8.74698ZM17.94 15.111L15.253 12.424C15.1406 12.3115 15.0775 12.159 15.0775 12C15.0775 11.841 15.1406 11.6885 15.253 11.576L17.94 8.88898C18.0525 8.77662 18.205 8.71351 18.364 8.71351C18.523 8.71351 18.6755 8.77662 18.788 8.88898L21.475 11.576C21.5873 11.6885 21.6504 11.841 21.6504 12C21.6504 12.159 21.5873 12.3115 21.475 12.424L18.788 15.111C18.6755 15.2233 18.523 15.2865 18.364 15.2865C18.205 15.2865 18.0525 15.2233 17.94 15.111Z" fill="#222526" stroke="#222526" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
3
app/assets/icons/navbar/products-outline.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.21196 15.111L2.52496 12.424C2.4126 12.3115 2.34949 12.159 2.34949 12C2.34949 11.841 2.4126 11.6885 2.52496 11.576L5.21196 8.88896C5.32446 8.7766 5.47696 8.71349 5.63596 8.71349C5.79496 8.71349 5.94746 8.7766 6.05996 8.88896L8.74696 11.576C8.85932 11.6885 8.92243 11.841 8.92243 12C8.92243 12.159 8.85932 12.3115 8.74696 12.424L6.05996 15.111C5.94746 15.2233 5.79496 15.2864 5.63596 15.2864C5.47696 15.2864 5.32446 15.2233 5.21196 15.111ZM11.576 21.475L8.88896 18.788C8.83317 18.7322 8.78892 18.6661 8.75872 18.5932C8.72853 18.5204 8.71299 18.4423 8.71299 18.3635C8.71299 18.2846 8.72853 18.2065 8.75872 18.1337C8.78892 18.0609 8.83317 17.9947 8.88896 17.939L11.576 15.252C11.6885 15.1396 11.841 15.0765 12 15.0765C12.159 15.0765 12.3115 15.1396 12.424 15.252L15.111 17.939C15.1667 17.9947 15.211 18.0609 15.2412 18.1337C15.2714 18.2065 15.2869 18.2846 15.2869 18.3635C15.2869 18.4423 15.2714 18.5204 15.2412 18.5932C15.211 18.6661 15.1667 18.7322 15.111 18.788L12.424 21.475C12.3115 21.5873 12.159 21.6504 12 21.6504C11.841 21.6504 11.6885 21.5873 11.576 21.475ZM11.576 8.74796L8.88896 6.05996C8.7766 5.94746 8.71349 5.79496 8.71349 5.63596C8.71349 5.47696 8.7766 5.32446 8.88896 5.21196L11.576 2.52496C11.6885 2.4126 11.841 2.34949 12 2.34949C12.159 2.34949 12.3115 2.4126 12.424 2.52496L15.111 5.21196C15.2233 5.32446 15.2864 5.47696 15.2864 5.63596C15.2864 5.79496 15.2233 5.94746 15.111 6.05996L12.424 8.74796C12.3115 8.86032 12.159 8.92343 12 8.92343C11.841 8.92343 11.6885 8.86032 11.576 8.74796ZM17.94 15.111L15.253 12.424C15.1406 12.3115 15.0775 12.159 15.0775 12C15.0775 11.841 15.1406 11.6885 15.253 11.576L17.94 8.88896C18.0525 8.7766 18.205 8.71349 18.364 8.71349C18.523 8.71349 18.6755 8.7766 18.788 8.88896L21.475 11.576C21.5873 11.6885 21.6504 11.841 21.6504 12C21.6504 12.159 21.5873 12.3115 21.475 12.424L18.788 15.111C18.6755 15.2233 18.523 15.2864 18.364 15.2864C18.205 15.2864 18.0525 15.2233 17.94 15.111Z" stroke="#95939A" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
4
app/assets/icons/navbar/profile-filled.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.7505 13.8307C8.83752 13.8307 5.40552 16.1327 5.40552 18.7557C5.40552 22.1307 10.9345 22.1307 12.7505 22.1307C14.5665 22.1307 20.0945 22.1307 20.0945 18.7337C20.0945 16.1217 16.6625 13.8307 12.7505 13.8307Z" fill="#222526"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.712 11.6423H12.743C15.438 11.6423 17.63 9.45026 17.63 6.75526C17.63 4.06126 15.438 1.86926 12.743 1.86926C10.048 1.86926 7.85605 4.06126 7.85605 6.75326C7.84705 9.43926 10.024 11.6323 12.712 11.6423Z" fill="#222526"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 651 B |
4
app/assets/icons/navbar/profile-outline.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4617 11.892H12.4927C15.3247 11.892 17.6287 9.58802 17.6287 6.75602C17.6287 3.92402 15.3247 1.61902 12.4927 1.61902C9.65975 1.61902 7.35575 3.92402 7.35575 6.75302C7.35075 8.12202 7.87975 9.41002 8.84375 10.381C9.80675 11.351 11.0917 11.888 12.4617 11.892ZM8.85575 6.75602C8.85575 4.75102 10.4877 3.11902 12.4927 3.11902C14.4977 3.11902 16.1287 4.75102 16.1287 6.75602C16.1287 8.76102 14.4977 10.392 12.4927 10.392H12.4647C11.4967 10.39 10.5897 10.01 9.90775 9.32302C9.22575 8.63702 8.85275 7.72602 8.85575 6.75602Z" fill="#95939A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.90552 18.7559C4.90552 22.3809 10.6215 22.3809 12.4995 22.3809C14.3775 22.3809 20.0945 22.3809 20.0945 18.7339C20.0945 15.9409 16.6165 13.5809 12.4995 13.5809C8.38352 13.5809 4.90552 15.9509 4.90552 18.7559ZM6.40552 18.7559C6.40552 17.0209 9.01152 15.0809 12.4995 15.0809C15.9885 15.0809 18.5945 17.0099 18.5945 18.7339C18.5945 20.1579 16.5435 20.8809 12.4995 20.8809C8.45652 20.8809 6.40552 20.1659 6.40552 18.7559Z" fill="#95939A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
app/assets/icons/navbar/search-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.1207 17.6543C18.5072 17.2704 19.1268 17.2704 19.5134 17.6543L22.068 19.7164H22.1124C22.6292 20.2388 22.6292 21.0858 22.1124 21.6082C21.5955 22.1306 20.7576 22.1306 20.2407 21.6082L18.1207 19.1785L18.0403 19.0877C17.8904 18.898 17.8076 18.6615 17.8076 18.4164C17.8076 18.1304 17.9203 17.8562 18.1207 17.6543ZM11.0776 2C13.3526 2 15.5343 2.91344 17.1429 4.53936C18.7516 6.16529 19.6553 8.37052 19.6553 10.6699C19.6553 15.4582 15.8149 19.3399 11.0776 19.3399C6.34034 19.3399 2.5 15.4582 2.5 10.6699C2.5 5.88166 6.34034 2 11.0776 2Z" fill="#222526"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 B |
4
app/assets/icons/navbar/search-outline.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.7485 3.50024C7.72315 3.50024 4.45996 6.76343 4.45996 10.7888C4.45996 14.8141 7.72315 18.0773 11.7485 18.0773C15.7738 18.0773 19.037 14.8141 19.037 10.7888C19.037 6.76343 15.7738 3.50024 11.7485 3.50024ZM2.95996 10.7888C2.95996 5.93501 6.89472 2.00024 11.7485 2.00024C16.6023 2.00024 20.537 5.93501 20.537 10.7888C20.537 15.6426 16.6023 19.5773 11.7485 19.5773C6.89472 19.5773 2.95996 15.6426 2.95996 10.7888Z" fill="#95939A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.2359 15.6477L22.8514 21.2486L21.7921 22.3106L16.1766 16.7097L17.2359 15.6477Z" fill="#95939A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
3
app/assets/icons/navbar/setting-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.7171 2.00012C13.4734 2.00012 14.1581 2.42012 14.5362 3.04012C14.7201 3.34012 14.8428 3.71012 14.8121 4.10012C14.7917 4.40012 14.8837 4.70012 15.0472 4.98012C15.5684 5.83012 16.7232 6.15012 17.6225 5.67012C18.6342 5.09012 19.9117 5.44012 20.4942 6.43012L21.1789 7.61012C21.7716 8.60012 21.4446 9.87012 20.4227 10.4401C19.554 10.9501 19.2474 12.0801 19.7686 12.9401C19.9321 13.2101 20.1161 13.4401 20.4022 13.5801C20.7599 13.7701 21.0358 14.0701 21.23 14.3701C21.6081 14.9901 21.5775 15.7501 21.2096 16.4201L20.4942 17.6201C20.1161 18.2601 19.4109 18.6601 18.6853 18.6601C18.3277 18.6601 17.9291 18.5601 17.6021 18.3601C17.3364 18.1901 17.0298 18.1301 16.7027 18.1301C15.691 18.1301 14.8428 18.9601 14.8121 19.9501C14.8121 21.1001 13.8719 22.0001 12.6967 22.0001H11.3068C10.1213 22.0001 9.18113 21.1001 9.18113 19.9501C9.16069 18.9601 8.31247 18.1301 7.30073 18.1301C6.96348 18.1301 6.6569 18.1901 6.40141 18.3601C6.07438 18.5601 5.6656 18.6601 5.31813 18.6601C4.58232 18.6601 3.87717 18.2601 3.49905 17.6201L2.7939 16.4201C2.41577 15.7701 2.39533 14.9901 2.77346 14.3701C2.93697 14.0701 3.24356 13.7701 3.59102 13.5801C3.87717 13.4401 4.06112 13.2101 4.23486 12.9401C4.74584 12.0801 4.43925 10.9501 3.57059 10.4401C2.55885 9.87012 2.23182 8.60012 2.81434 7.61012L3.49905 6.43012C4.09178 5.44012 5.35901 5.09012 6.38097 5.67012C7.27007 6.15012 8.42488 5.83012 8.94608 4.98012C9.10959 4.70012 9.20157 4.40012 9.18113 4.10012C9.16069 3.71012 9.27311 3.34012 9.46728 3.04012C9.8454 2.42012 10.5301 2.02012 11.2761 2.00012H12.7171ZM12.012 9.18012C10.4075 9.18012 9.10959 10.4401 9.10959 12.0101C9.10959 13.5801 10.4075 14.8301 12.012 14.8301C13.6164 14.8301 14.8837 13.5801 14.8837 12.0101C14.8837 10.4401 13.6164 9.18012 12.012 9.18012Z" fill="#222526"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
4
app/assets/icons/navbar/setting-outline.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 9.5C13.3809 9.5 14.5 10.6191 14.5 12C14.5 13.3809 13.3809 14.5 12 14.5C10.6191 14.5 9.5 13.3809 9.5 12C9.5 10.6191 10.6191 9.5 12 9.5Z" stroke="#95939A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.168 7.25025C19.4845 6.05799 17.9712 5.65004 16.7885 6.33852C15.7598 6.93613 14.4741 6.18838 14.4741 4.99218C14.4741 3.61619 13.3659 2.5 11.9998 2.5C10.6337 2.5 9.52546 3.61619 9.52546 4.99218C9.52546 6.18838 8.23977 6.93613 7.21199 6.33852C6.02829 5.65004 4.51507 6.05799 3.83153 7.25025C3.14896 8.4425 3.55399 9.96665 4.73769 10.6541C5.76546 11.2527 5.76546 12.7473 4.73769 13.3459C3.55399 14.0343 3.14896 15.5585 3.83153 16.7498C4.51507 17.942 6.02829 18.35 7.21101 17.6625C8.23879 17.0639 9.52546 17.8116 9.52546 19.0078C9.52546 20.3838 10.6337 21.5 11.9998 21.5C13.3659 21.5 14.4741 20.3838 14.4741 19.0078C14.4741 17.8116 15.7598 17.0639 16.7885 17.6625C17.9712 18.35 19.4845 17.942 20.168 16.7498C20.8515 15.5585 20.4455 14.0343 19.2628 13.3459C18.2351 12.7473 18.2341 11.2527 19.2628 10.6541C20.4455 9.96665 20.8515 8.4425 20.168 7.25025Z" stroke="#95939A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/assets/icons/product-dark.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
app/assets/icons/product.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
3
app/assets/icons/product/discount-red.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.90883 13.73L6.84783 12.669L12.3838 7.13401L13.4438 8.19497L7.90883 13.73ZM13.0748 13.357H11.5748L11.5668 11.857H13.0748V13.357ZM8.72183 7.50401V9.00397H7.22183L7.21583 7.50401H8.72183ZM19.8028 10.137C17.9308 8.71697 16.9678 6.39001 17.2868 4.06301L17.3768 3.41001L16.7228 3.50001C14.4028 3.82001 12.0688 2.85501 10.6488 0.983008L10.2508 0.458008L9.85183 0.983008C8.43183 2.85501 6.09883 3.82101 3.77783 3.50001L3.12483 3.41001L3.21483 4.06301C3.53383 6.39001 2.56983 8.71697 0.698828 10.137L0.173828 10.535L0.698828 10.934C2.56983 12.353 3.53383 14.68 3.21483 17.008L3.12483 17.661L3.77783 17.571C6.09783 17.253 8.43183 18.215 9.85183 20.087L10.2508 20.612L10.6488 20.087C12.0688 18.215 14.4038 17.254 16.7228 17.571L17.3768 17.661L17.2868 17.008C16.9678 14.68 17.9308 12.353 19.8028 10.934L20.3278 10.535L19.8028 10.137Z" fill="#FF3B30"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 995 B |
3
app/assets/icons/product/longArrow.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="112" height="8" viewBox="0 0 112 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M111 4.5C111.276 4.5 111.5 4.27614 111.5 4C111.5 3.72386 111.276 3.5 111 3.5L111 4.5ZM0.646446 3.64644C0.451187 3.8417 0.451187 4.15828 0.646446 4.35354L3.82843 7.53552C4.02369 7.73079 4.34027 7.73079 4.53554 7.53552C4.7308 7.34026 4.7308 7.02368 4.53554 6.82842L1.70711 3.99999L4.53554 1.17156C4.7308 0.976301 4.7308 0.659719 4.53554 0.464457C4.34027 0.269195 4.02369 0.269195 3.82843 0.464457L0.646446 3.64644ZM111 3.5L1 3.49999L1 4.49999L111 4.5L111 3.5Z" fill="#95939A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
3
app/assets/icons/product/tomanBlack.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="32" height="14" viewBox="0 0 32 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.95747 3.232C3.20547 2.848 3.46547 2.536 3.73747 2.296L4.55347 3.004C4.31347 3.38 4.06547 3.68 3.80947 3.904L2.95747 3.232ZM3.59347 12.268C2.31347 12.268 1.40547 12.008 0.869469 11.488C0.333469 10.976 0.0654688 10.308 0.0654688 9.484C0.0654688 9.012 0.129469 8.424 0.257469 7.72C0.393469 7.008 0.565469 6.204 0.773469 5.308L1.73347 5.56C1.28547 7.456 1.06147 8.744 1.06147 9.424C1.06147 10.136 1.28147 10.612 1.72147 10.852C2.16147 11.1 2.78547 11.224 3.59347 11.224C4.51347 11.224 5.25347 11.108 5.81347 10.876C6.37347 10.644 6.65347 10.232 6.65347 9.64C6.65347 9.16 6.59747 8.532 6.48547 7.756C6.38147 6.972 6.23347 6.02 6.04147 4.9L6.98947 4.54C7.21347 5.828 7.37747 6.88 7.48147 7.696C7.58547 8.512 7.63747 9.176 7.63747 9.688C7.63747 10.568 7.26547 11.216 6.52147 11.632C5.77747 12.056 4.80147 12.268 3.59347 12.268ZM12.2817 8.944C12.3537 8.944 12.4097 8.984 12.4497 9.064C12.4977 9.136 12.5217 9.228 12.5217 9.34V9.592C12.5217 9.704 12.4977 9.8 12.4497 9.88C12.4017 9.96 12.3457 10 12.2817 10C11.5057 10 10.9257 9.888 10.5417 9.664C10.1577 9.44 9.90166 9.1 9.77366 8.644C9.65366 8.188 9.59366 7.532 9.59366 6.676V4.42V0.412L10.5777 0.159999V7.228C10.5777 7.62 10.6017 7.936 10.6497 8.176C10.7057 8.408 10.8217 8.596 10.9977 8.74C11.1737 8.876 11.4417 8.944 11.8017 8.944H12.2817ZM16.6093 4.804C17.0493 4.804 17.4413 4.944 17.7853 5.224C18.1373 5.496 18.4093 5.86 18.6013 6.316C18.7933 6.764 18.8893 7.244 18.8893 7.756C18.8893 8.204 18.8333 8.644 18.7213 9.076C18.6173 9.508 18.4533 9.86 18.2293 10.132C18.0133 10.412 17.7493 10.552 17.4373 10.552C16.9813 10.552 16.4453 10.412 15.8293 10.132C15.2133 9.86 14.6933 9.52 14.2693 9.112C14.0773 9.376 13.8293 9.592 13.5253 9.76C13.2293 9.92 12.9053 10 12.5533 10H12.2773C12.2133 10 12.1573 9.96 12.1093 9.88C12.0613 9.8 12.0373 9.704 12.0373 9.592V9.34C12.0373 9.228 12.0613 9.136 12.1093 9.064C12.1493 8.984 12.2053 8.944 12.2773 8.944H12.5413C12.9173 8.944 13.2093 8.836 13.4173 8.62C13.6253 8.404 13.7773 8.1 13.8733 7.708C14.1533 6.636 14.5133 5.884 14.9533 5.452C15.4013 5.02 15.9533 4.804 16.6093 4.804ZM17.4733 9.508C17.7613 9.068 17.9053 8.488 17.9053 7.768C17.9053 7.424 17.8453 7.104 17.7253 6.808C17.6053 6.504 17.4413 6.264 17.2333 6.088C17.0333 5.904 16.8133 5.812 16.5733 5.812C16.0933 5.812 15.6933 6.044 15.3733 6.508C15.0613 6.972 14.8613 7.536 14.7733 8.2C15.1093 8.536 15.5133 8.832 15.9853 9.088C16.4653 9.344 16.9613 9.484 17.4733 9.508ZM26.7717 8.944C26.8437 8.944 26.8997 8.984 26.9397 9.064C26.9877 9.136 27.0117 9.228 27.0117 9.34V9.592C27.0117 9.704 26.9877 9.8 26.9397 9.88C26.8917 9.96 26.8357 10 26.7717 10H25.7157C25.3717 10.952 24.7597 11.732 23.8797 12.34C22.9997 12.956 21.9797 13.32 20.8197 13.432V12.388C21.8117 12.228 22.6437 11.94 23.3157 11.524C23.9877 11.108 24.4957 10.528 24.8397 9.784C24.4397 9.92 23.9197 9.988 23.2797 9.988C22.8397 9.988 22.4557 9.96 22.1277 9.904C21.8077 9.84 21.5237 9.68 21.2757 9.424C21.0277 9.16 20.9037 8.76 20.9037 8.224C20.9037 7.736 21.0077 7.196 21.2157 6.604C21.4317 6.004 21.7277 5.496 22.1037 5.08C22.4877 4.664 22.9197 4.456 23.3997 4.456C23.9037 4.456 24.3517 4.616 24.7437 4.936C25.1357 5.256 25.4397 5.712 25.6557 6.304C25.8797 6.896 25.9917 7.588 25.9917 8.38C25.9917 8.572 25.9797 8.76 25.9557 8.944H26.7717ZM21.8757 8.212C21.8757 8.452 21.9237 8.624 22.0197 8.728C22.1237 8.824 22.2677 8.884 22.4517 8.908C22.6437 8.932 22.9477 8.944 23.3637 8.944C23.9557 8.944 24.4957 8.884 24.9837 8.764C25.0157 8.636 25.0317 8.464 25.0317 8.248C25.0317 7.808 24.9677 7.372 24.8397 6.94C24.7197 6.508 24.5397 6.152 24.2997 5.872C24.0597 5.592 23.7717 5.452 23.4357 5.452C23.1637 5.452 22.9077 5.6 22.6677 5.896C22.4277 6.192 22.2357 6.56 22.0917 7C21.9477 7.432 21.8757 7.836 21.8757 8.212ZM29.1494 2.104C29.0134 2.344 28.7774 2.632 28.4414 2.968L27.6134 2.332C27.8774 1.94 28.1294 1.64 28.3694 1.432L29.1494 2.104ZM31.0334 2.176C30.8974 2.416 30.6614 2.704 30.3254 3.04L29.4974 2.404C29.7614 2.012 30.0134 1.712 30.2534 1.504L31.0334 2.176ZM30.7574 4.468C30.9494 5.868 31.0454 7.028 31.0454 7.948C31.0454 8.332 30.9094 8.68 30.6374 8.992C30.3734 9.304 29.9814 9.552 29.4614 9.736C28.9494 9.912 28.3294 10 27.6014 10H26.7734C26.7094 10 26.6534 9.96 26.6054 9.88C26.5574 9.8 26.5334 9.704 26.5334 9.592V9.34C26.5334 9.228 26.5574 9.136 26.6054 9.064C26.6454 8.984 26.7014 8.944 26.7734 8.944H27.6014C28.2494 8.944 28.8174 8.864 29.3054 8.704C29.8014 8.544 30.0494 8.288 30.0494 7.936C30.0494 7.12 29.9614 6.068 29.7854 4.78L30.7574 4.468Z" fill="#222526"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
3
app/assets/icons/product/tomanGray.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="32" height="14" viewBox="0 0 32 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.95747 3.232C3.20547 2.848 3.46547 2.536 3.73747 2.296L4.55347 3.004C4.31347 3.38 4.06547 3.68 3.80947 3.904L2.95747 3.232ZM3.59347 12.268C2.31347 12.268 1.40547 12.008 0.869469 11.488C0.333469 10.976 0.0654688 10.308 0.0654688 9.484C0.0654688 9.012 0.129469 8.424 0.257469 7.72C0.393469 7.008 0.565469 6.204 0.773469 5.308L1.73347 5.56C1.28547 7.456 1.06147 8.744 1.06147 9.424C1.06147 10.136 1.28147 10.612 1.72147 10.852C2.16147 11.1 2.78547 11.224 3.59347 11.224C4.51347 11.224 5.25347 11.108 5.81347 10.876C6.37347 10.644 6.65347 10.232 6.65347 9.64C6.65347 9.16 6.59747 8.532 6.48547 7.756C6.38147 6.972 6.23347 6.02 6.04147 4.9L6.98947 4.54C7.21347 5.828 7.37747 6.88 7.48147 7.696C7.58547 8.512 7.63747 9.176 7.63747 9.688C7.63747 10.568 7.26547 11.216 6.52147 11.632C5.77747 12.056 4.80147 12.268 3.59347 12.268ZM12.2817 8.944C12.3537 8.944 12.4097 8.984 12.4497 9.064C12.4977 9.136 12.5217 9.228 12.5217 9.34V9.592C12.5217 9.704 12.4977 9.8 12.4497 9.88C12.4017 9.96 12.3457 10 12.2817 10C11.5057 10 10.9257 9.888 10.5417 9.664C10.1577 9.44 9.90166 9.1 9.77366 8.644C9.65366 8.188 9.59366 7.532 9.59366 6.676V4.42V0.412L10.5777 0.159999V7.228C10.5777 7.62 10.6017 7.936 10.6497 8.176C10.7057 8.408 10.8217 8.596 10.9977 8.74C11.1737 8.876 11.4417 8.944 11.8017 8.944H12.2817ZM16.6093 4.804C17.0493 4.804 17.4413 4.944 17.7853 5.224C18.1373 5.496 18.4093 5.86 18.6013 6.316C18.7933 6.764 18.8893 7.244 18.8893 7.756C18.8893 8.204 18.8333 8.644 18.7213 9.076C18.6173 9.508 18.4533 9.86 18.2293 10.132C18.0133 10.412 17.7493 10.552 17.4373 10.552C16.9813 10.552 16.4453 10.412 15.8293 10.132C15.2133 9.86 14.6933 9.52 14.2693 9.112C14.0773 9.376 13.8293 9.592 13.5253 9.76C13.2293 9.92 12.9053 10 12.5533 10H12.2773C12.2133 10 12.1573 9.96 12.1093 9.88C12.0613 9.8 12.0373 9.704 12.0373 9.592V9.34C12.0373 9.228 12.0613 9.136 12.1093 9.064C12.1493 8.984 12.2053 8.944 12.2773 8.944H12.5413C12.9173 8.944 13.2093 8.836 13.4173 8.62C13.6253 8.404 13.7773 8.1 13.8733 7.708C14.1533 6.636 14.5133 5.884 14.9533 5.452C15.4013 5.02 15.9533 4.804 16.6093 4.804ZM17.4733 9.508C17.7613 9.068 17.9053 8.488 17.9053 7.768C17.9053 7.424 17.8453 7.104 17.7253 6.808C17.6053 6.504 17.4413 6.264 17.2333 6.088C17.0333 5.904 16.8133 5.812 16.5733 5.812C16.0933 5.812 15.6933 6.044 15.3733 6.508C15.0613 6.972 14.8613 7.536 14.7733 8.2C15.1093 8.536 15.5133 8.832 15.9853 9.088C16.4653 9.344 16.9613 9.484 17.4733 9.508ZM26.7717 8.944C26.8437 8.944 26.8997 8.984 26.9397 9.064C26.9877 9.136 27.0117 9.228 27.0117 9.34V9.592C27.0117 9.704 26.9877 9.8 26.9397 9.88C26.8917 9.96 26.8357 10 26.7717 10H25.7157C25.3717 10.952 24.7597 11.732 23.8797 12.34C22.9997 12.956 21.9797 13.32 20.8197 13.432V12.388C21.8117 12.228 22.6437 11.94 23.3157 11.524C23.9877 11.108 24.4957 10.528 24.8397 9.784C24.4397 9.92 23.9197 9.988 23.2797 9.988C22.8397 9.988 22.4557 9.96 22.1277 9.904C21.8077 9.84 21.5237 9.68 21.2757 9.424C21.0277 9.16 20.9037 8.76 20.9037 8.224C20.9037 7.736 21.0077 7.196 21.2157 6.604C21.4317 6.004 21.7277 5.496 22.1037 5.08C22.4877 4.664 22.9197 4.456 23.3997 4.456C23.9037 4.456 24.3517 4.616 24.7437 4.936C25.1357 5.256 25.4397 5.712 25.6557 6.304C25.8797 6.896 25.9917 7.588 25.9917 8.38C25.9917 8.572 25.9797 8.76 25.9557 8.944H26.7717ZM21.8757 8.212C21.8757 8.452 21.9237 8.624 22.0197 8.728C22.1237 8.824 22.2677 8.884 22.4517 8.908C22.6437 8.932 22.9477 8.944 23.3637 8.944C23.9557 8.944 24.4957 8.884 24.9837 8.764C25.0157 8.636 25.0317 8.464 25.0317 8.248C25.0317 7.808 24.9677 7.372 24.8397 6.94C24.7197 6.508 24.5397 6.152 24.2997 5.872C24.0597 5.592 23.7717 5.452 23.4357 5.452C23.1637 5.452 22.9077 5.6 22.6677 5.896C22.4277 6.192 22.2357 6.56 22.0917 7C21.9477 7.432 21.8757 7.836 21.8757 8.212ZM29.1494 2.104C29.0134 2.344 28.7774 2.632 28.4414 2.968L27.6134 2.332C27.8774 1.94 28.1294 1.64 28.3694 1.432L29.1494 2.104ZM31.0334 2.176C30.8974 2.416 30.6614 2.704 30.3254 3.04L29.4974 2.404C29.7614 2.012 30.0134 1.712 30.2534 1.504L31.0334 2.176ZM30.7574 4.468C30.9494 5.868 31.0454 7.028 31.0454 7.948C31.0454 8.332 30.9094 8.68 30.6374 8.992C30.3734 9.304 29.9814 9.552 29.4614 9.736C28.9494 9.912 28.3294 10 27.6014 10H26.7734C26.7094 10 26.6534 9.96 26.6054 9.88C26.5574 9.8 26.5334 9.704 26.5334 9.592V9.34C26.5334 9.228 26.5574 9.136 26.6054 9.064C26.6454 8.984 26.7014 8.944 26.7734 8.944H27.6014C28.2494 8.944 28.8174 8.864 29.3054 8.704C29.8014 8.544 30.0494 8.288 30.0494 7.936C30.0494 7.12 29.9614 6.068 29.7854 4.78L30.7574 4.468Z" fill="#95939A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
4
app/assets/icons/profile/bookmark/bookmark.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.4" d="M23.9824 37.2434L10.9989 43.7286C10.0184 44.2608 8.79537 43.9055 8.24697 42.929C8.08681 42.622 8.00212 42.2808 8 41.934V27.418C8 28.8571 8.81147 29.7455 10.946 30.7405L23.9824 37.2434Z" fill="#4C4A50"/>
|
||||
<path d="M30.1389 4C35.5546 4 39.9471 6.1321 40 11.5867V41.9337C39.9978 42.2748 39.9131 42.6102 39.753 42.9109C39.4959 43.4013 39.0519 43.7654 38.5229 43.9195C37.994 44.0736 37.4256 44.0046 36.9482 43.7282L23.9824 37.2431L10.946 30.7401C8.81147 29.7452 8 28.8568 8 27.4176V11.5867C8 6.1321 12.3925 4 17.7905 4H30.1389ZM31.4972 16.0819H16.4498C15.5827 16.0819 14.8798 16.7899 14.8798 17.6632C14.8798 18.5366 15.5827 19.2445 16.4498 19.2445H31.4972C32.3643 19.2445 33.0673 18.5366 33.0673 17.6632C33.0673 16.7899 32.3643 16.0819 31.4972 16.0819Z" fill="#4C4A50"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 891 B |
4
app/assets/icons/profile/location/location.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="49" height="48" viewBox="0 0 49 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.5632 5.87354C21.933 3.33455 27.3041 3.37893 31.6328 5.98979C35.9189 8.65383 38.5239 13.4084 38.4998 18.5229C38.3999 23.6039 35.6066 28.38 32.1149 32.0722C30.0997 34.2128 27.8452 36.1057 25.3977 37.7121C25.1456 37.8578 24.8695 37.9554 24.583 38C24.3072 37.9883 24.0386 37.9068 23.8015 37.7629C20.0649 35.3491 16.7867 32.2681 14.1247 28.6679C11.8972 25.6627 10.6317 22.032 10.5 18.2688L10.51 17.7214C10.6918 12.8093 13.3496 8.32186 17.5632 5.87354ZM26.3145 14.0695C24.5381 13.3145 22.4901 13.7247 21.1266 15.1087C19.7632 16.4927 19.3533 18.5774 20.0883 20.3896C20.8234 22.2017 22.5584 23.3837 24.4832 23.3837C25.7442 23.3928 26.9564 22.8877 27.8496 21.981C28.7429 21.0742 29.243 19.8413 29.2385 18.5568C29.2452 16.5961 28.0909 14.8246 26.3145 14.0695Z" fill="#4C4A50"/>
|
||||
<path opacity="0.4" d="M24.5 44C30.0228 44 34.5 43.1046 34.5 42C34.5 40.8954 30.0228 40 24.5 40C18.9772 40 14.5 40.8954 14.5 42C14.5 43.1046 18.9772 44 24.5 44Z" fill="#4C4A50"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
5
app/assets/icons/profile/orders/paper.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="34" height="40" viewBox="0 0 34 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M30.6176 14.042C29.7146 14.042 28.5185 14.022 27.0293 14.022C23.3974 14.022 20.4111 11.016 20.4111 7.35V0.918C20.4111 0.412 20.0071 0 19.5061 0H8.92725C3.99033 0 0 4.052 0 9.018V30.568C0 35.778 4.18044 40 9.33916 40H25.0926C30.0116 40 34 35.974 34 31.004V14.942C34 14.434 33.598 14.024 33.095 14.026C32.2494 14.032 31.2355 14.042 30.6176 14.042Z" fill="#4C4A50"/>
|
||||
<path opacity="0.4" d="M25.1684 1.13474C24.5704 0.51274 23.5264 0.94074 23.5264 1.80274V7.07674C23.5264 9.28874 25.3484 11.1087 27.5604 11.1087C28.9544 11.1247 30.8904 11.1287 32.5344 11.1247C33.3764 11.1227 33.8044 10.1167 33.2204 9.50874C31.1104 7.31474 27.3324 3.38274 25.1684 1.13474Z" fill="#4C4A50"/>
|
||||
<path d="M21.8362 25.7853C22.6582 25.7853 23.3262 26.4533 23.3262 27.2753C23.3262 28.0973 22.6582 28.7633 21.8362 28.7633H10.9482C10.1262 28.7633 9.46016 28.0973 9.46016 27.2753C9.46016 26.4533 10.1262 25.7853 10.9482 25.7853H21.8362ZM17.718 15.7969C18.54 15.7969 19.208 16.4649 19.208 17.2869C19.208 18.1089 18.54 18.7749 17.718 18.7749H10.948C10.126 18.7749 9.45996 18.1089 9.45996 17.2869C9.45996 16.4649 10.126 15.7969 10.948 15.7969H17.718Z" fill="#95939A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
app/assets/icons/profile/threads/messageBox.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="49" height="48" viewBox="0 0 49 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.4" d="M44.5 31.88C44.5 37.46 40.02 41.98 34.44 42H34.42H14.6C9.04 42 4.5 37.5 4.5 31.92V31.9C4.5 31.9 4.512 23.048 4.528 18.596C4.53 17.76 5.49 17.292 6.144 17.812C10.896 21.582 19.394 28.456 19.5 28.546C20.92 29.684 22.72 30.326 24.56 30.326C26.4 30.326 28.2 29.684 29.62 28.524C29.726 28.454 38.034 21.786 42.858 17.954C43.514 17.432 44.478 17.9 44.48 18.734C44.5 23.152 44.5 31.88 44.5 31.88Z" fill="#4C4A50"/>
|
||||
<path d="M43.4518 11.347C41.7198 8.08302 38.3118 5.99902 34.5598 5.99902H14.5998C10.8478 5.99902 7.43977 8.08302 5.70777 11.347C5.31977 12.077 5.50377 12.987 6.14977 13.503L20.9998 25.381C22.0398 26.221 23.2998 26.639 24.5598 26.639C24.5678 26.639 24.5738 26.639 24.5798 26.639C24.5858 26.639 24.5938 26.639 24.5998 26.639C25.8598 26.639 27.1198 26.221 28.1598 25.381L43.0098 13.503C43.6558 12.987 43.8398 12.077 43.4518 11.347Z" fill="#4C4A50"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 981 B |
4
app/assets/icons/sellerPlaceholder-dark.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 10.9871V15.4931C3 18.3241 3 19.7401 3.879 20.6201C4.757 21.5001 6.172 21.5001 9 21.5001H15C17.828 21.5001 19.243 21.5001 20.121 20.6201C20.999 19.7401 21 18.3241 21 15.4921V10.9871" stroke="#FDFDFD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.9998 16.9771C14.3158 17.5841 13.2268 17.9771 11.9998 17.9771C10.7728 17.9771 9.68385 17.5841 8.99985 16.9771M17.7958 2.50306L6.14985 2.53206C4.41185 2.44206 3.96585 3.78206 3.96585 4.43806C3.96585 5.02406 3.89085 5.87806 2.82585 7.48306C1.75985 9.08806 1.83985 9.56506 2.44085 10.6771C2.93885 11.5991 4.20685 11.9591 4.86885 12.0201C6.96885 12.0681 7.99085 10.2521 7.99085 8.97506C9.03285 12.1821 11.9958 12.1821 13.3158 11.8151C14.6378 11.4481 15.7718 10.1331 16.0388 8.97506C16.1948 10.4151 16.6688 11.2541 18.0658 11.8311C19.5148 12.4281 20.7598 11.5151 21.3848 10.9291C22.0098 10.3441 22.4108 9.04406 21.2968 7.61506C20.5288 6.63006 20.2078 5.70206 20.1028 4.74006C20.0428 4.18206 19.9888 3.58306 19.5968 3.20206C19.0248 2.64506 18.2028 2.47706 17.7958 2.50306Z" stroke="#FDFDFD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
app/assets/icons/sellerPlaceholder.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 10.9871V15.4931C3 18.3241 3 19.7401 3.879 20.6201C4.757 21.5001 6.172 21.5001 9 21.5001H15C17.828 21.5001 19.243 21.5001 20.121 20.6201C20.999 19.7401 21 18.3241 21 15.4921V10.9871" stroke="#222526" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 16.9771C14.316 17.5841 13.227 17.9771 12 17.9771C10.773 17.9771 9.68397 17.5841 8.99997 16.9771M17.796 2.50306L6.14997 2.53206C4.41197 2.44206 3.96597 3.78206 3.96597 4.43806C3.96597 5.02406 3.89097 5.87806 2.82597 7.48306C1.75997 9.08806 1.83997 9.56506 2.44097 10.6771C2.93897 11.5991 4.20697 11.9591 4.86897 12.0201C6.96897 12.0681 7.99097 10.2521 7.99097 8.97506C9.03297 12.1821 11.996 12.1821 13.316 11.8151C14.638 11.4481 15.772 10.1331 16.039 8.97506C16.195 10.4151 16.669 11.2541 18.066 11.8311C19.515 12.4281 20.76 11.5151 21.385 10.9291C22.01 10.3441 22.411 9.04406 21.297 7.61506C20.529 6.63006 20.208 5.70206 20.103 4.74006C20.043 4.18206 19.989 3.58306 19.597 3.20206C19.025 2.64506 18.203 2.47706 17.796 2.50306Z" stroke="#222526" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
26
app/assets/logo/SVG-01.svg
Normal file
@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_4" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
|
||||
<!-- Generator: Adobe Illustrator 29.1.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 142) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #efefef;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #14213d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="st1" width="1080" height="1080"/>
|
||||
<g>
|
||||
<path class="st0" d="M338.52,537.89c52.45-41.26,113.65-89.43,144.92-114.1,2.75-2.17,2.42-6.42-.61-8.16l-96.78-55.38c-3.93-2.25-8.92-.23-10.19,4.11l-48.37,166.06c-1.9,6.53,5.69,11.66,11.04,7.46Z"/>
|
||||
<path class="st0" d="M407.25,342.18l86.6,49.55c2.47,1.41,5.54-.37,5.54-3.21v-99.66c0-5.38-5.82-8.75-10.48-6.05-24.04,13.9-57.6,33.33-81.7,47.27-4.66,2.7-4.64,9.44.04,12.11Z"/>
|
||||
<path class="st0" d="M550.85,747.09l-229.91-131.54c-4.66-2.67-10.47.7-10.47,6.07v37.27c0,2.51,1.34,4.82,3.52,6.07l229.91,131.54c4.66,2.67,10.47-.7,10.47-6.07v-37.27c0-2.51-1.34-4.82-3.52-6.07Z"/>
|
||||
<path class="st0" d="M620.28,494.27l-102.47-58.63c-2.48-1.42-5.56-1.2-7.8.58-17.78,14.06-63.34,49.97-181.08,142.58-3.9,3.07-3.47,9.11.84,11.58l222.1,127.07c3.93,2.25,8.92.23,10.19-4.11l61.47-211.03c.9-3.1-.44-6.42-3.24-8.03Z"/>
|
||||
<path class="st0" d="M529.15,411.91l108.04,61.8c2.16,1.24,4.82,1.23,6.97-.02l101.66-58.8c4.67-2.7,4.66-9.44-.02-12.12-52.01-29.75-157.69-90.22-209.7-119.97-4.66-2.67-10.46.7-10.46,6.07v116.96c0,2.51,1.34,4.83,3.52,6.07Z"/>
|
||||
<path class="st0" d="M647.11,515.09l-52.35,179.68c-1.01,3.46,3.02,6.18,5.85,3.95l154.15-121.42c2.14-1.69,1.9-5.01-.47-6.36l-101.58-58.11c-2.16-1.24-4.91-.13-5.6,2.26Z"/>
|
||||
<path class="st0" d="M759.03,437.56l-86.44,50c-2.47,1.43-2.46,5,.02,6.42l86.46,49.46c4.66,2.67,10.47-.7,10.47-6.07v-93.75c0-5.39-5.83-8.75-10.5-6.05Z"/>
|
||||
<path class="st0" d="M758.2,607.98l-174.94,137.76c-1.68,1.33-2.67,3.35-2.67,5.49v39.91c0,5.31,5.69,8.69,10.35,6.13l174.94-95.81c2.24-1.23,3.63-3.58,3.63-6.13v-81.86c0-5.84-6.73-9.11-11.32-5.49Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
26
app/assets/logo/SVG-02.svg
Normal file
@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_4" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
|
||||
<!-- Generator: Adobe Illustrator 29.1.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 142) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #edf2f4;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #023e8a;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="st0" width="1080" height="1080"/>
|
||||
<g>
|
||||
<path class="st1" d="M338.52,537.89c52.45-41.26,113.65-89.43,144.92-114.1,2.75-2.17,2.42-6.42-.61-8.16l-96.78-55.38c-3.93-2.25-8.92-.23-10.19,4.11l-48.37,166.06c-1.9,6.53,5.69,11.66,11.04,7.46Z"/>
|
||||
<path class="st1" d="M407.25,342.18l86.6,49.55c2.47,1.41,5.54-.37,5.54-3.21v-99.66c0-5.38-5.82-8.75-10.48-6.05-24.04,13.9-57.6,33.33-81.7,47.27-4.66,2.7-4.64,9.44.04,12.11Z"/>
|
||||
<path class="st1" d="M550.85,747.09l-229.91-131.54c-4.66-2.67-10.47.7-10.47,6.07v37.27c0,2.51,1.34,4.82,3.52,6.07l229.91,131.54c4.66,2.67,10.47-.7,10.47-6.07v-37.27c0-2.51-1.34-4.82-3.52-6.07Z"/>
|
||||
<path class="st1" d="M620.28,494.27l-102.47-58.63c-2.48-1.42-5.56-1.2-7.8.58-17.78,14.06-63.34,49.97-181.08,142.58-3.9,3.07-3.47,9.11.84,11.58l222.1,127.07c3.93,2.25,8.92.23,10.19-4.11l61.47-211.03c.9-3.1-.44-6.42-3.24-8.03Z"/>
|
||||
<path class="st1" d="M529.15,411.91l108.04,61.8c2.16,1.24,4.82,1.23,6.97-.02l101.66-58.8c4.67-2.7,4.66-9.44-.02-12.12-52.01-29.75-157.69-90.22-209.7-119.97-4.66-2.67-10.46.7-10.46,6.07v116.96c0,2.51,1.34,4.83,3.52,6.07Z"/>
|
||||
<path class="st1" d="M647.11,515.09l-52.35,179.68c-1.01,3.46,3.02,6.18,5.85,3.95l154.15-121.42c2.14-1.69,1.9-5.01-.47-6.36l-101.58-58.11c-2.16-1.24-4.91-.13-5.6,2.26Z"/>
|
||||
<path class="st1" d="M759.03,437.56l-86.44,50c-2.47,1.43-2.46,5,.02,6.42l86.46,49.46c4.66,2.67,10.47-.7,10.47-6.07v-93.75c0-5.39-5.83-8.75-10.5-6.05Z"/>
|
||||
<path class="st1" d="M758.2,607.98l-174.94,137.76c-1.68,1.33-2.67,3.35-2.67,5.49v39.91c0,5.31,5.69,8.69,10.35,6.13l174.94-95.81c2.24-1.23,3.63-3.58,3.63-6.13v-81.86c0-5.84-6.73-9.11-11.32-5.49Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
17
app/assets/logo/SVG-03.svg
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_4" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
|
||||
<!-- Generator: Adobe Illustrator 29.1.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 142) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #14213d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="st1" width="1080" height="1080"/>
|
||||
<path class="st0" d="M708.58,567.49l-66.07,12.02c8.39-4.76,16.31-10.25,23.72-16.34l.03-.03c14.38-11.76,26.81-25.86,36.65-41.76,16.09-25.92,25.39-56.52,25.39-89.26,0-36.24-11.35-69.8-30.72-97.33-30.66-43.62-81.37-72.13-138.74-72.13-78.85,0-145.14,53.87-164.07,126.82-3.5,13.63-5.39,27.91-5.39,42.64,0,54.5,25.74,103.01,65.73,134.01,14.57,11.29,31.07,20.28,48.85,26.34,9.15,3.15,18.61,5.52,28.39,7.06l-166.87,30.34c-7.98,1.42-13.75,8.36-13.75,16.46v154.26c0,10.31,9.24,18.13,19.36,16.53l11.95-2.18,331.51-60.24c7.98-1.45,13.75-8.39,13.75-16.46v-154.29c0-10.44-9.46-18.32-19.71-16.46ZM474.11,432.13c0-24.38,10.28-46.36,26.78-61.82,15.14-14.22,35.54-22.93,57.94-22.93,11.61,0,22.68,2.33,32.74,6.56,30.56,12.81,52.01,42.99,52.01,78.19,0,46.77-37.94,84.71-84.75,84.71s-84.71-37.94-84.71-84.71Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
21
app/assets/logo/SVG-04.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
|
||||
<!-- Generator: Adobe Illustrator 29.1.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 142) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #edf2f4;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #023e8a;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_4">
|
||||
<rect class="st0" width="1080" height="1080"/>
|
||||
</g>
|
||||
<g id="Layer_1">
|
||||
<path class="st1" d="M708.58,567.49l-66.07,12.02c8.39-4.76,16.31-10.25,23.72-16.34l.03-.03c14.38-11.76,26.81-25.86,36.65-41.76,16.09-25.92,25.39-56.52,25.39-89.26,0-36.24-11.35-69.8-30.72-97.33-30.66-43.62-81.37-72.13-138.74-72.13-78.85,0-145.14,53.87-164.07,126.82-3.5,13.63-5.39,27.91-5.39,42.64,0,54.5,25.74,103.01,65.73,134.01,14.57,11.29,31.07,20.28,48.85,26.34,9.15,3.15,18.61,5.52,28.39,7.06l-166.87,30.34c-7.98,1.42-13.75,8.36-13.75,16.46v154.26c0,10.31,9.24,18.13,19.36,16.53l11.95-2.18,331.51-60.24c7.98-1.45,13.75-8.39,13.75-16.46v-154.29c0-10.44-9.46-18.32-19.71-16.46ZM474.11,432.13c0-24.38,10.28-46.36,26.78-61.82,15.14-14.22,35.54-22.93,57.94-22.93,11.61,0,22.68,2.33,32.74,6.56,30.56,12.81,52.01,42.99,52.01,78.19,0,46.77-37.94,84.71-84.75,84.71s-84.71-37.94-84.71-84.71Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
34
app/assets/logo/SVG-05.svg
Normal file
@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_4" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
|
||||
<!-- Generator: Adobe Illustrator 29.1.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 142) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #edf2f4;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #14213d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="st1" width="1080" height="1080"/>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M826.29,543.05l-20.95,3.81c2.66-1.51,5.17-3.25,7.52-5.18h.01c4.56-3.74,8.5-8.21,11.62-13.25,5.1-8.22,8.05-17.92,8.05-28.3,0-11.49-3.6-22.13-9.74-30.86-9.72-13.83-25.8-22.87-43.99-22.87-25,0-46.02,17.08-52.02,40.21-1.11,4.32-1.71,8.85-1.71,13.52,0,17.28,8.16,32.66,20.84,42.49,4.62,3.58,9.85,6.43,15.49,8.35,2.9,1,5.9,1.75,9,2.24l-52.91,9.62c-2.53.45-4.36,2.65-4.36,5.22v48.91c0,3.27,2.93,5.75,6.14,5.24l3.79-.69,105.11-19.1c2.53-.46,4.36-2.66,4.36-5.22v-48.92c0-3.31-3-5.81-6.25-5.22ZM751.95,500.13c0-7.73,3.26-14.7,8.49-19.6,4.8-4.51,11.27-7.27,18.37-7.27,3.68,0,7.19.74,10.38,2.08,9.69,4.06,16.49,13.63,16.49,24.79,0,14.83-12.03,26.86-26.87,26.86s-26.86-12.03-26.86-26.86Z"/>
|
||||
<path class="st0" d="M713.14,498.05c0-2.66-1.92-4.79-4.36-5.23h-.01c-.6-.11-1.24-.12-1.89,0l-53.44,9.72v-34.89c0-3.04-2.53-5.4-5.47-5.3h-.06c-.24,0-.49.03-.73.08l-50.53,9.19-2.61.47-55.65,10.11c-2.52.46-4.35,2.66-4.35,5.23v48.77l-174.75,31.76c-2.52.46-4.36,2.66-4.36,5.22v48.92c0,2.09,1.19,3.86,2.9,4.73.99.51,2.16.71,3.36.49l172.85-31.42,31.46-5.72c1.03-.19,1.94-.67,2.66-1.35h.01c1.05-.97,1.69-2.36,1.69-3.87v-48.77l47.76-8.69v34.9c0,3.28,2.94,5.76,6.15,5.24l.08-.02.53-.09,84.4-15.35c2.53-.46,4.36-2.66,4.36-5.22v-48.91Z"/>
|
||||
<path class="st0" d="M687.93,592.63h-68.98c-.74,0-1.33.6-1.33,1.33v21.23c0,.73.59,1.32,1.33,1.32h68.98c.74,0,1.33-.59,1.33-1.32v-21.23c0-.73-.59-1.33-1.33-1.33Z"/>
|
||||
<path class="st0" d="M535.36,449.35h68.99c.73,0,1.33-.59,1.33-1.32v-21.23c0-.73-.6-1.33-1.33-1.33h-68.99c-.73,0-1.32.6-1.32,1.33v21.23c0,.73.59,1.32,1.32,1.32Z"/>
|
||||
<path class="st0" d="M408.83,547l3.28-.6,26.41-4.8,47.76-8.68,31.46-5.72c2.52-.46,4.36-2.65,4.36-5.22v-48.91c0-2.93-2.34-5.23-5.14-5.3h-.01c-.37-.02-.73.01-1.11.08l-12.81,2.33c11.58-8.73,19.07-22.58,19.07-38.2,0-26.38-21.38-47.76-47.76-47.76s-47.76,21.38-47.76,47.76c0,21.72,14.5,40.05,34.34,45.85l-22.4,4.07-30.55,5.55-.92.17c-2.52.46-4.36,2.66-4.36,5.22v48.92c0,3.27,2.93,5.75,6.14,5.24ZM450.46,431.98c0-13.19,10.69-23.88,23.88-23.88s23.88,10.69,23.88,23.88-10.69,23.88-23.88,23.88-23.88-10.69-23.88-23.88Z"/>
|
||||
<path class="st0" d="M308.49,449.35h21.23c.73,0,1.32-.59,1.32-1.32v-21.23c0-.73-.59-1.33-1.32-1.33h-21.23c-.73,0-1.33.6-1.33,1.33v21.23c0,.73.6,1.32,1.33,1.32Z"/>
|
||||
<path class="st0" d="M386.4,551.01c2.52-.46,4.35-2.65,4.35-5.22v-117.67c0-1.46-1.18-2.65-2.65-2.65h-18.57c-1.47,0-2.66,1.19-2.66,2.65v66.74l-11.94,2.17-6.18,1.12-77.4,14.08v-61.27c0-1.47-1.19-2.66-2.65-2.66h-18.57c-1.47,0-2.66,1.19-2.66,2.66v118.96c0,2.91,2.32,5.2,5.1,5.3.38.02.77-.01,1.16-.08l5.68-1.04,93.69-17.04,1.83-.33,31.47-5.72Z"/>
|
||||
</g>
|
||||
<path class="st0" d="M266.9,694.61c-.97-.66-1.79-1.74-2.47-3.22l-15.14-33.4c-.49-1.02-.74-2.03-.74-3.02,0-1.37.48-2.48,1.45-3.33.97-.85,2.25-1.28,3.84-1.28s2.88.34,3.76,1.02c.87.68,1.65,1.82,2.33,3.41l10.98,25.49,11.04-25.55c.68-1.56,1.47-2.67,2.36-3.36.89-.68,2.1-1.02,3.61-1.02s2.67.42,3.58,1.25c.91.83,1.37,1.94,1.37,3.3,0,1.02-.25,2.05-.74,3.07l-15.19,33.4c-.61,1.48-1.39,2.55-2.36,3.22-.97.66-2.25,1-3.84,1s-2.87-.33-3.84-1Z"/>
|
||||
<path class="st0" d="M344.88,694.07c-1-1.02-1.51-2.48-1.51-4.38v-33.46c0-1.94.5-3.4,1.51-4.38,1-.99,2.44-1.48,4.3-1.48s3.28.49,4.27,1.48c.99.99,1.48,2.45,1.48,4.38v33.46c0,1.93-.48,3.41-1.45,4.41-.97,1-2.4,1.51-4.3,1.51s-3.29-.51-4.3-1.54Z"/>
|
||||
<path class="st0" d="M432.9,694.07c-1-1.02-1.51-2.45-1.51-4.27v-29.54h-9.84c-1.52,0-2.68-.4-3.5-1.2-.82-.8-1.22-1.95-1.22-3.47s.41-2.73,1.22-3.53c.82-.8,1.98-1.2,3.5-1.2h31.19c3.19,0,4.78,1.57,4.78,4.72,0,1.52-.41,2.67-1.22,3.47-.82.8-2,1.2-3.56,1.2h-9.84v29.54c0,1.86-.48,3.29-1.45,4.3-.97,1-2.4,1.51-4.3,1.51s-3.24-.51-4.24-1.54Z"/>
|
||||
<path class="st0" d="M511.67,694.07c-1-1.02-1.51-2.45-1.51-4.27v-33.06c0-1.9.51-3.35,1.54-4.35,1.02-1,2.46-1.51,4.32-1.51h15.37c4.97,0,8.83,1.22,11.58,3.67,2.75,2.45,4.13,5.85,4.13,10.21,0,3.26-.83,6.01-2.48,8.25-1.65,2.24-4.03,3.79-7.14,4.67,2.43.65,4.3,2.26,5.63,4.84l3.13,5.63c.64,1.18.97,2.28.97,3.3,0,1.21-.46,2.21-1.37,2.99-.91.78-2.16,1.17-3.76,1.17s-2.9-.32-3.93-.97-1.94-1.71-2.73-3.19l-5.69-10.41c-.53-.91-1.17-1.55-1.91-1.91-.74-.36-1.64-.54-2.7-.54h-3.41v11.21c0,1.86-.48,3.29-1.45,4.3-.97,1-2.4,1.51-4.3,1.51s-3.29-.51-4.3-1.54ZM529.34,670.46c2.2,0,3.89-.46,5.07-1.37,1.17-.91,1.76-2.26,1.76-4.04s-.59-3.06-1.76-3.96c-1.18-.89-2.87-1.34-5.07-1.34h-7.62v10.7h7.62Z"/>
|
||||
<path class="st0" d="M695.21,694.53c-.95-.72-1.71-1.86-2.28-3.41l-12.18-34.03c-.3-.83-.46-1.59-.46-2.28,0-1.33.48-2.4,1.45-3.22.97-.82,2.34-1.22,4.13-1.22,1.55,0,2.77.35,3.64,1.05.87.7,1.56,1.85,2.05,3.44l7.91,23.67,8.48-23.67c.61-1.56,1.35-2.69,2.22-3.41.87-.72,1.99-1.08,3.36-1.08s2.47.36,3.3,1.08c.83.72,1.54,1.88,2.11,3.47l8.14,24.19,8.31-24.24c.49-1.59,1.17-2.74,2.02-3.44.85-.7,2.06-1.05,3.61-1.05s2.67.42,3.58,1.25c.91.83,1.37,1.9,1.37,3.19,0,.68-.15,1.44-.46,2.28l-12.29,34.09c-.57,1.52-1.33,2.64-2.28,3.36-.95.72-2.18,1.08-3.7,1.08s-2.81-.36-3.76-1.08c-.95-.72-1.71-1.86-2.28-3.41l-8.02-22.59-8.25,22.65c-.57,1.52-1.32,2.64-2.25,3.36-.93.72-2.17,1.08-3.73,1.08s-2.81-.36-3.76-1.08Z"/>
|
||||
<path class="st0" d="M790.45,694.21c-.93-.93-1.39-2.29-1.39-4.07v-34.09c0-1.82.46-3.22,1.37-4.21.91-.99,2.12-1.48,3.64-1.48,1.29,0,2.3.23,3.04.68.74.46,1.6,1.31,2.59,2.56l18.1,22.54v-20.43c0-1.74.46-3.07,1.37-3.98.91-.91,2.24-1.37,3.98-1.37s3.06.46,3.96,1.37c.89.91,1.34,2.24,1.34,3.98v34.71c0,1.56-.42,2.81-1.25,3.76-.83.95-1.95,1.42-3.36,1.42-1.33,0-2.4-.25-3.21-.74-.82-.49-1.74-1.33-2.76-2.5l-18.04-22.65v20.43c0,1.78-.46,3.14-1.37,4.07-.91.93-2.24,1.39-3.98,1.39s-3.08-.46-4.01-1.39Z"/>
|
||||
<path class="st0" d="M636.64,660.03c-4.11-5.84-10.9-9.66-18.59-9.66-10.56,0-19.45,7.22-21.98,16.99-.47,1.83-.72,3.74-.72,5.71,0,7.3,3.45,13.8,8.81,17.96,1.95,1.51,4.16,2.72,6.55,3.53,1.23.42,2.49.74,3.8.95h.01c1.15.18,2.33.27,3.54.27,4.07,0,7.9-1.07,11.2-2.95,0,0,0,0,0,0,1.12-.64,2.18-1.37,3.18-2.19h0c1.93-1.58,3.59-3.47,4.91-5.6,2.16-3.47,3.4-7.57,3.4-11.96,0-4.86-1.52-9.35-4.12-13.04ZM618.05,661.72c1.56,0,3.04.31,4.39.88,4.09,1.72,6.97,5.76,6.97,10.48,0,6.27-5.08,11.35-11.36,11.35s-11.35-5.08-11.35-11.35c0-3.27,1.38-6.21,3.59-8.28,2.03-1.91,4.76-3.07,7.76-3.07Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
42
app/assets/logo/SVG-06.svg
Normal file
@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
|
||||
<!-- Generator: Adobe Illustrator 29.1.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 142) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #edf2f4;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #023e8a;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #14213d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_4">
|
||||
<rect class="st0" width="1080" height="1080"/>
|
||||
</g>
|
||||
<g id="Layer_1">
|
||||
<g>
|
||||
<g>
|
||||
<path class="st1" d="M826.29,543.05l-20.95,3.81c2.66-1.51,5.17-3.25,7.52-5.18h.01c4.56-3.74,8.5-8.21,11.62-13.25,5.1-8.22,8.05-17.92,8.05-28.3,0-11.49-3.6-22.13-9.74-30.86-9.72-13.83-25.8-22.87-43.99-22.87-25,0-46.02,17.08-52.02,40.21-1.11,4.32-1.71,8.85-1.71,13.52,0,17.28,8.16,32.66,20.84,42.49,4.62,3.58,9.85,6.43,15.49,8.35,2.9,1,5.9,1.75,9,2.24l-52.91,9.62c-2.53.45-4.36,2.65-4.36,5.22v48.91c0,3.27,2.93,5.75,6.14,5.24l3.79-.69,105.11-19.1c2.53-.46,4.36-2.66,4.36-5.22v-48.92c0-3.31-3-5.81-6.25-5.22ZM751.95,500.13c0-7.73,3.26-14.7,8.49-19.6,4.8-4.51,11.27-7.27,18.37-7.27,3.68,0,7.19.74,10.38,2.08,9.69,4.06,16.49,13.63,16.49,24.79,0,14.83-12.03,26.86-26.87,26.86s-26.86-12.03-26.86-26.86Z"/>
|
||||
<path class="st2" d="M713.14,498.05c0-2.66-1.92-4.79-4.36-5.23h-.01c-.6-.11-1.24-.12-1.89,0l-53.44,9.72v-34.89c0-3.04-2.53-5.4-5.47-5.3h-.06c-.24,0-.49.03-.73.08l-50.53,9.19-2.61.47-55.65,10.11c-2.52.46-4.35,2.66-4.35,5.23v48.77l-174.75,31.76c-2.52.46-4.36,2.66-4.36,5.22v48.92c0,2.09,1.19,3.86,2.9,4.73.99.51,2.16.71,3.36.49l172.85-31.42,31.46-5.72c1.03-.19,1.94-.67,2.66-1.35h.01c1.05-.97,1.69-2.36,1.69-3.87v-48.77l47.76-8.69v34.9c0,3.28,2.94,5.76,6.15,5.24l.08-.02.53-.09,84.4-15.35c2.53-.46,4.36-2.66,4.36-5.22v-48.91Z"/>
|
||||
<path class="st2" d="M687.93,592.63h-68.98c-.74,0-1.33.6-1.33,1.33v21.23c0,.73.59,1.32,1.33,1.32h68.98c.74,0,1.33-.59,1.33-1.32v-21.23c0-.73-.59-1.33-1.33-1.33Z"/>
|
||||
<path class="st2" d="M535.36,449.35h68.99c.73,0,1.33-.59,1.33-1.32v-21.23c0-.73-.6-1.33-1.33-1.33h-68.99c-.73,0-1.32.6-1.32,1.33v21.23c0,.73.59,1.32,1.32,1.32Z"/>
|
||||
<path class="st2" d="M408.83,547l3.28-.6,26.41-4.8,47.76-8.68,31.46-5.72c2.52-.46,4.36-2.65,4.36-5.22v-48.91c0-2.93-2.34-5.23-5.14-5.3h-.01c-.37-.02-.73.01-1.11.08l-12.81,2.33c11.58-8.73,19.07-22.58,19.07-38.2,0-26.38-21.38-47.76-47.76-47.76s-47.76,21.38-47.76,47.76c0,21.72,14.5,40.05,34.34,45.85l-22.4,4.07-30.55,5.55-.92.17c-2.52.46-4.36,2.66-4.36,5.22v48.92c0,3.27,2.93,5.75,6.14,5.24ZM450.46,431.98c0-13.19,10.69-23.88,23.88-23.88s23.88,10.69,23.88,23.88-10.69,23.88-23.88,23.88-23.88-10.69-23.88-23.88Z"/>
|
||||
<path class="st2" d="M308.49,449.35h21.23c.73,0,1.32-.59,1.32-1.32v-21.23c0-.73-.59-1.33-1.32-1.33h-21.23c-.73,0-1.33.6-1.33,1.33v21.23c0,.73.6,1.32,1.33,1.32Z"/>
|
||||
<path class="st2" d="M386.4,551.01c2.52-.46,4.35-2.65,4.35-5.22v-117.67c0-1.46-1.18-2.65-2.65-2.65h-18.57c-1.47,0-2.66,1.19-2.66,2.65v66.74l-11.94,2.17-6.18,1.12-77.4,14.08v-61.27c0-1.47-1.19-2.66-2.65-2.66h-18.57c-1.47,0-2.66,1.19-2.66,2.66v118.96c0,2.91,2.32,5.2,5.1,5.3.38.02.77-.01,1.16-.08l5.68-1.04,93.69-17.04,1.83-.33,31.47-5.72Z"/>
|
||||
</g>
|
||||
<path class="st2" d="M266.9,694.61c-.97-.66-1.79-1.74-2.47-3.22l-15.14-33.4c-.49-1.02-.74-2.03-.74-3.02,0-1.37.48-2.48,1.45-3.33.97-.85,2.25-1.28,3.84-1.28s2.88.34,3.76,1.02c.87.68,1.65,1.82,2.33,3.41l10.98,25.49,11.04-25.55c.68-1.56,1.47-2.67,2.36-3.36.89-.68,2.1-1.02,3.61-1.02s2.67.42,3.58,1.25c.91.83,1.37,1.94,1.37,3.3,0,1.02-.25,2.05-.74,3.07l-15.19,33.4c-.61,1.48-1.39,2.55-2.36,3.22-.97.66-2.25,1-3.84,1s-2.87-.33-3.84-1Z"/>
|
||||
<path class="st2" d="M344.88,694.07c-1-1.02-1.51-2.48-1.51-4.38v-33.46c0-1.94.5-3.4,1.51-4.38,1-.99,2.44-1.48,4.3-1.48s3.28.49,4.27,1.48c.99.99,1.48,2.45,1.48,4.38v33.46c0,1.93-.48,3.41-1.45,4.41-.97,1-2.4,1.51-4.3,1.51s-3.29-.51-4.3-1.54Z"/>
|
||||
<path class="st2" d="M432.9,694.07c-1-1.02-1.51-2.45-1.51-4.27v-29.54h-9.84c-1.52,0-2.68-.4-3.5-1.2-.82-.8-1.22-1.95-1.22-3.47s.41-2.73,1.22-3.53c.82-.8,1.98-1.2,3.5-1.2h31.19c3.19,0,4.78,1.57,4.78,4.72,0,1.52-.41,2.67-1.22,3.47-.82.8-2,1.2-3.56,1.2h-9.84v29.54c0,1.86-.48,3.29-1.45,4.3-.97,1-2.4,1.51-4.3,1.51s-3.24-.51-4.24-1.54Z"/>
|
||||
<path class="st2" d="M511.67,694.07c-1-1.02-1.51-2.45-1.51-4.27v-33.06c0-1.9.51-3.35,1.54-4.35,1.02-1,2.46-1.51,4.32-1.51h15.37c4.97,0,8.83,1.22,11.58,3.67,2.75,2.45,4.13,5.85,4.13,10.21,0,3.26-.83,6.01-2.48,8.25-1.65,2.24-4.03,3.79-7.14,4.67,2.43.65,4.3,2.26,5.63,4.84l3.13,5.63c.64,1.18.97,2.28.97,3.3,0,1.21-.46,2.21-1.37,2.99-.91.78-2.16,1.17-3.76,1.17s-2.9-.32-3.93-.97-1.94-1.71-2.73-3.19l-5.69-10.41c-.53-.91-1.17-1.55-1.91-1.91-.74-.36-1.64-.54-2.7-.54h-3.41v11.21c0,1.86-.48,3.29-1.45,4.3-.97,1-2.4,1.51-4.3,1.51s-3.29-.51-4.3-1.54ZM529.34,670.46c2.2,0,3.89-.46,5.07-1.37,1.17-.91,1.76-2.26,1.76-4.04s-.59-3.06-1.76-3.96c-1.18-.89-2.87-1.34-5.07-1.34h-7.62v10.7h7.62Z"/>
|
||||
<path class="st2" d="M695.21,694.53c-.95-.72-1.71-1.86-2.28-3.41l-12.18-34.03c-.3-.83-.46-1.59-.46-2.28,0-1.33.48-2.4,1.45-3.22.97-.82,2.34-1.22,4.13-1.22,1.55,0,2.77.35,3.64,1.05.87.7,1.56,1.85,2.05,3.44l7.91,23.67,8.48-23.67c.61-1.56,1.35-2.69,2.22-3.41.87-.72,1.99-1.08,3.36-1.08s2.47.36,3.3,1.08c.83.72,1.54,1.88,2.11,3.47l8.14,24.19,8.31-24.24c.49-1.59,1.17-2.74,2.02-3.44.85-.7,2.06-1.05,3.61-1.05s2.67.42,3.58,1.25c.91.83,1.37,1.9,1.37,3.19,0,.68-.15,1.44-.46,2.28l-12.29,34.09c-.57,1.52-1.33,2.64-2.28,3.36-.95.72-2.18,1.08-3.7,1.08s-2.81-.36-3.76-1.08c-.95-.72-1.71-1.86-2.28-3.41l-8.02-22.59-8.25,22.65c-.57,1.52-1.32,2.64-2.25,3.36-.93.72-2.17,1.08-3.73,1.08s-2.81-.36-3.76-1.08Z"/>
|
||||
<path class="st2" d="M790.45,694.21c-.93-.93-1.39-2.29-1.39-4.07v-34.09c0-1.82.46-3.22,1.37-4.21.91-.99,2.12-1.48,3.64-1.48,1.29,0,2.3.23,3.04.68.74.46,1.6,1.31,2.59,2.56l18.1,22.54v-20.43c0-1.74.46-3.07,1.37-3.98.91-.91,2.24-1.37,3.98-1.37s3.06.46,3.96,1.37c.89.91,1.34,2.24,1.34,3.98v34.71c0,1.56-.42,2.81-1.25,3.76-.83.95-1.95,1.42-3.36,1.42-1.33,0-2.4-.25-3.21-.74-.82-.49-1.74-1.33-2.76-2.5l-18.04-22.65v20.43c0,1.78-.46,3.14-1.37,4.07-.91.93-2.24,1.39-3.98,1.39s-3.08-.46-4.01-1.39Z"/>
|
||||
<path class="st1" d="M636.64,660.03c-4.11-5.84-10.9-9.66-18.59-9.66-10.56,0-19.45,7.22-21.98,16.99-.47,1.83-.72,3.74-.72,5.71,0,7.3,3.45,13.8,8.81,17.96,1.95,1.51,4.16,2.72,6.55,3.53,1.23.42,2.49.74,3.8.95h.01c1.15.18,2.33.27,3.54.27,4.07,0,7.9-1.07,11.2-2.95,0,0,0,0,0,0,1.12-.64,2.18-1.37,3.18-2.19h0c1.93-1.58,3.59-3.47,4.91-5.6,2.16-3.47,3.4-7.57,3.4-11.96,0-4.86-1.52-9.35-4.12-13.04ZM618.05,661.72c1.56,0,3.04.31,4.39.88,4.09,1.72,6.97,5.76,6.97,10.48,0,6.27-5.08,11.35-11.36,11.35s-11.35-5.08-11.35-11.35c0-3.27,1.38-6.21,3.59-8.28,2.03-1.91,4.76-3.07,7.76-3.07Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
3
app/assets/logo/SVG-07.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="22" height="32" viewBox="0 0 22 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.8479 17.5863L16.988 18.2798C17.4781 18.0051 17.9408 17.6884 18.3737 17.3371L18.3755 17.3353C19.2156 16.6569 19.9418 15.8434 20.5167 14.9261C21.4567 13.4307 22 11.6653 22 9.77651C22 7.68575 21.3369 5.7496 20.2053 4.16133C18.4141 1.6448 15.4515 0 12.0998 0C7.49322 0 3.62041 3.10788 2.51448 7.31652C2.31001 8.10286 2.19959 8.9267 2.19959 9.77651C2.19959 12.9207 3.70337 15.7194 6.03967 17.5078C6.89088 18.1592 7.85485 18.6778 8.89359 19.0274C9.42815 19.2092 9.98083 19.3459 10.5522 19.4348L0.803304 21.1851C0.337095 21.2671 0 21.6674 0 22.1347V31.0343C0 31.6291 0.539819 32.0803 1.13105 31.988L1.8292 31.8622L21.1967 28.3868C21.6629 28.3032 22 27.9028 22 27.4372V18.5359C22 17.9336 21.4473 17.479 20.8485 17.5863H20.8479ZM7.14969 9.77709C7.14969 8.37055 7.75027 7.10248 8.71424 6.21056C9.59875 5.39018 10.7906 4.88768 12.0992 4.88768C12.7775 4.88768 13.4242 5.0221 14.012 5.26614C15.7973 6.00517 17.0505 7.74632 17.0505 9.77709C17.0505 12.4753 14.834 14.6642 12.0992 14.6642C9.36448 14.6642 7.15028 12.4753 7.15028 9.77709H7.14969Z" fill="#023E8A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
21
app/auth.server.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Authenticator } from "remix-auth";
|
||||
import { FormStrategy } from "remix-auth-form";
|
||||
import { sessionStorage } from "./sessions.server";
|
||||
|
||||
export const authenticator = new Authenticator(sessionStorage);
|
||||
|
||||
// login
|
||||
authenticator.use(
|
||||
new FormStrategy(async ({ form }) => {
|
||||
const phoneNumber = form.get("phoneNumber") as string;
|
||||
const access_token = form.get("access_token") as string;
|
||||
const refresh_token = form.get("refresh_token") as string;
|
||||
return {
|
||||
phoneNumber: phoneNumber?.toString(),
|
||||
access_token: access_token?.toString(),
|
||||
refresh_token: refresh_token?.toString(),
|
||||
loggedIn: true,
|
||||
};
|
||||
}),
|
||||
"vitrown-login"
|
||||
);
|
||||
585
app/components/AIPromoteModal.tsx
Normal file
@ -0,0 +1,585 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
} from "./ui/dialog";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Sparkles,
|
||||
Upload,
|
||||
ImageIcon,
|
||||
X,
|
||||
Download,
|
||||
RotateCcw,
|
||||
CheckCircle,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { useRootData, useAuthToken } from "../hooks/use-root-data";
|
||||
import { LoginRequiredDialog } from "./LoginRequiredDialog";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getApiBaseUrl } from "~/utils/api-config";
|
||||
|
||||
interface AIPromoteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
collectionId?: string;
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
export default function AIPromoteModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
collectionId,
|
||||
productId,
|
||||
}: AIPromoteModalProps) {
|
||||
const isProductMode = !!productId;
|
||||
const { user } = useRootData();
|
||||
const token = useAuthToken();
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [resultImage, setResultImage] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false);
|
||||
const [loadingStep, setLoadingStep] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Loading steps messages
|
||||
const loadingSteps = [
|
||||
{ text: "در حال آمادهسازی لباس...", icon: "👗" },
|
||||
{ text: "در حال اندازهگیری سایز شما...", icon: "📏" },
|
||||
{ text: "در حال دوختن لباس به اندازه شما...", icon: "✂️" },
|
||||
{ text: "در حال اتو کردن و مرتب کردن...", icon: "✨" },
|
||||
{ text: "در حال پرو کردن روی شما...", icon: "🎯" },
|
||||
{ text: "تقریباً آماده شده یخورده هم صبر کن...", icon: "⏳" },
|
||||
];
|
||||
|
||||
// Cycle through loading steps
|
||||
useEffect(() => {
|
||||
if (!isUploading) {
|
||||
setLoadingStep(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setLoadingStep((prev) => {
|
||||
// Stay on last step if we've gone through all
|
||||
if (prev >= loadingSteps.length - 1) return prev;
|
||||
return prev + 1;
|
||||
});
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isUploading, loadingSteps.length]);
|
||||
|
||||
// AI Try-on mutation for collection
|
||||
const tryOnCollectionMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
image,
|
||||
collectionId,
|
||||
}: {
|
||||
image: Blob;
|
||||
collectionId: string;
|
||||
}) => {
|
||||
if (!token) throw new Error("Authentication required");
|
||||
const baseUrl = getApiBaseUrl();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("collection_id", collectionId);
|
||||
formData.append("image", image);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/aifit/v1/try-on/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
// Response is binary PNG, convert to blob URL
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
},
|
||||
});
|
||||
|
||||
// AI Try-on mutation for product
|
||||
const tryOnProductMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
image,
|
||||
productId,
|
||||
}: {
|
||||
image: Blob;
|
||||
productId: string;
|
||||
}) => {
|
||||
if (!token) throw new Error("Authentication required");
|
||||
const baseUrl = getApiBaseUrl();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("product_id", productId);
|
||||
formData.append("image", image);
|
||||
|
||||
// AI processing can take a while, set 2 minute timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 120000);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/aifit/v1/try-on/product/`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
console.error("API error response:", errorText);
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
// Response is binary PNG, convert to blob URL
|
||||
const blob = await response.blob();
|
||||
console.log("Received blob:", blob.type, blob.size);
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error(
|
||||
"درخواست زمان زیادی طول کشید. لطفا دوباره تلاش کنید."
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Image Upload Handler
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith("image/")) {
|
||||
alert("لطفا یک فایل تصویری انتخاب کنید");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert("حجم فایل نباید بیشتر از 10 مگابایت باشد");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedImage(file);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle login required - close AI modal first, then show login
|
||||
const handleLoginRequired = () => {
|
||||
onClose(); // Close AI modal first
|
||||
// Small delay to let the modal close animation complete
|
||||
setTimeout(() => {
|
||||
setIsLoginDialogOpen(true);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// Handle AI Promote Submit
|
||||
const handleAIPromote = async () => {
|
||||
// Check if user is logged in
|
||||
if (!user) {
|
||||
handleLoginRequired();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedImage) {
|
||||
alert("لطفا ابتدا عکس خود را آپلود کنید");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
console.log("Sending AI try-on request...");
|
||||
|
||||
let result;
|
||||
|
||||
if (isProductMode && productId) {
|
||||
// Product try-on
|
||||
result = await tryOnProductMutation.mutateAsync({
|
||||
image: selectedImage,
|
||||
productId,
|
||||
});
|
||||
} else if (collectionId) {
|
||||
// Collection try-on
|
||||
result = await tryOnCollectionMutation.mutateAsync({
|
||||
image: selectedImage,
|
||||
collectionId,
|
||||
});
|
||||
} else {
|
||||
throw new Error("No product or collection ID provided");
|
||||
}
|
||||
|
||||
console.log("AI try-on completed successfully", result);
|
||||
|
||||
// Result is already a blob URL from the mutation
|
||||
setResultImage(result);
|
||||
|
||||
// Reset state
|
||||
// handleModalClose();
|
||||
} catch (error) {
|
||||
console.error("Error in AI try-on:", error);
|
||||
alert("متاسفانه خطایی رخ داد. لطفا دوباره تلاش کنید.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset modal state when closed
|
||||
const handleModalClose = () => {
|
||||
setSelectedImage(null);
|
||||
setImagePreview(null);
|
||||
setResultImage(null);
|
||||
setIsUploading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Reset to try again
|
||||
const handleTryAgain = () => {
|
||||
setResultImage(null);
|
||||
setSelectedImage(null);
|
||||
setImagePreview(null);
|
||||
};
|
||||
|
||||
// Download result image
|
||||
const handleDownload = () => {
|
||||
if (!resultImage) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = resultImage;
|
||||
link.download = `ai-tryon-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Share result image
|
||||
const handleShare = async () => {
|
||||
if (!resultImage) return;
|
||||
|
||||
try {
|
||||
// Convert blob URL to actual blob for sharing
|
||||
const response = await fetch(resultImage);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `ai-tryon-${Date.now()}.png`, {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
// Build product URL for sharing
|
||||
const siteUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://vitrown.com";
|
||||
const productUrl = productId
|
||||
? `${siteUrl}/product/${productId}`
|
||||
: siteUrl;
|
||||
const shareText = `ببین این لباس رو تن من چطور شده! 👗✨\n\nاین محصول رو از ویترون ببین:\n${productUrl}`;
|
||||
|
||||
// Check if Web Share API is available and supports sharing files
|
||||
if (navigator.share && navigator.canShare?.({ files: [file] })) {
|
||||
await navigator.share({
|
||||
title: "پرو کردن با هوش مصنوعی - ویترون",
|
||||
text: shareText,
|
||||
files: [file],
|
||||
});
|
||||
} else {
|
||||
// Fallback: Copy image to clipboard or show message
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ "image/png": blob }),
|
||||
]);
|
||||
alert(
|
||||
"تصویر در کلیپبورد کپی شد! حالا میتونی توی هر اپلیکیشنی Paste کنی."
|
||||
);
|
||||
} catch {
|
||||
// If clipboard also fails, just download
|
||||
handleDownload();
|
||||
alert("تصویر دانلود شد. میتونی از گالری به اشتراک بذاری.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sharing:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Login Required Dialog - outside of main dialog to avoid overlap */}
|
||||
<LoginRequiredDialog
|
||||
isOpen={isLoginDialogOpen}
|
||||
onOpenChange={setIsLoginDialogOpen}
|
||||
loginTitle="برای استفاده از قابلیت پرو کردن با AI ابتدا باید وارد حساب کاربری خود شوید."
|
||||
/>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={handleModalClose}>
|
||||
<DialogOverlay />
|
||||
<DialogContent className="bg-WHITE rounded-2xl max-w-md w-[calc(100%-32px)] sm:w-full p-4 border border-inner-border">
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleModalClose}
|
||||
disabled={isUploading}
|
||||
className="absolute top-4 right-4 text-GRAY hover:text-BLACK transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-B16 mt-8 font-bold text-right flex items-center gap-2 pl-8">
|
||||
<span className="w-full">
|
||||
{isProductMode
|
||||
? "پرو کردن محصول با هوش مصنوعی"
|
||||
: "پرو کردن کالکشن با هوش مصنوعی"}
|
||||
</span>
|
||||
<Sparkles className="w-6 h-6 text-BLACK" />
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-R14 text-GRAY text-right">
|
||||
{isProductMode
|
||||
? "برای اینکه AI بتواند این محصول رو روی بدن شما نمایش بده، لطفا یک عکس قدی از خود را آپلود کنید."
|
||||
: "برای اینکه AI بتواد این کالکشن رو روی بدن شما نمایش بده، لطفا یک عکس قدی از خود را آپلود کنید."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{resultImage ? (
|
||||
// Result View
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Success Badge */}
|
||||
<div className="flex items-center justify-center gap-2 p-3 bg-GREEN/10 rounded-xl border border-GREEN/30">
|
||||
<CheckCircle className="w-5 h-5 text-GREEN" />
|
||||
<p className="text-B14 font-bold text-GREEN">
|
||||
پرو کردن با موفقیت انجام شد!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Result Image */}
|
||||
<div className="relative w-full h-80 rounded-xl overflow-hidden bg-WHITE2 border-2 border-GREEN/30 shadow-lg">
|
||||
<img
|
||||
src={resultImage}
|
||||
alt="AI Try-on Result"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Share Button - Full Width */}
|
||||
<Button
|
||||
variant="dark"
|
||||
size="lg"
|
||||
onClick={handleShare}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Share2 className="w-4 h-4" />
|
||||
<span>ارسال به دوستان</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Download & Try Again */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleTryAgain}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>امتحان مجدد</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleDownload}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
<span>دانلود</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Upload View
|
||||
<>
|
||||
{/* Image Upload Area */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
id="ai-image-upload"
|
||||
/>
|
||||
|
||||
{imagePreview ? (
|
||||
// Image Preview
|
||||
<div className="relative w-full h-64 rounded-xl overflow-hidden bg-WHITE2 border-2 border-BLACK/10">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedImage(null);
|
||||
setImagePreview(null);
|
||||
}}
|
||||
className="absolute top-2 right-2 bg-RED text-WHITE rounded-full p-2 hover:bg-RED/80 transition-colors shadow-lg"
|
||||
aria-label="حذف عکس"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Upload Button
|
||||
<label
|
||||
htmlFor="ai-image-upload"
|
||||
className="w-full h-64 rounded-xl border-2 border-dashed border-GRAY/30
|
||||
hover:border-BLACK transition-colors cursor-pointer
|
||||
flex flex-col items-center justify-center gap-3 bg-WHITE2 hover:bg-WHITE3"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-BLACK/5 flex items-center justify-center">
|
||||
<Upload className="w-8 h-8 text-BLACK" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-B16 font-bold text-BLACK">
|
||||
آپلود عکس
|
||||
</p>
|
||||
<p className="text-R12 text-GRAY text-center px-4">
|
||||
عکس باید تمام قد باشه تا AI بتونه بهتر کالکشن رو روی
|
||||
بدن شما نمایش بده
|
||||
</p>
|
||||
<p className="text-R10 text-GRAY mt-2">
|
||||
حداکثر حجم: 10MB
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button or Loading State */}
|
||||
{isUploading ? (
|
||||
// Loading State - replaces button and info boxes
|
||||
<div className="flex flex-col items-center gap-4 py-6 px-4 bg-gradient-to-b from-BLACK/5 to-BLACK/10 rounded-xl border border-BLACK/10">
|
||||
{/* Animated Icon */}
|
||||
<div className="text-5xl animate-bounce">
|
||||
{loadingSteps[loadingStep].icon}
|
||||
</div>
|
||||
|
||||
{/* Loading Text */}
|
||||
<p className="text-B16 font-bold text-BLACK text-center">
|
||||
{loadingSteps[loadingStep].text}
|
||||
</p>
|
||||
|
||||
{/* Progress Dots */}
|
||||
<div className="flex items-center gap-2">
|
||||
{loadingSteps.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${
|
||||
index <= loadingStep
|
||||
? "bg-BLACK scale-100"
|
||||
: "bg-BLACK/20 scale-75"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Counter */}
|
||||
<p className="text-R12 text-GRAY">
|
||||
مرحله {loadingStep + 1} از {loadingSteps.length}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// Normal State - Button and Info boxes
|
||||
<>
|
||||
<Button
|
||||
variant="dark"
|
||||
size="xl"
|
||||
onClick={handleAIPromote}
|
||||
disabled={!selectedImage}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>شروع پرو کردن</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="flex items-start gap-2 p-3 bg-BLUE/5 rounded-lg border border-BLUE/20">
|
||||
<ImageIcon className="w-5 h-5 text-BLUE mt-0.5 flex-shrink-0" />
|
||||
<p className="text-R12 text-GRAY text-right">
|
||||
<span className="font-bold text-BLACK">نکته:</span> برای
|
||||
بهترین نتیجه، عکس باید در نورپردازی مناسب و با زمینه
|
||||
ساده باشه. AI با استفاده از این عکس،{" "}
|
||||
{isProductMode ? "این محصول" : "محصولات کالکشن"} رو روی
|
||||
بدن شما نمایش میده.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Privacy Notice */}
|
||||
<div className="flex items-center justify-center gap-1.5 p-2 bg-GREEN/5 rounded-lg border border-GREEN/20">
|
||||
<svg
|
||||
className="w-4 h-4 text-GREEN flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-R11 text-GRAY text-center">
|
||||
<span className="font-bold text-GREEN">
|
||||
حریم خصوصی شما مهمه:
|
||||
</span>{" "}
|
||||
عکسهای شما هیچجا ذخیره نمیشه
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
app/components/AppInput.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { memo } from "react";
|
||||
import { Input } from "./ui/input";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
const AppInput = memo(function AppInput({
|
||||
inputValue,
|
||||
inputOnChange,
|
||||
inputIcon,
|
||||
inputPlaceholder,
|
||||
inputDir,
|
||||
disabled,
|
||||
type,
|
||||
inputTitle,
|
||||
loading, // New prop
|
||||
onKeyDown,
|
||||
className,
|
||||
inputRef, // New prop to forward ref to input
|
||||
inputOnFocus,
|
||||
inputOnBlur,
|
||||
inputTitleFontSize,
|
||||
iconProps, // New prop for icon customization
|
||||
}: {
|
||||
inputValue: string;
|
||||
inputOnChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
inputIcon?: LucideIcon | string;
|
||||
inputPlaceholder: string;
|
||||
type?: string;
|
||||
inputDir?: string;
|
||||
disabled?: boolean;
|
||||
inputTitle: string;
|
||||
loading?: boolean; // New prop
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
className?: string;
|
||||
inputRef?: React.RefObject<HTMLInputElement>; // New prop type
|
||||
inputOnFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
inputOnBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
inputTitleFontSize?: string;
|
||||
iconProps?: {
|
||||
size?: number;
|
||||
color?: string;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
}; // New prop for icon customization
|
||||
}) {
|
||||
return (
|
||||
<div className={`w-full flex flex-col ${inputTitle ? "gap-2" : "gap-0"}`}>
|
||||
<p
|
||||
className={`${inputTitleFontSize || "text-R14"} text-foreground dark:text-gray-200`}
|
||||
>
|
||||
{inputTitle}
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton className="w-full h-[48px]" /> // Display skeleton when loading
|
||||
) : (
|
||||
<Input
|
||||
iconSrc={typeof inputIcon === "string" ? inputIcon : undefined}
|
||||
iconComponent={inputIcon}
|
||||
iconProps={iconProps}
|
||||
dir={inputDir || "ltr"}
|
||||
type={type || "text"}
|
||||
placeholder={inputPlaceholder}
|
||||
value={inputValue}
|
||||
onChange={inputOnChange}
|
||||
disabled={disabled}
|
||||
onKeyDown={onKeyDown}
|
||||
className={className}
|
||||
ref={inputRef}
|
||||
onFocus={inputOnFocus}
|
||||
onBlur={inputOnBlur}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppInput;
|
||||
112
app/components/AppSelectBox.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { Check, Loader } from "lucide-react";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "../components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "../components/ui/popover";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Item {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface AppSelectBoxProps {
|
||||
items: Item[];
|
||||
placeholder?: string;
|
||||
haveSearchbar?: boolean;
|
||||
value: string;
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
loading?: boolean; // New loading prop
|
||||
width?: string; // New width prop
|
||||
disabled?: boolean;
|
||||
title?: string; // Add title prop
|
||||
}
|
||||
|
||||
export default function AppSelectBox({
|
||||
haveSearchbar,
|
||||
items,
|
||||
placeholder,
|
||||
value,
|
||||
setValue,
|
||||
loading = false, // Default to false
|
||||
width = "w-[105px]", // Default width
|
||||
disabled = false,
|
||||
title,
|
||||
}: AppSelectBoxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`w-full flex flex-col ${title ? "gap-2" : "gap-0"}`}>
|
||||
{title && (
|
||||
<p className="text-R14 text-foreground dark:text-gray-200">{title}</p>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={"lg"}
|
||||
aria-expanded={open}
|
||||
className={`${width} ${value ? "text-BLACK dark:text-gray-200" : "text-GRAY dark:text-gray-400"} rounded-[12px] border border-input bg-inputBg dark:bg-gray-800 dark:border-gray-600`}
|
||||
disabled={disabled || loading} // Disable button when loading
|
||||
>
|
||||
{loading ? (
|
||||
<Loader className="animate-spin" size={16} /> // Show loading spinner
|
||||
) : value ? (
|
||||
items.find((item: Item) => item.value.toString() === value)?.label
|
||||
) : (
|
||||
placeholder
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{!loading && ( // Only show dropdown content when not loading
|
||||
<PopoverContent className={`${width} p-0`}>
|
||||
<Command>
|
||||
{haveSearchbar && (
|
||||
<CommandInput placeholder="جستجو" className="h-9" />
|
||||
)}
|
||||
<CommandList>
|
||||
<CommandEmpty>آیتمی وجود نداره</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{items.map((item: Item) => (
|
||||
<CommandItem
|
||||
key={item.value}
|
||||
value={item.value.toString()}
|
||||
onSelect={() => {
|
||||
setValue(
|
||||
item.value === value ? "" : item.value.toString()
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === item.value.toString()
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
app/components/AppTextArea.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
export default function AppTextArea({
|
||||
inputValue,
|
||||
inputOnChange,
|
||||
inputPlaceholder,
|
||||
inputDir,
|
||||
disabled,
|
||||
inputTitle,
|
||||
loading,
|
||||
}: {
|
||||
inputValue: string;
|
||||
inputOnChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
inputPlaceholder: string;
|
||||
inputDir?: string;
|
||||
disabled?: boolean;
|
||||
inputTitle: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<p className={"text-R14 text-foreground dark:text-gray-200"}>
|
||||
{inputTitle}
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton className="w-full h-[120px]" />
|
||||
) : (
|
||||
<textarea
|
||||
dir={inputDir || "rtl"}
|
||||
placeholder={inputPlaceholder}
|
||||
value={inputValue}
|
||||
onChange={inputOnChange}
|
||||
disabled={disabled}
|
||||
className="flex h-[120px] w-full rounded-[12px] border border-input bg-inputBg px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-inputPlaceholder focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:placeholder:text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
393
app/components/BankLogo.tsx
Normal file
@ -0,0 +1,393 @@
|
||||
import { IranianBank } from "~/utils/iranian-banks";
|
||||
|
||||
interface BankLogoProps {
|
||||
bank: IranianBank;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BankLogo({ bank, size = 24, className = "" }: BankLogoProps) {
|
||||
// Map of bank names to their logo image paths
|
||||
const bankLogoPath: Record<string, string> = {
|
||||
"ملی ایران": "/images/banks/melli.png",
|
||||
ملت: "/images/banks/mellat.png",
|
||||
سپه: "/images/banks/sepah.png",
|
||||
پارسیان: "/images/banks/parsian.png",
|
||||
پاسارگاد: "/images/banks/pasargad.png",
|
||||
تجارت: "/images/banks/tejarat.png",
|
||||
سامان: "/images/banks/saman.png",
|
||||
کشاورزی: "/images/banks/keshavarzi.png",
|
||||
"صادرات ایران": "/images/banks/saderat.png",
|
||||
"رفاه کارگران": "/images/banks/refah.png",
|
||||
"اقتصاد نوین": "/images/banks/eghtesad-novin.png",
|
||||
"توسعه صادرات ایران": "/images/banks/edbi.png",
|
||||
انصار: "/images/banks/ansar.png",
|
||||
"ایران زمین": "/images/banks/iran-zamin.png",
|
||||
شهر: "/images/banks/shahr.png",
|
||||
"توسعه تعاون": "/images/banks/tosee-taavon.png",
|
||||
کارآفرین: "/images/banks/karafarin.png",
|
||||
دی: "/images/banks/dey.png",
|
||||
گردشگری: "/images/banks/gardeshgari.png",
|
||||
آینده: "/images/banks/ayandeh.png",
|
||||
"موسسه اعتباری کوثر": "/images/banks/kosar.png",
|
||||
"قرض الحسنه مهر ایران": "/images/banks/mehr-iran.png",
|
||||
سرمایه: "/images/banks/sarmayeh.png",
|
||||
سینا: "/images/banks/sina.png",
|
||||
"صنعت و معدن": "/images/banks/sanat-madan.png",
|
||||
قوامین: "/images/banks/ghavamin.png",
|
||||
"پست ایران": "/images/banks/post-bank.png",
|
||||
مسکن: "/images/banks/maskan.png",
|
||||
"موسسه اعتباری توسعه": "/images/banks/tosee.png",
|
||||
مرکزی: "/images/banks/markazi.png",
|
||||
"حکمت ایرانیان": "/images/banks/hekmat.png",
|
||||
"مهر اقتصاد": "/images/banks/mehr-eghtesad.png",
|
||||
};
|
||||
|
||||
// Check if we have a logo image for this bank
|
||||
const logoPath = bankLogoPath[bank.name];
|
||||
|
||||
if (logoPath) {
|
||||
return (
|
||||
<img
|
||||
src={logoPath}
|
||||
alt={`لوگوی بانک ${bank.name}`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={`rounded ${className}`}
|
||||
onError={(e) => {
|
||||
// If image fails to load, hide it and show fallback
|
||||
e.currentTarget.style.display = "none";
|
||||
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||
if (fallback) fallback.style.display = "block";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: Use old SVG icons as backup
|
||||
const bankIcons: Record<string, JSX.Element> = {
|
||||
"ملی ایران": (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#melli-gradient)" />
|
||||
<path
|
||||
d="M12 6L6 10V18H10V14H14V18H18V10L12 6Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="melli-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1e3a8a" />
|
||||
<stop offset="1" stopColor="#60a5fa" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
ملت: (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#mellat-gradient)" />
|
||||
<circle cx="12" cy="12" r="6" fill="white" opacity="0.9" />
|
||||
<path d="M12 8L14 12L12 16L10 12L12 8Z" fill="url(#mellat-gradient)" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="mellat-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#b91c1c" />
|
||||
<stop offset="1" stopColor="#f87171" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
سپه: (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#sepah-gradient)" />
|
||||
<path
|
||||
d="M8 6H16V10H8V6Z M6 11H18V15H6V11Z M9 16H15V20H9V16Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sepah-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1e40af" />
|
||||
<stop offset="1" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
پارسیان: (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#parsian-gradient)" />
|
||||
<path
|
||||
d="M12 5L17 9V19H7V9L12 5Z M10 12H14V16H10V12Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="parsian-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1e40af" />
|
||||
<stop offset="1" stopColor="#60a5fa" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
پاسارگاد: (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#pasargad-gradient)" />
|
||||
<path
|
||||
d="M12 4L6 8L12 12L18 8L12 4Z M6 10L12 14L18 10V16L12 20L6 16V10Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="pasargad-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#4c1d95" />
|
||||
<stop offset="1" stopColor="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
تجارت: (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#tejarat-gradient)" />
|
||||
<path
|
||||
d="M6 8H18V11H6V8Z M8 12H16V15H8V12Z M10 16H14V19H10V16Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="tejarat-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#15803d" />
|
||||
<stop offset="1" stopColor="#22c55e" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
سامان: (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#saman-gradient)" />
|
||||
<circle cx="12" cy="9" r="3" fill="white" opacity="0.9" />
|
||||
<path
|
||||
d="M7 16C7 13.5 9 12 12 12C15 12 17 13.5 17 16V19H7V16Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="saman-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1e40af" />
|
||||
<stop offset="1" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
کشاورزی: (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#keshavarzi-gradient)" />
|
||||
<path
|
||||
d="M12 4C9 4 7 6 7 9C7 11 8 12 9 13L12 19L15 13C16 12 17 11 17 9C17 6 15 4 12 4Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<circle cx="12" cy="9" r="2" fill="url(#keshavarzi-gradient)" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="keshavarzi-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#15803d" />
|
||||
<stop offset="1" stopColor="#4ade80" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
"صادرات ایران": (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#saderat-gradient)" />
|
||||
<path
|
||||
d="M4 12L12 4L20 12L12 20L4 12Z M12 8L8 12L12 16L16 12L12 8Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="saderat-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#065f46" />
|
||||
<stop offset="1" stopColor="#34d399" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
"رفاه کارگران": (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect width="24" height="24" rx="4" fill="url(#refah-gradient)" />
|
||||
<path d="M12 6L8 10H11V18H13V10H16L12 6Z" fill="white" opacity="0.9" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="refah-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#9f1239" />
|
||||
<stop offset="1" stopColor="#fb7185" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Get specific icon or use a generic one
|
||||
const icon = bankIcons[bank.name] || (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
rx="4"
|
||||
fill={`url(#generic-gradient-${bank.name})`}
|
||||
/>
|
||||
<path
|
||||
d="M12 4L4 8V10H20V8L12 4Z M5 11H7V19H5V11Z M9 11H11V19H9V11Z M13 11H15V19H13V11Z M17 11H19V19H17V11Z M4 20H20V22H4V20Z"
|
||||
fill="white"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`generic-gradient-${bank.name}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2="24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor={bank.color.from} />
|
||||
<stop offset="1" stopColor={bank.color.to} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return icon;
|
||||
}
|
||||
57
app/components/CallDialog.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogOverlay,
|
||||
} from "./ui/dialog";
|
||||
import { Phone } from "lucide-react";
|
||||
|
||||
export default function CallDialog({
|
||||
isCallModalOpen,
|
||||
setIsCallModalOpen,
|
||||
handleCallConfirm,
|
||||
}: {
|
||||
isCallModalOpen: boolean;
|
||||
setIsCallModalOpen: (open: boolean) => void;
|
||||
handleCallConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={isCallModalOpen} onOpenChange={setIsCallModalOpen}>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
className={`p-4 bg-WHITE overflow-y-auto rounded-[10px] w-[calc(100%-40px)]`}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-center gap-2">
|
||||
<Phone size={20} />
|
||||
تماس با پشتیبانی
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-GRAY">آیا میخواهید تماس بگیرید؟</p>
|
||||
</div>
|
||||
<DialogFooter className="flex gap-2 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={() => setIsCallModalOpen(false)}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="dark"
|
||||
onClick={handleCallConfirm}
|
||||
className="w-full"
|
||||
>
|
||||
تماس بگیرید
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
16
app/components/ClientOnly.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface ClientOnlyProps {
|
||||
children: () => React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted ? <>{children()}</> : <>{fallback}</>;
|
||||
}
|
||||
276
app/components/FilterDrawer.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import React, { useState } from "react";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { X } from "lucide-react";
|
||||
import { DrawerHeader, DrawerTitle, DrawerContent, Drawer } from "./ui/drawer";
|
||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
|
||||
import { Slider } from "./ui/slider";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
import filterIcon from "~/assets/icons/filter.png";
|
||||
import filterIconDark from "~/assets/icons/filter-dark.png";
|
||||
|
||||
interface FilterDrawerProps {
|
||||
minimumPrice: number;
|
||||
maximumPrice: number;
|
||||
}
|
||||
|
||||
const FilterDrawer: React.FC<FilterDrawerProps> = ({
|
||||
minimumPrice,
|
||||
maximumPrice,
|
||||
}) => {
|
||||
const { theme } = useRootData();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [order, setOrder] = useState(searchParams.get("order") || "all");
|
||||
|
||||
// Get initial values from URL params or use defaults
|
||||
const initialMinPrice = searchParams.get("priceMin")
|
||||
? parseInt(searchParams.get("priceMin") || "", 10)
|
||||
: minimumPrice;
|
||||
|
||||
const initialMaxPrice = searchParams.get("priceMax")
|
||||
? parseInt(searchParams.get("priceMax") || "", 10)
|
||||
: maximumPrice;
|
||||
|
||||
// Check if any filters are different from defaults
|
||||
const isFiltersChanged =
|
||||
order !== "all" ||
|
||||
initialMinPrice !== minimumPrice ||
|
||||
initialMaxPrice !== maximumPrice;
|
||||
|
||||
const [priceRange, setPriceRange] = useState<[number, number]>([
|
||||
initialMinPrice,
|
||||
initialMaxPrice,
|
||||
]);
|
||||
|
||||
// Check if current values are different from URL values
|
||||
const hasChanges =
|
||||
order !== (searchParams.get("order") || "all") ||
|
||||
priceRange[0] !== initialMinPrice ||
|
||||
priceRange[1] !== initialMaxPrice;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center justify-center rounded-[10px] h-10 w-10"
|
||||
onClick={() => setIsOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Open filters"
|
||||
>
|
||||
<img
|
||||
src={theme === "dark" ? filterIconDark : filterIcon}
|
||||
alt="filter"
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
</div>
|
||||
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DrawerContent className="w-full pb-6">
|
||||
<DrawerHeader className="flex flex-col py-3">
|
||||
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
|
||||
<X
|
||||
className="absolute right-0 cursor-pointer"
|
||||
size={24}
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<DrawerTitle className="text-B12 font-bold">
|
||||
فیلتر نتایج
|
||||
</DrawerTitle>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<div className="w-full p-4">
|
||||
<div className="flex flex-col gap-4 w-full border-b pb-4">
|
||||
<RadioGroup
|
||||
className="flex w-full justify-between"
|
||||
onValueChange={(value) => setOrder(value)}
|
||||
value={order}
|
||||
>
|
||||
<RadioGroupItem
|
||||
circleClass="h-4 w-4"
|
||||
className="h-6 w-6"
|
||||
value={"all"}
|
||||
id="all"
|
||||
/>
|
||||
<label htmlFor="all" className="text-R16 w-full text-right">
|
||||
همه نتایج
|
||||
</label>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
className="flex w-full justify-between"
|
||||
onValueChange={(value) => setOrder(value)}
|
||||
value={order}
|
||||
>
|
||||
<RadioGroupItem
|
||||
circleClass="h-4 w-4"
|
||||
className="h-6 w-6"
|
||||
value={"cheap"}
|
||||
id="cheap"
|
||||
/>
|
||||
<label htmlFor="cheap" className="text-R16 w-full text-right">
|
||||
ارزان ترین
|
||||
</label>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
className="flex w-full justify-between"
|
||||
onValueChange={(value) => setOrder(value)}
|
||||
value={order}
|
||||
>
|
||||
<RadioGroupItem
|
||||
circleClass="h-4 w-4"
|
||||
className="h-6 w-6"
|
||||
value={"expensive"}
|
||||
id="expensive"
|
||||
/>
|
||||
<label
|
||||
htmlFor="expensive"
|
||||
className="text-R16 w-full text-right"
|
||||
>
|
||||
گران ترین
|
||||
</label>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
className="flex w-full justify-between"
|
||||
onValueChange={(value) => setOrder(value)}
|
||||
value={order}
|
||||
>
|
||||
<RadioGroupItem
|
||||
circleClass="h-4 w-4"
|
||||
className="h-6 w-6"
|
||||
value={"newest"}
|
||||
id="newest"
|
||||
/>
|
||||
<label htmlFor="newest" className="text-R16 w-full text-right">
|
||||
جدید ترین
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full p-4 flex flex-col gap-4">
|
||||
<p className="R14">محدوده قیمت :</p>
|
||||
<Slider
|
||||
dir="rtl"
|
||||
defaultValue={[initialMinPrice, initialMaxPrice]}
|
||||
value={priceRange}
|
||||
min={minimumPrice}
|
||||
max={maximumPrice}
|
||||
step={10000}
|
||||
onValueChange={(value: number[]) =>
|
||||
setPriceRange(value as [number, number])
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-6 justify-between w-full">
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="R12">کمترین قیمت</p>
|
||||
<div className="flex w-full items-center gap-1 h-12 border border-BLACK rounded-[8px]">
|
||||
<input
|
||||
type="text"
|
||||
value={priceRange[0].toLocaleString()}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(
|
||||
e.target.value.replace(/,/g, ""),
|
||||
10
|
||||
);
|
||||
if (!isNaN(value)) {
|
||||
setPriceRange([value, priceRange[1]]);
|
||||
}
|
||||
}}
|
||||
className="w-full text-GRAY focus:text-BLACK px-4 h-full flex items-center justify-start bg-transparent outline-none"
|
||||
/>
|
||||
<div className="text-R12 text-GRAY p-2 border-r border-BLACK h-full flex items-center justify-start">
|
||||
تومان
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="R12">بیشترین قیمت</p>
|
||||
<div className="flex w-full items-center gap-1 h-12 border border-BLACK rounded-[8px]">
|
||||
<input
|
||||
type="text"
|
||||
value={priceRange[1].toLocaleString()}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(
|
||||
e.target.value.replace(/,/g, ""),
|
||||
10
|
||||
);
|
||||
if (!isNaN(value)) {
|
||||
setPriceRange([priceRange[0], value]);
|
||||
}
|
||||
}}
|
||||
className="w-full text-GRAY focus:text-BLACK px-4 h-full flex items-center justify-start bg-transparent outline-none"
|
||||
/>
|
||||
<div className="text-R12 text-GRAY p-2 border-r border-BLACK h-full flex items-center justify-start">
|
||||
تومان
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-6 w-full px-4 pt-6 pb-2">
|
||||
<Button
|
||||
disabled={!hasChanges}
|
||||
className="w-[100%]"
|
||||
size={"xl"}
|
||||
variant="dark"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (order !== "all") {
|
||||
params.set("order", order);
|
||||
} else {
|
||||
params.delete("order");
|
||||
}
|
||||
|
||||
if (priceRange[0] !== minimumPrice) {
|
||||
params.set("priceMin", priceRange[0].toString());
|
||||
} else {
|
||||
params.delete("priceMin");
|
||||
}
|
||||
|
||||
if (priceRange[1] !== maximumPrice) {
|
||||
params.set("priceMax", priceRange[1].toString());
|
||||
} else {
|
||||
params.delete("priceMax");
|
||||
}
|
||||
|
||||
setSearchParams(params, { replace: true });
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
مشاهده نتایج
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!isFiltersChanged}
|
||||
className="w-[100%]"
|
||||
size={"xl"}
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
// Reset all filter values
|
||||
setOrder("all");
|
||||
setPriceRange([minimumPrice, maximumPrice]);
|
||||
|
||||
// Keep search query but clear filter params
|
||||
const params = new URLSearchParams();
|
||||
const searchQuery = searchParams.get("q");
|
||||
if (searchQuery) {
|
||||
params.set("q", searchQuery);
|
||||
}
|
||||
setSearchParams(params, { replace: true });
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
حذف فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterDrawer;
|
||||
70
app/components/HeaderWithSupport.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useState, memo, useCallback, useMemo } from "react";
|
||||
import { ArrowRight, Headset } from "lucide-react";
|
||||
import CallDialog from "./CallDialog";
|
||||
|
||||
interface HeaderWithSupportProps {
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
hideSupportButton?: boolean;
|
||||
}
|
||||
|
||||
const HeaderWithSupport = memo(function HeaderWithSupport({
|
||||
title,
|
||||
onBack,
|
||||
hideSupportButton,
|
||||
}: HeaderWithSupportProps) {
|
||||
const [isCallModalOpen, setIsCallModalOpen] = useState(false);
|
||||
const supportPhone = useMemo(
|
||||
() => import.meta.env.VITE_SUPPORT_PHONE || "",
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSupportClick = useCallback(() => {
|
||||
setIsCallModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCallConfirm = useCallback(() => {
|
||||
window.location.href = `tel:${supportPhone}`;
|
||||
setIsCallModalOpen(false);
|
||||
}, [supportPhone]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSupportClick();
|
||||
}
|
||||
},
|
||||
[handleSupportClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
|
||||
<ArrowRight
|
||||
onClick={onBack}
|
||||
className="absolute top-5 right-5 cursor-pointer"
|
||||
/>
|
||||
<p className="text-B16 font-bold ">{title}</p>
|
||||
{!hideSupportButton && (
|
||||
<div
|
||||
className="absolute top-4 h-8 w-8 left-5 rounded-full bg-WHITE shadow-xl flex items-center justify-center cursor-pointer"
|
||||
onClick={handleSupportClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Headset size={20} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CallDialog
|
||||
isCallModalOpen={isCallModalOpen}
|
||||
setIsCallModalOpen={setIsCallModalOpen}
|
||||
handleCallConfirm={handleCallConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default HeaderWithSupport;
|
||||
278
app/components/HorizontalCollectionsList.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight, Package, ShoppingBag } from "lucide-react";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { ErrorState } from "./UiProvider";
|
||||
|
||||
interface HorizontalCollection {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
coverImageUrl?: string | null;
|
||||
productCount?: number;
|
||||
}
|
||||
|
||||
const HorizontalCollectionsList = ({
|
||||
collections,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
showViewAllButton = false,
|
||||
}: {
|
||||
collections: HorizontalCollection[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
showViewAllButton?: boolean;
|
||||
}) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftButton, setShowLeftButton] = useState(false);
|
||||
const [showRightButton, setShowRightButton] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const totalPages = collections.length;
|
||||
|
||||
// Helper function to calculate card width consistently
|
||||
const getCardWidth = useCallback(() => {
|
||||
// Same calculation as scrollToNext/scrollToPrev: 85vw (max 300px) + 16px gap
|
||||
return Math.min(window.innerWidth * 0.85, 300) + 16;
|
||||
}, []);
|
||||
|
||||
const checkScrollButtons = useCallback(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
||||
|
||||
// Calculate max scroll position
|
||||
const maxScroll = scrollWidth - clientWidth;
|
||||
|
||||
// For RTL, scrollLeft might be negative or start from max
|
||||
// Check if we can scroll in each direction
|
||||
const canScrollRight = Math.abs(scrollLeft) > 100;
|
||||
const canScrollLeft = Math.abs(scrollLeft) < maxScroll - 100;
|
||||
|
||||
setShowRightButton(canScrollRight);
|
||||
setShowLeftButton(canScrollLeft);
|
||||
|
||||
// Calculate card width using the same logic as scroll functions
|
||||
const cardWidth = getCardWidth();
|
||||
|
||||
// Calculate current page based on scroll position
|
||||
const page = Math.round(Math.abs(scrollLeft) / cardWidth);
|
||||
setCurrentPage(Math.min(page, collections.length - 1));
|
||||
}, [collections.length, getCardWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
// Small delay to ensure content is rendered
|
||||
const timer = setTimeout(() => {
|
||||
checkScrollButtons();
|
||||
}, 100);
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener("scroll", checkScrollButtons);
|
||||
window.addEventListener("resize", checkScrollButtons);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", checkScrollButtons);
|
||||
window.removeEventListener("resize", checkScrollButtons);
|
||||
};
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [collections, checkScrollButtons]);
|
||||
|
||||
const scrollToNext = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
const container = scrollContainerRef.current;
|
||||
const cardWidth = getCardWidth();
|
||||
container.scrollBy({ left: cardWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const scrollToPrev = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
const container = scrollContainerRef.current;
|
||||
const cardWidth = getCardWidth();
|
||||
container.scrollBy({ left: -cardWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <HorizontalSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="px-4">
|
||||
<ErrorState onRetry={refetch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (collections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
{/* Scroll Container */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="w-full overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4"
|
||||
>
|
||||
<div className="flex" style={{ width: "max-content" }}>
|
||||
{collections.map((collection, index) => (
|
||||
<Link
|
||||
key={collection.id || index}
|
||||
to={`/collection/${collection.id}`}
|
||||
className="flex flex-col pr-2 md:pr-4 snap-start group"
|
||||
style={{ width: "calc(85vw)", maxWidth: "300px" }}
|
||||
>
|
||||
<div className="flex flex-col gap-3 p-3 bg-WHITE2 rounded-2xl border border-inner-border hover:border-gray-300 transition-all duration-300 hover:shadow-lg h-full">
|
||||
{/* Collection Image */}
|
||||
<div className="relative overflow-hidden rounded-xl w-full h-64 bg-gray-100">
|
||||
{collection.coverImageUrl ? (
|
||||
<img
|
||||
src={collection.coverImageUrl}
|
||||
alt={collection.title || "Collection"}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-WHITE2">
|
||||
<Package size={48} className="text-GRAY" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Count Badge */}
|
||||
{collection.productCount !== undefined && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<div className="flex items-center gap-1.5 bg-white/95 backdrop-blur-sm px-2.5 py-1 rounded-full shadow-md">
|
||||
<ShoppingBag size={12} className="text-black" />
|
||||
<span className="text-R12 text-black font-bold">
|
||||
{collection.productCount} محصول
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collection Info */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h3 className="text-B14 font-bold text-black line-clamp-2">
|
||||
{collection.title || "بدون عنوان"}
|
||||
</h3>
|
||||
|
||||
{collection.description && (
|
||||
<p className="text-R12 text-GRAY line-clamp-2">
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* View All Button Card */}
|
||||
{showViewAllButton && (
|
||||
<Link
|
||||
to="/collections"
|
||||
className="flex flex-col pr-2 md:pr-4 snap-start group"
|
||||
style={{ width: "calc(85vw)", maxWidth: "300px" }}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-3 p-3 bg-gradient-to-br from-black to-gray-800 rounded-2xl border border-inner-border hover:border-gray-600 transition-all duration-300 hover:shadow-lg h-full min-h-[304px]">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
<Package size={32} className="text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h3 className="text-B16 font-bold text-white text-center">
|
||||
مشاهده همه کالکشنها
|
||||
</h3>
|
||||
<p className="text-R12 text-white/70 text-center">
|
||||
کاوش در کالکشنهای بیشتر
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 text-white group-hover:gap-3 transition-all duration-300">
|
||||
<span className="text-R14 font-bold">مشاهده همه</span>
|
||||
<ChevronLeft
|
||||
size={18}
|
||||
className="group-hover:translate-x-[-4px] transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Navigation Buttons Row */}
|
||||
{collections.length > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 px-4">
|
||||
<motion.button
|
||||
whileHover={!showRightButton ? {} : { scale: 1.1 }}
|
||||
whileTap={!showRightButton ? {} : { scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
onClick={scrollToNext}
|
||||
disabled={!showRightButton}
|
||||
aria-label="Next collections"
|
||||
>
|
||||
<ChevronRight
|
||||
size={20}
|
||||
className={showRightButton ? "text-BLACK" : "text-GRAY"}
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
{/* Dots indicator */}
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: totalPages }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||
i === currentPage ? "w-6 bg-BLACK" : "w-1.5 bg-GRAY3"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
whileHover={!showLeftButton ? {} : { scale: 1.1 }}
|
||||
whileTap={!showLeftButton ? {} : { scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
onClick={scrollToPrev}
|
||||
disabled={!showLeftButton}
|
||||
aria-label="Previous collections"
|
||||
>
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
className={showLeftButton ? "text-BLACK" : "text-GRAY"}
|
||||
/>
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HorizontalSkeleton = () => {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto scrollbar-hide">
|
||||
<div className="flex" style={{ width: "max-content" }}>
|
||||
{Array(4)
|
||||
.fill(1)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col pr-2 md:pr-4 snap-start group"
|
||||
style={{ width: "calc(85vw)", maxWidth: "300px" }}
|
||||
>
|
||||
<div className="flex flex-col gap-3 p-3 bg-WHITE2 rounded-2xl border border-inner-border">
|
||||
<Skeleton className="w-full h-64 rounded-xl" />
|
||||
<Skeleton className="w-3/4 h-4" />
|
||||
<Skeleton className="w-full h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HorizontalCollectionsList;
|
||||
289
app/components/HorizontalProductList.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight, Images } from "lucide-react";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { ErrorState } from "./UiProvider";
|
||||
import discountRed from "~/assets/icons/product/discount-red.svg";
|
||||
import placeholderImage from "~/assets/icons/product.png";
|
||||
|
||||
interface HorizontalProduct {
|
||||
id?: string;
|
||||
title?: string;
|
||||
images?: string[];
|
||||
discountPercent?: number;
|
||||
discountPrice?: number;
|
||||
basePrice?: number;
|
||||
}
|
||||
|
||||
const HorizontalProductList = ({
|
||||
products,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
storeId,
|
||||
storePage,
|
||||
}: {
|
||||
products: HorizontalProduct[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
storeId?: string;
|
||||
storePage?: boolean;
|
||||
}) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftButton, setShowLeftButton] = useState(false);
|
||||
const [showRightButton, setShowRightButton] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const totalPages = Math.ceil(products.length / 2);
|
||||
|
||||
const checkScrollButtons = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
||||
|
||||
// Calculate max scroll position
|
||||
const maxScroll = scrollWidth - clientWidth;
|
||||
|
||||
// For RTL, scrollLeft might be negative or start from max
|
||||
// Check if we can scroll in each direction
|
||||
const canScrollRight = Math.abs(scrollLeft) > 100;
|
||||
const canScrollLeft = Math.abs(scrollLeft) < maxScroll - 100;
|
||||
|
||||
setShowRightButton(canScrollRight);
|
||||
setShowLeftButton(canScrollLeft);
|
||||
|
||||
// Calculate current page based on scroll position
|
||||
const page = Math.round(Math.abs(scrollLeft) / clientWidth);
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Small delay to ensure content is rendered
|
||||
const timer = setTimeout(() => {
|
||||
checkScrollButtons();
|
||||
}, 100);
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener("scroll", checkScrollButtons);
|
||||
window.addEventListener("resize", checkScrollButtons);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", checkScrollButtons);
|
||||
window.removeEventListener("resize", checkScrollButtons);
|
||||
};
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [products]);
|
||||
|
||||
const scrollToNext = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
const container = scrollContainerRef.current;
|
||||
const scrollAmount = container.clientWidth;
|
||||
container.scrollBy({ left: scrollAmount, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const scrollToPrev = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
const container = scrollContainerRef.current;
|
||||
const scrollAmount = container.clientWidth;
|
||||
container.scrollBy({ left: -scrollAmount, behavior: "smooth" });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <HorizontalSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="px-4">
|
||||
<ErrorState onRetry={refetch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
{/* Scroll Container */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="w-full overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4"
|
||||
>
|
||||
<div className="flex" style={{ width: "max-content" }}>
|
||||
{products.map((product, index) => (
|
||||
<div
|
||||
key={product.id || index}
|
||||
className="flex flex-col gap-2 pr-4 snap-start"
|
||||
style={{ width: "calc(50vw - 14px)", maxWidth: "220px" }}
|
||||
>
|
||||
<Link
|
||||
to={`${storePage ? `/store/${storeId}/product/${product.id}` : `/product/${product.id}`}`}
|
||||
className="block w-full"
|
||||
>
|
||||
<div className="relative rounded-lg overflow-hidden h-[180px]">
|
||||
{/* Single Image Display */}
|
||||
<ProductThumbnail
|
||||
image={product.images?.[0]}
|
||||
imageCount={product.images?.length || 0}
|
||||
title={product.title}
|
||||
/>
|
||||
{/* Discount badge */}
|
||||
{product.discountPercent && product.discountPercent !== 0 && (
|
||||
<DiscountBadge discountPercent={product.discountPercent} />
|
||||
)}
|
||||
{/* Overlay info */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-2 pt-6 pointer-events-none">
|
||||
<div className="flex w-full justify-between gap-1">
|
||||
{/* Title */}
|
||||
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">
|
||||
{product.title}
|
||||
</h3>
|
||||
{/* Price */}
|
||||
<div className="flex flex-col items-center gap-0 relative">
|
||||
{product.discountPrice && (
|
||||
<p className="text-white text-xs font-bold">
|
||||
{`${product.discountPrice.toLocaleString()}`}
|
||||
<span className="text-white text-[8px] mr-[2px]">
|
||||
تومان
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{product.basePrice && product.discountPercent ? (
|
||||
<p className="text-white/80 text-[10px] line-through absolute bottom-4 left-0">
|
||||
{product.basePrice.toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Navigation Buttons Row */}
|
||||
{products.length > 2 && (
|
||||
<div className="flex items-center justify-center gap-3 px-4">
|
||||
<motion.button
|
||||
whileHover={!showRightButton ? {} : { scale: 1.1 }}
|
||||
whileTap={!showRightButton ? {} : { scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
onClick={scrollToNext}
|
||||
disabled={!showRightButton}
|
||||
aria-label="Next products"
|
||||
>
|
||||
<ChevronRight
|
||||
size={20}
|
||||
className={showRightButton ? "text-BLACK" : "text-GRAY"}
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
{/* Dots indicator */}
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: totalPages }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||
i === currentPage ? "w-6 bg-BLACK" : "w-1.5 bg-GRAY3"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
whileHover={!showLeftButton ? {} : { scale: 1.1 }}
|
||||
whileTap={!showLeftButton ? {} : { scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
onClick={scrollToPrev}
|
||||
disabled={!showLeftButton}
|
||||
aria-label="Previous products"
|
||||
>
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
className={showLeftButton ? "text-BLACK" : "text-GRAY"}
|
||||
/>
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple thumbnail component - shows only first image with count badge
|
||||
const ProductThumbnail = ({
|
||||
image,
|
||||
imageCount,
|
||||
title,
|
||||
}: {
|
||||
image?: string;
|
||||
imageCount: number;
|
||||
title?: string;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={image || placeholderImage}
|
||||
alt={title || "محصول"}
|
||||
className={`w-full h-full ${image ? "object-cover" : "object-contain bg-GRAY4"}`}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = placeholderImage;
|
||||
e.currentTarget.style.objectFit = "contain";
|
||||
e.currentTarget.className = "w-full h-full object-contain bg-GRAY4";
|
||||
}}
|
||||
/>
|
||||
{/* Image count badge - only show if more than 1 image */}
|
||||
{imageCount > 1 && (
|
||||
<div className="absolute top-2 left-2 z-10 bg-DARK_OVERLAY/80 text-WHITE px-2 py-1 rounded-full flex items-center gap-1">
|
||||
<Images size={12} />
|
||||
<span className="text-[12px] font-medium">{imageCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DiscountBadge = ({ discountPercent }: { discountPercent?: number }) => {
|
||||
return (
|
||||
<div className="absolute items-center gap-1 flex top-1 right-1 z-10 bg-[#FDFDFD]/75 text-[#FF3B30] py-[2px] px-2 rounded-[15px]">
|
||||
<p
|
||||
className="text-B14 font-bold leading-[20px]"
|
||||
dir="ltr"
|
||||
style={{ fontFamily: "Arial, sans-serif" }}
|
||||
>
|
||||
{discountPercent}
|
||||
</p>
|
||||
<img src={discountRed} alt="discount-red" className="w-5 h-5" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HorizontalSkeleton = () => {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto scrollbar-hide">
|
||||
<div className="flex gap-4 px-4" style={{ width: "max-content" }}>
|
||||
{Array(6)
|
||||
.fill(1)
|
||||
.map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
width: "calc(50vw - 32px)",
|
||||
maxWidth: "220px",
|
||||
height: "180px",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HorizontalProductList;
|
||||
1137
app/components/InteractiveMap.tsx
Normal file
298
app/components/LoginRequiredDialog.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
import { Dialog, DialogContent, DialogOverlay } from "./ui/dialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowLeft, Loader2, Phone, RotateCcw, LogIn } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSubmit, useRevalidator } from "@remix-run/react";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "~/components/ui/input-otp";
|
||||
import AppInput from "~/components/AppInput";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import {
|
||||
useServiceSendOtp,
|
||||
useServiceVerifyOtp,
|
||||
} from "~/utils/RequestHandler";
|
||||
|
||||
interface LoginRequiredDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
loginTitle: string;
|
||||
}
|
||||
|
||||
// Helper function to convert Persian numbers to English
|
||||
const convertPersianToEnglish = (str: string): string => {
|
||||
const persianNumbers = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
|
||||
const englishNumbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
||||
|
||||
let result = str;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
result = result.replace(
|
||||
new RegExp(persianNumbers[i], "g"),
|
||||
englishNumbers[i]
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const ResendTimer = ({
|
||||
initialTime = 120,
|
||||
onTimerEnd,
|
||||
}: {
|
||||
initialTime?: number;
|
||||
onTimerEnd?: () => void;
|
||||
}) => {
|
||||
const [timeLeft, setTimeLeft] = useState(initialTime);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft <= 0) {
|
||||
if (onTimerEnd) onTimerEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft((prev) => prev - 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [timeLeft, onTimerEnd]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<p className="text-GRAY text-R12">
|
||||
{formatTime(timeLeft)} تا ارسال مجدد کد
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export function LoginRequiredDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
loginTitle,
|
||||
}: LoginRequiredDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const submit = useSubmit();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [otpValue, setOtpValue] = useState("");
|
||||
const [enableVerifySection, setEnableVerifySection] = useState(false);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
|
||||
const sendOtpMutation = useServiceSendOtp(
|
||||
phoneNumber,
|
||||
setEnableVerifySection
|
||||
);
|
||||
|
||||
const verifyOtpMutation = useServiceVerifyOtp(phoneNumber, otpValue, toast);
|
||||
|
||||
// Handle successful verification
|
||||
useEffect(() => {
|
||||
if (
|
||||
verifyOtpMutation.data &&
|
||||
verifyOtpMutation.data.accessToken &&
|
||||
verifyOtpMutation.data.refreshToken
|
||||
) {
|
||||
const currentUrl = window.location.pathname;
|
||||
submit(
|
||||
{
|
||||
phoneNumber: phoneNumber,
|
||||
access_token: JSON.stringify(verifyOtpMutation.data.accessToken),
|
||||
refresh_token: JSON.stringify(verifyOtpMutation.data.refreshToken),
|
||||
redirectTo: currentUrl,
|
||||
},
|
||||
{
|
||||
action: "/login",
|
||||
method: "post",
|
||||
encType: "application/x-www-form-urlencoded",
|
||||
}
|
||||
);
|
||||
// Close modal and revalidate after successful login
|
||||
onOpenChange(false);
|
||||
revalidator.revalidate();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [verifyOtpMutation.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enableVerifySection) setCanResend(false);
|
||||
}, [enableVerifySection]);
|
||||
|
||||
const handleTimerEnd = () => {
|
||||
setCanResend(true);
|
||||
};
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPhoneNumber("");
|
||||
setOtpValue("");
|
||||
setEnableVerifySection(false);
|
||||
setCanResend(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogOverlay />
|
||||
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)] max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
{enableVerifySection ? (
|
||||
// OTP Verification Section
|
||||
<>
|
||||
<div className="w-16 h-16 rounded-full bg-PRIMARY/10 flex items-center justify-center">
|
||||
<LogIn size={32} className="text-PRIMARY" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-center w-full">
|
||||
<h3 className="text-B16 font-bold">کد تایید شماره موبایل</h3>
|
||||
<p className="text-R14 text-GRAY">
|
||||
کد ارسال شده به {phoneNumber} را وارد کنید:
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-BLUE mx-auto"
|
||||
size="lg"
|
||||
onClick={() => setEnableVerifySection(false)}
|
||||
>
|
||||
ویرایش شماره موبایل
|
||||
<ArrowLeft className="mr-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={4}
|
||||
onComplete={(finalValue) => {
|
||||
const convertedValue = convertPersianToEnglish(finalValue);
|
||||
setOtpValue(convertedValue);
|
||||
}}
|
||||
value={otpValue}
|
||||
onChange={(value) => {
|
||||
const convertedValue = convertPersianToEnglish(value);
|
||||
setOtpValue(convertedValue);
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup dir="ltr" className="gap-4">
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<div className="gap-3 w-full flex flex-col items-center">
|
||||
<Button
|
||||
disabled={verifyOtpMutation.isPending || !(otpValue.length === 4)}
|
||||
className="w-full"
|
||||
size="xl"
|
||||
variant="dark"
|
||||
onClick={() => verifyOtpMutation.mutate()}
|
||||
>
|
||||
{verifyOtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin ml-2" />
|
||||
درحال تایید...
|
||||
</>
|
||||
) : (
|
||||
"تایید شماره موبایل"
|
||||
)}
|
||||
</Button>
|
||||
<div>
|
||||
{!canResend ? (
|
||||
<ResendTimer initialTime={120} onTimerEnd={handleTimerEnd} />
|
||||
) : (
|
||||
<Button
|
||||
variant="link"
|
||||
size="lg"
|
||||
className="text-BLUE font-bold p-0 h-auto gap-1"
|
||||
onClick={() => {
|
||||
setCanResend(false);
|
||||
setOtpValue("");
|
||||
sendOtpMutation.mutate();
|
||||
toast({
|
||||
description: "کد مجددا ارسال گردید",
|
||||
title: "ارسال کد",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="text-B12">ارسال مجدد کد</span>
|
||||
<RotateCcw className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Phone Number Entry Section
|
||||
<>
|
||||
<div className="w-16 h-16 rounded-full bg-PRIMARY/10 flex items-center justify-center">
|
||||
<LogIn size={32} className="text-PRIMARY" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h3 className="text-B16 font-bold">ورود به حساب کاربری</h3>
|
||||
<p className="text-R14 text-GRAY">{loginTitle}</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<AppInput
|
||||
inputTitle="شماره موبایل خود را وارد کنید:"
|
||||
inputIcon={Phone}
|
||||
inputValue={phoneNumber}
|
||||
inputPlaceholder="مثل ۰۹۳۹۱۲۳۴۵۶۷"
|
||||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const convertedValue = convertPersianToEnglish(value);
|
||||
if (/^\d*$/.test(convertedValue)) {
|
||||
setPhoneNumber(convertedValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<Button
|
||||
disabled={
|
||||
sendOtpMutation.isPending ||
|
||||
!(phoneNumber.length === 11 && phoneNumber.startsWith("0"))
|
||||
}
|
||||
variant="dark"
|
||||
size="xl"
|
||||
className="w-full"
|
||||
onClick={() => sendOtpMutation.mutate()}
|
||||
>
|
||||
{sendOtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin ml-2" />
|
||||
درحال ارسال کد
|
||||
</>
|
||||
) : (
|
||||
"تایید و ادامه"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
600
app/components/MondrianProductList.tsx
Normal file
@ -0,0 +1,600 @@
|
||||
import { Link, useNavigate } from "@remix-run/react";
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { ProductImageCarousel } from "./product/ProductImageCarousel";
|
||||
import { EmptyState, ErrorState } from "./UiProvider";
|
||||
import { Brain, Eye, EyeOff, MoveRight, Pencil, X } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogOverlay } from "./ui/dialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { useDeleteInstagramPost } from "~/requestHandler/use-instagram-hooks";
|
||||
import placeholderImage from "~/assets/icons/product.png";
|
||||
import placeholderImageDark from "~/assets/icons/product-dark.png";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
import discountRed from "~/assets/icons/product/discount-red.svg";
|
||||
|
||||
interface MondrianList {
|
||||
id?: string;
|
||||
title?: string;
|
||||
images?: string[];
|
||||
isDraft?: boolean;
|
||||
isActive?: boolean;
|
||||
discountPercent?: number;
|
||||
discountPrice?: number;
|
||||
basePrice?: number;
|
||||
}
|
||||
|
||||
// Utility function to detect media type
|
||||
export const isVideo = (url: string): boolean => {
|
||||
const videoExtensions = [".mp4", ".webm", ".ogg", ".mov"];
|
||||
return videoExtensions.some((ext) => url.toLowerCase().endsWith(ext));
|
||||
};
|
||||
|
||||
// ProductList component to display product images
|
||||
const MondrianProductList = ({
|
||||
products,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
emptyState,
|
||||
isCommonList = false,
|
||||
isDraft = false,
|
||||
storeId,
|
||||
storePage,
|
||||
}: {
|
||||
products: MondrianList[];
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean | undefined;
|
||||
isFetchingNextPage: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
emptyState: {
|
||||
image?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
isCommonList?: boolean;
|
||||
isDraft?: boolean;
|
||||
storeId?: string;
|
||||
storePage?: boolean;
|
||||
}) => {
|
||||
// Handle scroll for infinite loading with throttling
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
const handleScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
|
||||
if (
|
||||
scrollPosition >= documentHeight - 300 &&
|
||||
!isFetchingNextPage &&
|
||||
hasNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
// Memoize expensive filtering operations
|
||||
const { oddProducts, evenProducts } = useMemo(() => {
|
||||
const odd = products.filter((_, index) => index % 2 === 0);
|
||||
const even = products.filter((_, index) => index % 2 !== 0);
|
||||
return { oddProducts: odd, evenProducts: even };
|
||||
}, [products]);
|
||||
|
||||
const oddHeight = useMemo(() => [220, 260, 320, 200, 280], []);
|
||||
const evenHeight = useMemo(() => [280, 200, 260, 220, 320], []);
|
||||
return (
|
||||
<div className="px-4 flex flex-col w-full">
|
||||
{!isCommonList ? (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<MondrianSkeleton />
|
||||
) : isError ? (
|
||||
<ErrorState onRetry={refetch} />
|
||||
) : oddProducts.length === 0 && evenProducts.length === 0 ? (
|
||||
<EmptyState
|
||||
type="mondrian"
|
||||
mondrianImage={emptyState.image}
|
||||
mondrianTitle={emptyState.title}
|
||||
mondrianDescription={emptyState.description}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-1 w-full">
|
||||
{/* Column 1 (Odd products - index 0, 2, ...) */}
|
||||
<div className="flex flex-col gap-1 w-[calc(50%)]">
|
||||
{oddProducts.map((product, index: number) => {
|
||||
const height = oddHeight[index % 5];
|
||||
return (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
height={height}
|
||||
storeId={storeId}
|
||||
storePage={storePage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Column 2 (Even products - index 1, 3, ...) */}
|
||||
<div className="flex flex-col gap-1 w-[calc(50%)]">
|
||||
{evenProducts.map((product, index: number) => {
|
||||
const height = evenHeight[index % 5];
|
||||
return (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
height={height}
|
||||
storeId={storeId}
|
||||
storePage={storePage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{hasNextPage && <MondrianSkeleton isMini={true} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<CommonSkeleton />
|
||||
) : isError ? (
|
||||
<ErrorState onRetry={refetch} />
|
||||
) : oddProducts.length === 0 && evenProducts.length === 0 ? (
|
||||
<EmptyState
|
||||
type="mondrian"
|
||||
mondrianImage={emptyState.image}
|
||||
mondrianTitle={emptyState.title}
|
||||
mondrianDescription={emptyState.description}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="gap-1 w-full grid grid-cols-3">
|
||||
{products.map((product, index: number) => {
|
||||
return (
|
||||
<ProductCard
|
||||
isDraft={isDraft}
|
||||
key={index}
|
||||
product={product}
|
||||
storePage={storePage}
|
||||
height={136}
|
||||
storeId={storeId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasNextPage && <CommonSkeleton />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProductCard = ({
|
||||
product,
|
||||
height,
|
||||
isDraft,
|
||||
storeId,
|
||||
storePage,
|
||||
}: {
|
||||
product: MondrianList;
|
||||
height: number;
|
||||
isDraft?: boolean;
|
||||
storeId?: string;
|
||||
storePage?: boolean;
|
||||
}) => {
|
||||
const [isPublishModalOpen, setIsPublishModalOpen] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { theme } = useRootData();
|
||||
|
||||
const handleVideoClick = () => {
|
||||
if (!videoRef.current) return;
|
||||
if (videoRef.current.paused) {
|
||||
videoRef.current.play();
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDraft ? (
|
||||
<>
|
||||
<div
|
||||
className="w-full relative aspect-square overflow-hidden"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{product.images?.[0] &&
|
||||
(isVideo(product.images[0]) ? (
|
||||
<div className="w-full h-full relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.images[0]}
|
||||
className={"w-full object-cover"}
|
||||
style={{
|
||||
height: height ? `${height}px` : "100%",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
onClick={handleVideoClick}
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const img = document.createElement("img");
|
||||
img.src =
|
||||
theme === "dark"
|
||||
? placeholderImageDark
|
||||
: placeholderImage;
|
||||
img.alt = product.title || "Product image";
|
||||
img.className = "w-full h-full object-contain";
|
||||
img.style.borderRadius = "10px";
|
||||
img.style.backgroundColor =
|
||||
theme === "dark" ? "#121212" : "#f2f0f0";
|
||||
parent.replaceChild(img, target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={
|
||||
product.images[0] ||
|
||||
(theme === "dark" ? placeholderImageDark : placeholderImage)
|
||||
}
|
||||
alt={product.title || "Product image"}
|
||||
className={`w-full h-full ${product.images?.[0] ? "object-cover" : "object-contain"}`}
|
||||
style={{
|
||||
borderRadius: "10px",
|
||||
backgroundColor: !product.images?.[0]
|
||||
? theme === "dark"
|
||||
? "#121212"
|
||||
: "#f2f0f0"
|
||||
: undefined,
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src =
|
||||
theme === "dark"
|
||||
? placeholderImageDark
|
||||
: placeholderImage;
|
||||
e.currentTarget.style.objectFit = "contain";
|
||||
e.currentTarget.style.backgroundColor =
|
||||
theme === "dark" ? "#121212" : "#f2f0f0";
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Draft mode overlay - clickable container */}
|
||||
{product.isDraft && (
|
||||
<div
|
||||
className="absolute inset-0 cursor-pointer"
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsPublishModalOpen(true);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
setIsPublishModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{/* Top-right corner for EyeOff icon */}
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<EyeOff className="text-WHITE" size={24} />
|
||||
</div>
|
||||
{/* Semi-transparent overlay for the whole area */}
|
||||
<div className="absolute inset-0 bg-BLACK/50 rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
{/* Discount badge */}
|
||||
{product.discountPercent && product.discountPercent !== 0 && (
|
||||
<DiscountBadge discountPercent={product.discountPercent} />
|
||||
)}
|
||||
</div>
|
||||
<PublishModal
|
||||
open={isPublishModalOpen}
|
||||
onOpenChange={setIsPublishModalOpen}
|
||||
storeId={storeId}
|
||||
postId={product.id}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className="relative rounded-lg overflow-hidden"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
<Link
|
||||
to={`${storePage ? `/store/${storeId}/product/${product.id}` : `/product/${product.id}`}`}
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<ProductImageCarousel
|
||||
images={product.images || []}
|
||||
hideIndex={true}
|
||||
height={height}
|
||||
/>
|
||||
</Link>
|
||||
{/* Discount badge */}
|
||||
{product.discountPercent && product.discountPercent !== 0 && (
|
||||
<DiscountBadge discountPercent={product.discountPercent} />
|
||||
)}
|
||||
{/* Inactive product overlay */}
|
||||
{product.isActive === false && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Top-right corner for EyeOff icon */}
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<EyeOff className="text-WHITE" size={24} />
|
||||
</div>
|
||||
{/* Semi-transparent overlay for the whole area */}
|
||||
<div className="absolute inset-0 bg-BLACK/50 rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay info - Airbnb style */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-2 pt-6 pointer-events-none">
|
||||
<div className="flex w-full justify-between gap-1">
|
||||
{/* Title */}
|
||||
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">
|
||||
{product.title}
|
||||
</h3>
|
||||
{/* Price */}
|
||||
<div className="flex flex-col items-center gap-0 relative">
|
||||
{product.discountPrice && (
|
||||
<p className="text-white text-xs font-bold">
|
||||
{`${product.discountPrice.toLocaleString()}`}
|
||||
<span className="text-white text-[8px] mr-[2px]">
|
||||
تومان
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{product.basePrice && product.discountPercent ? (
|
||||
<p className="text-white/80 text-[10px] line-through absolute bottom-4 left-0">
|
||||
{product.basePrice.toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const DiscountBadge = ({ discountPercent }: { discountPercent?: number }) => {
|
||||
return (
|
||||
<div className="absolute items-center gap-1 flex top-1 right-1 z-10 bg-[#FDFDFD]/75 text-[#FF3B30] py-[2px] px-2 rounded-[15px]">
|
||||
<p
|
||||
className="text-B14 font-bold leading-[20px]"
|
||||
dir="ltr"
|
||||
style={{ fontFamily: "Arial, sans-serif" }}
|
||||
>
|
||||
{discountPercent}
|
||||
</p>
|
||||
<img src={discountRed} alt="discount-red" className="w-5 h-5" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const PublishModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
storeId,
|
||||
postId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
storeId?: string;
|
||||
postId?: string;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||
const deletePostMutation = useDeleteInstagramPost();
|
||||
|
||||
const handleManualAddClick = (isAI: boolean) => {
|
||||
// Navigate to manual add page
|
||||
if (storeId && postId) {
|
||||
navigate(`/store/add/manual/${storeId}?id=${postId}&ai=${isAI}`);
|
||||
} else {
|
||||
// Fallback to create store if no storeId available
|
||||
navigate("/store/create");
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
onOpenChange(false); // Close the publish modal first
|
||||
setIsDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (postId) {
|
||||
try {
|
||||
await deletePostMutation.mutateAsync(postId);
|
||||
onOpenChange(false);
|
||||
setIsDeleteConfirmOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete post:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogOverlay className="bg-BLACK/50" />
|
||||
<DialogContent
|
||||
className={`p-0 bg-WHITE overflow-y-auto rounded-[15px] w-[calc(100%-40px)]`}
|
||||
>
|
||||
{/* Close button */}
|
||||
<X
|
||||
className="absolute top-4 right-4 z-10"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6 items-center w-full p-6 pt-12">
|
||||
{/* Header text */}
|
||||
<div className="text-center px-4 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Eye size={36} />
|
||||
<MoveRight className="text-B14 font-bold" size={32} />
|
||||
<EyeOff size={36} />
|
||||
</div>
|
||||
<p className="text-B16 font-bold mt-2">نمایش پست</p>
|
||||
<p className="text-R14 mb-2">
|
||||
با تکمیل اطلاعات محصولات معلق، آنها را برای مخاطبین قابل مشاهده
|
||||
کنید
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{/* تکمیل اطلاعات با AI button */}
|
||||
<Button
|
||||
onClick={() => handleManualAddClick(true)}
|
||||
variant="dark"
|
||||
size="xl"
|
||||
className="w-full"
|
||||
>
|
||||
<Brain size={20} />
|
||||
<span className="text-R14 font-medium">تکمیل اطلاعات با AI</span>
|
||||
</Button>
|
||||
|
||||
{/* تکمیل اطلاعات button */}
|
||||
<Button
|
||||
onClick={() => handleManualAddClick(false)}
|
||||
variant="outline"
|
||||
size="xl"
|
||||
className="w-full"
|
||||
>
|
||||
<Pencil size={20} />
|
||||
<span className="text-R14 font-medium">تکمیل اطلاعات</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bottom text */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="w-full text-R12 text-red-500 text-center"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deletePostMutation.isPending}
|
||||
>
|
||||
{deletePostMutation.isPending ? "در حال حذف..." : "حذف پست"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Dialog open={isDeleteConfirmOpen} onOpenChange={setIsDeleteConfirmOpen}>
|
||||
<DialogOverlay className="bg-BLACK/50" />
|
||||
<DialogContent className="p-0 bg-WHITE overflow-y-auto rounded-[15px] w-[calc(100%-40px)]">
|
||||
<div className="flex flex-col gap-6 items-center w-full p-6">
|
||||
<div className="text-center flex flex-col gap-4">
|
||||
<div className="w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center">
|
||||
<X
|
||||
className="text-red-500"
|
||||
size={32}
|
||||
onClick={() => setIsDeleteConfirmOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-B16 font-bold">حذف پست</h3>
|
||||
<p className="text-R14 text-BLACK2">
|
||||
آیا از حذف این پست اطمینان دارید؟ این عمل قابل بازگشت نیست.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
variant="destructive"
|
||||
size="xl"
|
||||
className="w-full"
|
||||
disabled={deletePostMutation.isPending}
|
||||
>
|
||||
{deletePostMutation.isPending ? "در حال حذف..." : "بله، حذف کن"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsDeleteConfirmOpen(false)}
|
||||
variant="outline"
|
||||
size="xl"
|
||||
className="w-full"
|
||||
disabled={deletePostMutation.isPending}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export const CommonSkeleton = () => {
|
||||
return (
|
||||
<div className="gap-1 w-full grid grid-cols-3">
|
||||
{Array(9)
|
||||
.fill(1)
|
||||
.map((_, index: number) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className={`w-full `}
|
||||
style={{ height: `136px` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const MondrianSkeleton = ({ isMini }: { isMini?: boolean }) => {
|
||||
const oddHeight = isMini ? [200, 240] : [200, 240, 300, 180, 260];
|
||||
const evenHeight = isMini ? [260, 180] : [260, 180, 240, 200, 300];
|
||||
return (
|
||||
<div className="flex gap-1 w-full mt-4">
|
||||
{/* Column 1 (Odd products - index 0, 2, ...) */}
|
||||
<div className="flex flex-col gap-1 w-1/2">
|
||||
{oddHeight.map((height, index: number) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className={`w-full `}
|
||||
style={{ height: `${height}px` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Column 2 (Even products - index 1, 3, ...) */}
|
||||
<div className="flex flex-col gap-1 w-1/2">
|
||||
{evenHeight.map((height, index: number) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className={`w-full `}
|
||||
style={{ height: `${height}px` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default MondrianProductList;
|
||||
355
app/components/MyLayout.tsx
Normal file
@ -0,0 +1,355 @@
|
||||
import React, { memo, useMemo, useEffect, useState } from "react";
|
||||
import buyOutline from "app/assets/icons/navbar/buy-outline.svg";
|
||||
import homeOutline from "app/assets/icons/navbar/home-outline.svg";
|
||||
import profileOutline from "app/assets/icons/navbar/profile-outline.svg";
|
||||
import searchOutline from "app/assets/icons/navbar/search-outline.svg";
|
||||
import buyFilled from "app/assets/icons/navbar/buy-filled.svg";
|
||||
import homeFilled from "app/assets/icons/navbar/home-filled.svg";
|
||||
import profileFilled from "app/assets/icons/navbar/profile-filled.svg";
|
||||
import searchFilled from "app/assets/icons/navbar/search-filled.svg";
|
||||
import { Link, useLocation, useParams } from "@remix-run/react";
|
||||
import { detectPage } from "~/utils/helpers";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
import { useCartCount } from "~/requestHandler/use-cart-hooks";
|
||||
import { useSellerOrderCount } from "~/requestHandler/use-order-hooks";
|
||||
import settingIconFilled from "~/assets/icons/navbar/setting-filled.svg";
|
||||
import settingIconOutline from "~/assets/icons/navbar/setting-outline.svg";
|
||||
import documentFilled from "~/assets/icons/navbar/document-filled.svg";
|
||||
import documentOutline from "~/assets/icons/navbar/document-outline.svg";
|
||||
import productsFilled from "~/assets/icons/navbar/products-filled.svg";
|
||||
import productsOutline from "~/assets/icons/navbar/products-outline.svg";
|
||||
|
||||
/**
|
||||
* Primary application layout component
|
||||
* Renders either mobile or desktop header based on screen size
|
||||
*/
|
||||
const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 lg:bg-gray-200">
|
||||
<div className="max-w-md mx-auto bg-WHITE min-h-screen shadow-lg lg:shadow-xl">
|
||||
<div className="pb-[calc(50px+env(safe-area-inset-bottom))]">
|
||||
{children}
|
||||
{location.pathname.split("threads")[1] ? (
|
||||
<></>
|
||||
) : (
|
||||
<MobileBottomNavigation />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MyLayout.displayName = "Layout";
|
||||
|
||||
const MobileBottomNavigation = () => {
|
||||
const { theme } = useRootData();
|
||||
const location = useLocation();
|
||||
const currentPage = useMemo(
|
||||
() => detectPage(location?.pathname),
|
||||
[location?.pathname]
|
||||
);
|
||||
const cartCount = useCartCount();
|
||||
const { user } = useRootData();
|
||||
const [isStoreMode, setIsStoreMode] = useState(false);
|
||||
const sellerOrderCount = useSellerOrderCount(!!isStoreMode);
|
||||
const [storeId, setStoreId] = useState<string | null>(null);
|
||||
|
||||
// Initialize state from sessionStorage on mount - only once
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const savedMode = sessionStorage.getItem("navigationMode");
|
||||
const savedStoreId = sessionStorage.getItem("currentStoreId");
|
||||
|
||||
if (savedMode === "store" && savedStoreId) {
|
||||
setIsStoreMode(true);
|
||||
setStoreId(savedStoreId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const params = useParams();
|
||||
// Function to determine page type
|
||||
const getPageType = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
const mode = params.mode;
|
||||
// Store management pages
|
||||
const isStoreManagementPage =
|
||||
path.match(/^\/store\/[^/]+$/) || // Main store page: /store/{id}
|
||||
path.match(/^\/store\/[^/]+\/setting$/) || // Store settings: /store/{id}/setting
|
||||
path.match(/^\/store\/[^/]+\/orders/) || // Store orders: /store/{id}/orders
|
||||
path.match(/^\/store\/[^/]+\/shipping-method$/) || // Shipping method: /store/{id}/shipping-method
|
||||
path.match(/^\/store\/[^/]+\/financial-dashboard$/) || // Financial dashboard: /store/{id}/financial-dashboard
|
||||
path.match(/^\/store\/[^/]+\/financial-dashboard\/cash-funds$/) || // Financial dashboard: /store/{id}/financial-dashboard
|
||||
path.match(/^\/store\/[^/]+\/financial-dashboard\/transactions$/) || // Financial dashboard: /store/{id}/financial-dashboard
|
||||
path.match(/^\/store\/[^/]+\/financial-dashboard\/reports$/) || // Financial dashboard: /store/{id}/financial-dashboard
|
||||
path.match(/^\/store\/[^/]+\/products$/) || // Products list: /store/{id}/products
|
||||
path.match(/^\/store\/[^/]+\/product\/[^/]+$/) || // Product detail: /store/{id}/products/{id}
|
||||
path.match(/^\/store\/[^/]+\/edit$/) || // Edit store: /store/{id}/edit
|
||||
path.match(/^\/store\/add\/manual\/[^/]+$/) || // Add product: /store/add/manual/{id}
|
||||
path.match(/^\/store\/create$/) || // Create store: /store/create
|
||||
path.match(/^\/store\/authenticate\/instagram\/[^/]+$/) || // Instagram auth: /store/authenticate/instagram/{id}
|
||||
path.match(/^\/store\/[^/]+\/threads$/) || // Threads: /store/{id}/threads
|
||||
path.match(/^\/store\/[^/]+\/threads\/[^/]+$/); // Threads: /store/{id}/threads/{threadId}
|
||||
|
||||
// Shared pages (should preserve current mode)
|
||||
const isSharedPage =
|
||||
path.match(/^\/explore$/) || // Explore page
|
||||
path.match(/^\/search\//) || // Search pages
|
||||
path.match(/^\/product\//) || // Product detail pages
|
||||
path.match(/^\/seller\/[^/]+$/); // Seller view pages (not management)
|
||||
|
||||
// Regular user pages
|
||||
const isRegularPage =
|
||||
path.match(/^\/profile/) || // Profile pages
|
||||
path.match(/^\/cart/) || // Cart pages
|
||||
path.match(/^\/login$/) || // Login page
|
||||
path.match(/^\/logout$/) || // Logout page
|
||||
path === "/"; // Home page
|
||||
|
||||
if (isStoreManagementPage || mode === "store") return "store";
|
||||
if (isSharedPage) return "shared";
|
||||
if (isRegularPage) return "regular";
|
||||
return "regular"; // Default
|
||||
}, [location.pathname, params.mode]);
|
||||
|
||||
// Update store mode based on current page
|
||||
useEffect(() => {
|
||||
const pageType = getPageType;
|
||||
|
||||
const extractStoreId = (path: string): string | null => {
|
||||
let match;
|
||||
|
||||
// Order matters here: more specific paths first.
|
||||
// Case: /store/add/manual/{id}
|
||||
match = path.match(/^\/store\/add\/manual\/([^/]+)/);
|
||||
if (match) return match[1];
|
||||
|
||||
// Case: /store/authenticate/instagram/{id}
|
||||
match = path.match(/^\/store\/authenticate\/instagram\/([^/]+)/);
|
||||
if (match) return match[1];
|
||||
|
||||
// Case: /store/{id}/* (e.g., /store/my-store/settings)
|
||||
match = path.match(/^\/store\/([^/]+)\//);
|
||||
if (match) return match[1];
|
||||
|
||||
// Case: /store/{id} (e.g., /store/my-store)
|
||||
match = path.match(/^\/store\/([^/]+)$/);
|
||||
if (match && match[1] !== "create") {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (pageType === "store") {
|
||||
// We're in store management - set store mode
|
||||
const currentStoreId = extractStoreId(location.pathname);
|
||||
|
||||
if (currentStoreId) {
|
||||
setIsStoreMode((prev) => {
|
||||
if (!prev && typeof window !== "undefined") {
|
||||
sessionStorage.setItem("navigationMode", "store");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setStoreId((prev) => {
|
||||
if (prev !== currentStoreId && typeof window !== "undefined") {
|
||||
sessionStorage.setItem("currentStoreId", currentStoreId);
|
||||
}
|
||||
return currentStoreId;
|
||||
});
|
||||
} else {
|
||||
// It's a store page without an ID, like /store/create.
|
||||
setIsStoreMode((prev) => {
|
||||
if (prev && typeof window !== "undefined") {
|
||||
sessionStorage.setItem("navigationMode", "regular");
|
||||
sessionStorage.removeItem("currentStoreId");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
setStoreId(null);
|
||||
}
|
||||
} else if (pageType === "regular") {
|
||||
// We're in regular user pages - set regular mode
|
||||
setIsStoreMode((prev) => {
|
||||
if (prev && typeof window !== "undefined") {
|
||||
sessionStorage.setItem("navigationMode", "regular");
|
||||
sessionStorage.removeItem("currentStoreId");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
setStoreId(null);
|
||||
|
||||
// Clear store mode if user logs out
|
||||
if (location.pathname === "/logout" && typeof window !== "undefined") {
|
||||
sessionStorage.removeItem("navigationMode");
|
||||
sessionStorage.removeItem("currentStoreId");
|
||||
}
|
||||
} else if (pageType === "shared") {
|
||||
// We're in shared pages - preserve current mode (don't read from storage on every navigation)
|
||||
// State is already set from initial mount
|
||||
}
|
||||
}, [location.pathname, getPageType]);
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
if (isStoreMode) {
|
||||
// Store mode navigation
|
||||
return [
|
||||
{
|
||||
link: `/store/${storeId}/setting`,
|
||||
icon:
|
||||
currentPage === "setting"
|
||||
? theme === "dark"
|
||||
? settingIconOutline
|
||||
: settingIconFilled
|
||||
: theme === "dark"
|
||||
? settingIconFilled
|
||||
: settingIconOutline,
|
||||
},
|
||||
{
|
||||
link: `/store/${storeId}/orders`,
|
||||
icon:
|
||||
currentPage === "orders"
|
||||
? theme === "dark"
|
||||
? documentOutline
|
||||
: documentFilled
|
||||
: theme === "dark"
|
||||
? documentFilled
|
||||
: documentOutline,
|
||||
badge:
|
||||
sellerOrderCount > 0 && user?.access_token
|
||||
? sellerOrderCount
|
||||
: null,
|
||||
},
|
||||
{
|
||||
link: `/store/${storeId}/products`,
|
||||
icon:
|
||||
currentPage === "products"
|
||||
? theme === "dark"
|
||||
? productsOutline
|
||||
: productsFilled
|
||||
: theme === "dark"
|
||||
? productsFilled
|
||||
: productsOutline,
|
||||
},
|
||||
{
|
||||
link: `/store/${storeId}`,
|
||||
icon:
|
||||
currentPage === "store"
|
||||
? theme === "dark"
|
||||
? homeOutline
|
||||
: homeFilled
|
||||
: theme === "dark"
|
||||
? homeFilled
|
||||
: homeOutline,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Normal mode navigation
|
||||
return [
|
||||
{
|
||||
link: "/profile",
|
||||
icon:
|
||||
currentPage === "profile"
|
||||
? theme === "dark"
|
||||
? profileOutline
|
||||
: profileFilled
|
||||
: theme === "dark"
|
||||
? profileFilled
|
||||
: profileOutline,
|
||||
},
|
||||
{
|
||||
link: "/cart",
|
||||
icon:
|
||||
currentPage === "cart"
|
||||
? theme === "dark"
|
||||
? buyOutline
|
||||
: buyFilled
|
||||
: theme === "dark"
|
||||
? buyFilled
|
||||
: buyOutline,
|
||||
badge: cartCount > 0 && user?.access_token ? cartCount : null,
|
||||
},
|
||||
{
|
||||
link: "/explore",
|
||||
icon:
|
||||
currentPage === "explore" || currentPage === "search"
|
||||
? theme === "dark"
|
||||
? searchOutline
|
||||
: searchFilled
|
||||
: theme === "dark"
|
||||
? searchFilled
|
||||
: searchOutline,
|
||||
},
|
||||
{
|
||||
link: "/",
|
||||
icon:
|
||||
currentPage === "home"
|
||||
? theme === "dark"
|
||||
? homeOutline
|
||||
: homeFilled
|
||||
: theme === "dark"
|
||||
? homeFilled
|
||||
: homeOutline,
|
||||
},
|
||||
];
|
||||
}
|
||||
}, [
|
||||
currentPage,
|
||||
cartCount,
|
||||
sellerOrderCount,
|
||||
user?.access_token,
|
||||
isStoreMode,
|
||||
storeId,
|
||||
theme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-[50px] z-20 bg-WHITE flex gap-[48px] px-[20px] fixed bottom-0 border-t w-full max-w-md mx-auto items-center justify-center pb-[env(safe-area-inset-bottom)] lg:left-1/2 lg:transform lg:-translate-x-1/2">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.link}
|
||||
to={item.link}
|
||||
prefetch="intent"
|
||||
className="relative"
|
||||
>
|
||||
{typeof item.icon === "string" ? (
|
||||
<img
|
||||
src={item.icon}
|
||||
className="h-6 w-6"
|
||||
alt={item.link}
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
item.icon
|
||||
)}
|
||||
{item.badge && (
|
||||
<span className="absolute -top-1 -right-1 text-R12 ">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LanguageToggle = memo(
|
||||
({
|
||||
onToggle,
|
||||
currentLanguage,
|
||||
}: {
|
||||
onToggle: () => Promise<void>;
|
||||
currentLanguage: string;
|
||||
}) => (
|
||||
<button onClick={onToggle}>{currentLanguage === "fa" ? "En" : "فا"}</button>
|
||||
)
|
||||
);
|
||||
LanguageToggle.displayName = "LanguageToggle";
|
||||
|
||||
export default MyLayout;
|
||||
205
app/components/PersianDatePicker.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import { useState } from "react";
|
||||
import { Calendar, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "./ui/drawer";
|
||||
import jMoment from "moment-jalaali";
|
||||
|
||||
interface PersianDatePickerProps {
|
||||
value?: string; // Format: YYYY/MM/DD
|
||||
onChange: (date: string) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const PersianDatePicker = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "انتخاب تاریخ",
|
||||
label,
|
||||
}: PersianDatePickerProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentDate, setCurrentDate] = useState(jMoment());
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(
|
||||
value || null
|
||||
);
|
||||
|
||||
const persianMonths = [
|
||||
"فروردین",
|
||||
"اردیبهشت",
|
||||
"خرداد",
|
||||
"تیر",
|
||||
"مرداد",
|
||||
"شهریور",
|
||||
"مهر",
|
||||
"آبان",
|
||||
"آذر",
|
||||
"دی",
|
||||
"بهمن",
|
||||
"اسفند",
|
||||
];
|
||||
|
||||
const persianDaysOfWeek = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||
|
||||
// Generate calendar days
|
||||
const generateCalendarDays = () => {
|
||||
const startOfMonth = currentDate.clone().startOf("jMonth");
|
||||
const endOfMonth = currentDate.clone().endOf("jMonth");
|
||||
const startOfWeek = startOfMonth.clone().startOf("week");
|
||||
const endOfWeek = endOfMonth.clone().endOf("week");
|
||||
|
||||
const days = [];
|
||||
const currentDay = startOfWeek.clone();
|
||||
|
||||
while (currentDay.isSameOrBefore(endOfWeek)) {
|
||||
days.push(currentDay.clone());
|
||||
currentDay.add(1, "day");
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const handleDateSelect = (date: typeof jMoment) => {
|
||||
const dateString = date.format("jYYYY/jMM/jDD");
|
||||
setSelectedDate(dateString);
|
||||
onChange(dateString);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const goToPreviousMonth = () => {
|
||||
setCurrentDate(currentDate.clone().subtract(1, "jMonth"));
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
setCurrentDate(currentDate.clone().add(1, "jMonth"));
|
||||
};
|
||||
|
||||
const calendarDays = generateCalendarDays();
|
||||
|
||||
return (
|
||||
<>
|
||||
{label && <p className="text-R14 font-bold mb-2">{label}</p>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xl"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`${!selectedDate && "text-GRAY"} w-full bg-WHITE relative justify-start text-right h-12 px-4`}
|
||||
>
|
||||
<div className="flex px-2 items-center justify-between absolute left-0 bg-BLACK h-full rounded-l-md">
|
||||
<Calendar size={24} className="text-WHITE" />
|
||||
</div>
|
||||
{selectedDate || placeholder}
|
||||
</Button>
|
||||
|
||||
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DrawerContent className="w-full pb-6">
|
||||
<DrawerHeader className="flex flex-col py-3">
|
||||
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
|
||||
<X
|
||||
className="absolute right-4 cursor-pointer"
|
||||
size={24}
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<DrawerTitle className="text-B16 font-bold">
|
||||
انتخاب تاریخ
|
||||
</DrawerTitle>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="px-4">
|
||||
{/* Month/Year Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goToPreviousMonth}
|
||||
className="p-2"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold">
|
||||
{persianMonths[currentDate.jMonth()]} {currentDate.jYear()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goToNextMonth}
|
||||
className="p-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Days of Week Header */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{persianDaysOfWeek.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-sm font-bold text-GRAY py-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Days */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendarDays.map((day, index) => {
|
||||
const isCurrentMonth = day.jMonth() === currentDate.jMonth();
|
||||
const isSelected = selectedDate === day.format("jYYYY/jMM/jDD");
|
||||
const isToday = day.isSame(jMoment(), "day");
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={isSelected ? "dark" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => handleDateSelect(day)}
|
||||
className={`
|
||||
h-10 w-full p-0 text-sm
|
||||
${!isCurrentMonth ? "text-GRAY2" : ""}
|
||||
${isToday && !isSelected ? "bg-blue-50 text-blue-600" : ""}
|
||||
`}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{day.jDate()}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex-1"
|
||||
size="xl"
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const today = jMoment().format("jYYYY/jMM/jDD");
|
||||
setSelectedDate(today);
|
||||
onChange(today);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
size="xl"
|
||||
>
|
||||
امروز
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersianDatePicker;
|
||||
38
app/components/ProfilePagesHeader.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { memo, useCallback } from "react";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { safeGoBack } from "~/utils/helpers";
|
||||
|
||||
interface ProfilePagesHeaderProps {
|
||||
title: string;
|
||||
hideBackButton?: boolean;
|
||||
rightContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ProfilePagesHeader = memo(function ProfilePagesHeader({
|
||||
title,
|
||||
hideBackButton,
|
||||
rightContent,
|
||||
}: ProfilePagesHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const handleBack = useCallback(() => {
|
||||
safeGoBack(navigate);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
|
||||
{!hideBackButton && (
|
||||
<ArrowRight
|
||||
onClick={handleBack}
|
||||
className="absolute top-5 right-5 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
<p className="text-B16 font-bold">{title}</p>
|
||||
{rightContent && (
|
||||
<div className="absolute top-4 left-4">{rightContent}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfilePagesHeader;
|
||||
69
app/components/SearchTopBar.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import AppInput from "./AppInput";
|
||||
import { Search as SearchIcon } from "lucide-react";
|
||||
import FilterDrawer from "./FilterDrawer";
|
||||
|
||||
interface SearchTopBarProps {
|
||||
searchValue: string;
|
||||
setSearchValue: (value: string) => void;
|
||||
placeholder?: string;
|
||||
minimumPrice?: number;
|
||||
maximumPrice?: number;
|
||||
onSearch?: () => void;
|
||||
}
|
||||
|
||||
const SearchTopBar: React.FC<SearchTopBarProps> = ({
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
placeholder = "جستجو...",
|
||||
minimumPrice = 0,
|
||||
maximumPrice = 10000000,
|
||||
onSearch,
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isChangedSearchBar = searchValue !== (searchParams.get("q") || "");
|
||||
|
||||
const handleSearch = () => {
|
||||
if (onSearch) {
|
||||
onSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isChangedSearchBar) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (searchValue.trim()) {
|
||||
params.set("q", searchValue);
|
||||
} else {
|
||||
params.delete("q");
|
||||
}
|
||||
setSearchParams(params, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 px-4 w-full sticky top-0 z-30 bg-WHITE">
|
||||
<div className="flex items-center justify-center gap-2 rounded-[10px] w-full relative">
|
||||
<AppInput
|
||||
inputIcon={SearchIcon}
|
||||
inputDir="rtl"
|
||||
inputTitle={""}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
className="bg-WHITE2 focus:border-blue-500"
|
||||
inputValue={searchValue}
|
||||
inputPlaceholder={placeholder}
|
||||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FilterDrawer minimumPrice={minimumPrice} maximumPrice={maximumPrice} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchTopBar;
|
||||
76
app/components/SellerLogo.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { cn } from "../lib/utils";
|
||||
import placeholderImage from "../assets/icons/sellerPlaceholder.svg";
|
||||
import placeholderImageDark from "../assets/icons/sellerPlaceholder-dark.svg";
|
||||
import { useState, memo, useMemo, useCallback } from "react";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
|
||||
interface SellerLogoProps {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-8 h-8",
|
||||
md: "w-12 h-12",
|
||||
lg: "w-16 h-16",
|
||||
xl: "w-20 h-20",
|
||||
} as const;
|
||||
|
||||
const imageSizeClasses = {
|
||||
sm: "w-6 h-6",
|
||||
md: "w-8 h-8",
|
||||
lg: "w-12 h-12",
|
||||
xl: "w-16 h-16",
|
||||
} as const;
|
||||
|
||||
const SellerLogo = memo(function SellerLogo({
|
||||
src,
|
||||
alt = "Seller Logo",
|
||||
className,
|
||||
size = "md",
|
||||
}: SellerLogoProps) {
|
||||
const { theme } = useRootData();
|
||||
const [errorInDownload, setErrorInDownload] = useState(false);
|
||||
|
||||
const placeholder = useMemo(
|
||||
() => (theme === "dark" ? placeholderImageDark : placeholderImage),
|
||||
[theme]
|
||||
);
|
||||
|
||||
const imageSrc = useMemo(
|
||||
() => src || placeholder,
|
||||
[src, placeholder]
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
e.currentTarget.src = placeholder;
|
||||
setErrorInDownload(true);
|
||||
},
|
||||
[placeholder]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`rounded-full items-center justify-center flex ${!src || errorInDownload ? "border-GRAY2 border" : ""} object-cover shrink-0`,
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className={cn(
|
||||
"rounded-full object-cover shrink-0",
|
||||
src && !errorInDownload ? sizeClasses[size] : imageSizeClasses[size],
|
||||
className
|
||||
)}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default SellerLogo;
|
||||
101
app/components/SellerTilesGrid.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { ChevronLeft, Store } from "lucide-react";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { ErrorState } from "./UiProvider";
|
||||
|
||||
interface SellerTile {
|
||||
id?: string;
|
||||
sellerId?: string;
|
||||
sellerUsername?: string;
|
||||
sellerName?: string;
|
||||
imageUrl?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
interface SellerTilesGridProps {
|
||||
sellers: SellerTile[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
showViewAllButton?: boolean;
|
||||
}
|
||||
|
||||
const SellerTilesGrid = ({
|
||||
sellers,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
showViewAllButton = true,
|
||||
}: SellerTilesGridProps) => {
|
||||
if (isLoading) {
|
||||
return <SellerTilesSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="px-4">
|
||||
<ErrorState onRetry={refetch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sellers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-4 px-4">
|
||||
{/* Grid Container - 2x2 layout */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sellers.slice(0, 4).map((seller, index) => (
|
||||
<Link
|
||||
key={seller.id || index}
|
||||
to={`/seller/${seller.sellerUsername}`}
|
||||
className="relative aspect-square overflow-hidden rounded-lg group"
|
||||
>
|
||||
{/* Background Image */}
|
||||
{seller.imageUrl ? (
|
||||
<img
|
||||
src={seller.imageUrl}
|
||||
alt={seller.sellerName || "فروشگاه"}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-gray-200 to-gray-300 flex items-center justify-center">
|
||||
<Store size={48} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View All Button */}
|
||||
{showViewAllButton && (
|
||||
<Link
|
||||
to="/sellers"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 border border-black rounded-none hover:bg-black hover:text-white transition-colors duration-300"
|
||||
>
|
||||
<span className="text-B14 font-bold">مشاهده همه فروشگاهها</span>
|
||||
<ChevronLeft size={18} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SellerTilesSkeleton = () => {
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-4 px-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Array(4)
|
||||
.fill(1)
|
||||
.map((_, index) => (
|
||||
<Skeleton key={index} className="aspect-square rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="w-full h-12 rounded-none" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SellerTilesGrid;
|
||||
99
app/components/SunMoonToggle.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { saveSession } from "../cookie";
|
||||
import { ThemeTypes } from "../types/theme";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export const SunMoonToggle = ({
|
||||
theme: initialTheme,
|
||||
}: {
|
||||
theme: ThemeTypes;
|
||||
}) => {
|
||||
const [theme, setTheme] = useState<ThemeTypes>(initialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme class to document body to match root.tsx
|
||||
const body = document.body;
|
||||
if (theme === "dark") {
|
||||
body.classList.add("dark");
|
||||
} else {
|
||||
body.classList.remove("dark");
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const changeTheme = useCallback(async () => {
|
||||
const newTheme = theme === "light" ? "dark" : "light";
|
||||
setTheme(newTheme);
|
||||
|
||||
// Update document body class immediately to match root.tsx
|
||||
const body = document.body;
|
||||
if (newTheme === "dark") {
|
||||
body.classList.add("dark");
|
||||
} else {
|
||||
body.classList.remove("dark");
|
||||
}
|
||||
|
||||
await saveSession("theme", newTheme);
|
||||
|
||||
// Refresh the page to ensure all components update with new theme
|
||||
window.location.reload();
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={theme === "dark"}
|
||||
onChange={changeTheme}
|
||||
className="sr-only"
|
||||
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} theme`}
|
||||
/>
|
||||
<div
|
||||
onClick={changeTheme}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
changeTheme();
|
||||
}
|
||||
}}
|
||||
className="relative w-14 h-7 bg-gradient-to-r from-blue-400 to-blue-500 dark:from-indigo-600 dark:to-purple-600 rounded-full cursor-pointer transition-all duration-300 ease-in-out shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{/* Track background with gradient */}
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-sky-300 via-blue-400 to-blue-500 dark:from-slate-700 dark:via-slate-600 dark:to-slate-800 transition-all duration-300"></div>
|
||||
|
||||
{/* Sliding circle */}
|
||||
<div
|
||||
className={`absolute top-0.5 w-6 h-6 bg-WHITE rounded-full shadow-lg transform transition-all duration-300 ease-in-out flex items-center justify-center ${
|
||||
theme === "dark" ? "-translate-x-8" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Icon inside the circle */}
|
||||
<div className="transition-all duration-300">
|
||||
{theme === "light" ? (
|
||||
<Sun className="w-4 h-4 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4 text-indigo-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stars for dark mode */}
|
||||
{theme === "dark" && (
|
||||
<div className="absolute inset-0 rounded-full overflow-hidden">
|
||||
<div className="absolute top-1 left-1 w-1 h-1 bg-WHITE rounded-full opacity-60 animate-pulse"></div>
|
||||
<div className="absolute top-2 left-4 w-0.5 h-0.5 bg-WHITE rounded-full opacity-40 animate-pulse delay-75"></div>
|
||||
<div className="absolute top-1.5 right-2 w-0.5 h-0.5 bg-WHITE rounded-full opacity-50 animate-pulse delay-150"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clouds for light mode */}
|
||||
{theme === "light" && (
|
||||
<div className="absolute inset-0 rounded-full overflow-hidden">
|
||||
<div className="absolute top-1 right-1 w-2 h-1 bg-WHITE/30 rounded-full"></div>
|
||||
<div className="absolute top-2 right-2.5 w-1.5 h-0.5 bg-WHITE/20 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
app/components/ThemeToggle.css
Normal file
@ -0,0 +1,19 @@
|
||||
.theme-toggle {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 5px var(--color-shadow);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
49
app/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import "./ThemeToggle.css";
|
||||
import { saveSession } from "~/cookie";
|
||||
import { ThemeTypes } from "~/types/theme";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export const ThemeToggle = ({ theme: initialTheme }: { theme: ThemeTypes }) => {
|
||||
const [theme, setTheme] = useState<ThemeTypes>(initialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme class to document body to match root.tsx
|
||||
const body = document.body;
|
||||
if (theme === "dark") {
|
||||
body.classList.add("dark");
|
||||
} else {
|
||||
body.classList.remove("dark");
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const changeTheme = useCallback(async () => {
|
||||
const newTheme = theme === "light" ? "dark" : "light";
|
||||
setTheme(newTheme);
|
||||
|
||||
// Update document body class immediately to match root.tsx
|
||||
const body = document.body;
|
||||
if (newTheme === "dark") {
|
||||
body.classList.add("dark");
|
||||
} else {
|
||||
body.classList.remove("dark");
|
||||
}
|
||||
|
||||
// Save to cookie for persistence
|
||||
await saveSession("theme", newTheme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={changeTheme}
|
||||
className="theme-toggle p-2 rounded-lg bg-WHITE2 hover:bg-GRAY3 transition-colors"
|
||||
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} theme`}
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<Moon className="w-5 h-5 text-BLACK2" />
|
||||
) : (
|
||||
<Sun className="w-5 h-5 text-GRAY2" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
206
app/components/UiProvider.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import React from "react";
|
||||
import basket from "../assets/icons/cart/basket.svg";
|
||||
import paper from "../assets/icons/profile/orders/paper.svg";
|
||||
import location from "../assets/icons/profile/location/location.svg";
|
||||
import messageBox from "../assets/icons/profile/threads/messageBox.svg";
|
||||
import { Button } from "./ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { safeGoBack } from "~/utils/helpers";
|
||||
|
||||
export default function UiProvider({
|
||||
children,
|
||||
isLoading,
|
||||
isError,
|
||||
isEmpty,
|
||||
onRetry,
|
||||
type,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isEmpty: boolean;
|
||||
onRetry?: () => void;
|
||||
type:
|
||||
| "cart"
|
||||
| "following"
|
||||
| "location"
|
||||
| "order"
|
||||
| "product"
|
||||
| "threads"
|
||||
| "seller"
|
||||
| "wallet"
|
||||
| "locationEdit";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<LoadingState type={type} />
|
||||
) : isError ? (
|
||||
<ErrorState onRetry={onRetry} />
|
||||
) : isEmpty ? (
|
||||
<EmptyState type={type} />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const LoadingState = ({ type }: { type: string }) => {
|
||||
if (type === "following")
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full p-4">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<Skeleton key={index} className="w-full h-24 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (type === "location")
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full py-6">
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute top-0 left-0 w-full h-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
className="absolute top-1 left-1 w-10 h-10 border-4 border-t-transparent border-r-primary border-b-transparent border-l-transparent rounded-full animate-spin"
|
||||
style={{ animationDirection: "reverse", animationDuration: "0.8s" }}
|
||||
></div>
|
||||
<div
|
||||
className="absolute top-2 left-2 w-8 h-8 border-4 border-t-transparent border-r-transparent border-b-primary border-l-transparent rounded-full animate-spin"
|
||||
style={{ animationDuration: "1.2s" }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (type === "threads")
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-full h-[80px] flex items-center px-4 gap-4"
|
||||
>
|
||||
<Skeleton className="w-[50px] h-[50px] rounded-full shrink-0" />
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Skeleton className="w-20 h-4 rounded-lg" />
|
||||
<Skeleton className="w-40 h-4 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (type === "wallet")
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full py-6">
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute top-0 left-0 w-full h-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
className="absolute top-1 left-1 w-10 h-10 border-4 border-t-transparent border-r-primary border-b-transparent border-l-transparent rounded-full animate-spin"
|
||||
style={{ animationDirection: "reverse", animationDuration: "0.8s" }}
|
||||
></div>
|
||||
<div
|
||||
className="absolute top-2 left-2 w-8 h-8 border-4 border-t-transparent border-r-transparent border-b-primary border-l-transparent rounded-full animate-spin"
|
||||
style={{ animationDuration: "1.2s" }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<p className="text-B14">در حال بارگذاری...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ErrorState = ({ onRetry }: { onRetry?: () => void }) => {
|
||||
return (
|
||||
<div className="flex justify-center items-center flex-col gap-2 py-12">
|
||||
<p className="text-B16 font-bold">مشکلی در دریافت اطلاعات پیش آمده است</p>
|
||||
{onRetry && (
|
||||
<Button variant="dark" size="lg" onClick={onRetry}>
|
||||
تلاش مجدد
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const EmptyState = ({
|
||||
type,
|
||||
mondrianImage,
|
||||
mondrianTitle,
|
||||
mondrianDescription,
|
||||
}: {
|
||||
type: string;
|
||||
mondrianImage?: React.ReactNode;
|
||||
mondrianTitle?: string;
|
||||
mondrianDescription?: string;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
{type === "cart" && (
|
||||
<div className="flex w-full mt-[200px] flex-col gap-2 items-center justify-center px-4">
|
||||
<img alt="cartBanner" src={basket} className="w-12 h-12" />
|
||||
<p className="text-R16 text-BLACK2">سبد خرید شما خالی است</p>
|
||||
<p className="text-R12 text-GRAY">
|
||||
در سبد خرید، جای انتخابهای خاص شما خالیست.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{type === "following" && (
|
||||
<div className="flex w-full mt-[200px] flex-col gap-2 items-center justify-center">
|
||||
<img alt="cartBanner" src={paper} className="w-12 h-12" />
|
||||
<p className="text-R16 text-BLACK2">فروشگاهی دنبال نشده است.</p>
|
||||
<p className="text-R12 text-GRAY">
|
||||
تمام فروشگاههایی که دنبال میکنید در این قسمت قابل مشاهده است.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{type === "location" && (
|
||||
<div className="flex flex-col gap-2 items-center mt-[200px]">
|
||||
<img alt="" src={location} className="w-12 h-12" />
|
||||
<p className="text-R16 text-BLACK2">هنوز آدرسی ثبت نشده است</p>
|
||||
<p className="text-R12 text-GRAY">اولین آدرس خود را ثبت کنید</p>
|
||||
</div>
|
||||
)}
|
||||
{type === "locationEdit" && (
|
||||
<div className="h-[500px] w-full bg-WHITE3 flex items-center justify-center flex-col gap-2 mt-[200px]">
|
||||
<p className="text-lg font-medium">آدرس مورد نظر یافت نشد</p>
|
||||
<Button
|
||||
onClick={() => safeGoBack(navigate)}
|
||||
size={"sm"}
|
||||
variant="primary"
|
||||
>
|
||||
بازگشت
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{type === "threads" && (
|
||||
<div className="flex w-full mt-[200px] flex-col gap-2 items-center justify-center">
|
||||
<img alt="messageBox" src={messageBox} className="w-12 h-12" />
|
||||
<p className="text-R16 text-BLACK2">لیست پیامها خالی است.</p>
|
||||
<p className="text-R12 text-GRAY">
|
||||
هنوز پیامی دریافت یا ارسال نکردهاید
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{type === "orders" && (
|
||||
<div className="flex w-full mt-[200px] flex-col gap-2 items-center justify-center">
|
||||
<img alt="cartBanner" src={basket} className="w-12 h-12" />
|
||||
<p className="text-R16 text-BLACK2">هنوز سفارشی ثبت نشده است</p>
|
||||
<p className="text-R12 text-GRAY">
|
||||
از محصولات دیدن کنید و سفارش خود را ثبت کنید.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{type === "mondrian" && (
|
||||
<div className="flex w-full mt-[200px] flex-col gap-2 items-center justify-center">
|
||||
{mondrianImage}
|
||||
<p className="text-R16 text-BLACK2">{mondrianTitle}</p>
|
||||
<p className="text-R12 text-GRAY">{mondrianDescription}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
74
app/components/cart/CartHeader.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { CartDetail } from "src/api/types";
|
||||
import SellerLogo from "../SellerLogo";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { safeGoBack } from "~/utils/helpers";
|
||||
|
||||
interface CartHeaderProps {
|
||||
cart: CartDetail | null | undefined;
|
||||
step: number;
|
||||
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export const CartHeader = ({ cart, step, setStep }: CartHeaderProps) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-3 pt-4 sticky top-0 z-30 bg-WHITE w-full items-center justify-center ${step !== 0 ? "pb-4" : ""}`}
|
||||
>
|
||||
<p className={"text-B16 font-bold px-4"}>
|
||||
{step === 0
|
||||
? "سبد خرید"
|
||||
: step === 1
|
||||
? "اطلاعات ارسال"
|
||||
: "اطلاعات تحویل گیرنده وپرداخت"}
|
||||
</p>
|
||||
|
||||
<ArrowRight
|
||||
onClick={() => {
|
||||
if (step > 0) setStep((p) => p - 1);
|
||||
else safeGoBack(navigate);
|
||||
}}
|
||||
className="absolute top-4 right-4"
|
||||
/>
|
||||
{cart && cart.items && cart.items.length > 0 && (
|
||||
<>
|
||||
<StepIndicator currentStep={step} />
|
||||
{step === 0 && (
|
||||
<a
|
||||
href={`/seller/${cart.seller?.username}`}
|
||||
className="flex px-4 gap-2 h-[65px] border-b w-full justify-center items-center"
|
||||
>
|
||||
<SellerLogo
|
||||
size="sm"
|
||||
src={cart.seller?.logo}
|
||||
alt={"seller-image"}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="text-B12 font-bold text-overflow-ellipsis overflow-hidden whitespace-nowrap">
|
||||
{cart.seller?.name}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Step Indicator Component
|
||||
const StepIndicator = ({ currentStep }: { currentStep: number }) => (
|
||||
<div className="w-full px-4 gap-4 flex pt-2">
|
||||
{Array(3)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-full h-[6px] rounded-md ${
|
||||
currentStep >= index ? "bg-BLACK" : "bg-GRAY2"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
178
app/components/cart/CartItem.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { Check, Trash } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { QuantityControl } from "./QuantityControl";
|
||||
import { formatPrice } from "../../utils/helpers";
|
||||
import { CartItem as CartItemType } from "src/api/types";
|
||||
import { isVideo } from "../MondrianProductList";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface CartItemProps {
|
||||
item: CartItemType;
|
||||
onQuantityChange: (itemId: string, quantity: number) => void;
|
||||
onRemoveItem: (itemId: string) => void;
|
||||
isLoading: boolean;
|
||||
hasBorder: boolean;
|
||||
}
|
||||
|
||||
export const CartItem = memo(function CartItem({
|
||||
item,
|
||||
onQuantityChange,
|
||||
onRemoveItem,
|
||||
isLoading,
|
||||
hasBorder,
|
||||
}: CartItemProps) {
|
||||
// Memoize discount calculation
|
||||
const discountPercent = useMemo(() => {
|
||||
if (!item.productVariant?.discountPrice || !item.productVariant?.price) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(
|
||||
((Number(item.productVariant.price) -
|
||||
Number(item.productVariant.discountPrice)) /
|
||||
Number(item.productVariant.price)) *
|
||||
100
|
||||
);
|
||||
}, [item.productVariant?.discountPrice, item.productVariant?.price]);
|
||||
|
||||
// Memoize video check
|
||||
const isVideoMedia = useMemo(
|
||||
() => isVideo(item.productVariant?.productImage || ""),
|
||||
[item.productVariant?.productImage]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`flex p-4 gap-4 w-full flex-col ${hasBorder && "border-b"}`}
|
||||
>
|
||||
<div className="flex gap-2 w-full items-center">
|
||||
{isVideoMedia ? (
|
||||
<video
|
||||
src={item.productVariant?.productImage}
|
||||
className="rounded-[5px] w-[116px] h-[116px] object-cover"
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={item.productVariant?.productImage}
|
||||
alt={item.productVariant?.productTitle || ""}
|
||||
className="rounded-[5px] w-[116px] h-[116px] object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-B14 font-bold">
|
||||
{item.productVariant?.productTitle || ""}
|
||||
</p>
|
||||
<p className="text-R12 text-GRAY line-clamp-1">
|
||||
کد کالا: {item.productVariant?.id || ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ColorSelector
|
||||
color={
|
||||
item.productVariant?.color?.value !== "UNSELECTED"
|
||||
? item.productVariant?.color?.info?.detail || ""
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<SizeSelector
|
||||
size={
|
||||
item.productVariant?.size?.value !== "UNSELECTED"
|
||||
? item.productVariant?.size?.info?.detail || ""
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="text-B14 font-bold">قیمت واحد :</p>
|
||||
<p className="text-B14 font-bold">
|
||||
{formatPrice(item.productVariant?.price || "")} تومان
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{discountPercent ? (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-B14 font-bold">مقدار تخفیف :</p>
|
||||
<div className="bg-BLACK rounded-full text-WHITE text-R14 px-2 py-1">
|
||||
{discountPercent}%
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-B14 font-bold">
|
||||
{formatPrice(
|
||||
(
|
||||
Number(item.productVariant?.price) -
|
||||
Number(item.productVariant?.discountPrice)
|
||||
).toString()
|
||||
)}{" "}
|
||||
تومان
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item?.productVariant?.stock && item?.productVariant?.stock > 0 ? (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2"></div>
|
||||
<QuantityControl
|
||||
isPending={isLoading}
|
||||
quantity={item.quantity}
|
||||
maxQuantity={item.productVariant?.stock || 0}
|
||||
onIncrease={() =>
|
||||
onQuantityChange(item.id || "", item.quantity + 1)
|
||||
}
|
||||
onDecrease={() =>
|
||||
onQuantityChange(item.id || "", item.quantity - 1)
|
||||
}
|
||||
onRemove={() => onRemoveItem(item.id || "")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="text-B14 font-bold">محصول در حال حاضر موجود نیست</p>
|
||||
<Button
|
||||
variant="dark"
|
||||
className="flex items-center gap-2 bg-red-500"
|
||||
size="sm"
|
||||
onClick={() => onRemoveItem(item.id || "")}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Color Selector Component
|
||||
const ColorSelector = ({ color }: { color: string }) => (
|
||||
<>
|
||||
{color && (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="text-B14 font-bold">رنگ :</p>
|
||||
<div
|
||||
className="w-10 h-10 rounded-[7px] flex items-center justify-center border border-BLACK"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
<Check size={24} className="text-WHITE" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Size Selector Component
|
||||
const SizeSelector = ({ size }: { size: string }) => (
|
||||
<>
|
||||
{size && (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="text-B14 font-bold">سایز :</p>
|
||||
<p className="text-BLACK text-R12">{size}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
37
app/components/cart/CartSummary.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { formatPrice } from "../../utils/helpers";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface CartSummaryProps {
|
||||
total: string;
|
||||
onContinue: () => void;
|
||||
step: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const CartSummary = ({
|
||||
total,
|
||||
onContinue,
|
||||
step,
|
||||
isLoading,
|
||||
}: CartSummaryProps) => (
|
||||
<div className="max-w-md mx-auto fixed flex flex-row gap-2 w-full bottom-[50px] left-0 right-0 border-t py-1 px-2 z-50 bg-WHITE">
|
||||
<Button
|
||||
variant="dark"
|
||||
size="lg"
|
||||
className="w-full flex-1 font-bold bg-VITROWN_BLUE active:bg-VITROWN_BLUE hover:bg-VITROWN_BLUE text-white"
|
||||
onClick={onContinue}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{step === 0 || step === 1 ? "ثبت و ادامه" : "تایید و پرداخت"}
|
||||
</Button>
|
||||
<div className="flex-1 gap-2 w-full flex flex-col items-center justify-center">
|
||||
<p className="text-B14 font-bold text-GRAY">مبلغ کل فاکتور:</p>
|
||||
<p className="text-B14 font-bold">{formatPrice(total)} تومان</p>
|
||||
{step === 2 && (
|
||||
<p className="text-R10 text-GRAY">۱۰٪ مالیات به مبلغ نهایی اضافه میشود</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
190
app/components/cart/QuantityControl.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Minus, Trash2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogOverlay } from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface QuantityControlProps {
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
onRemove?: () => void;
|
||||
isPending: boolean;
|
||||
allowManualEdit?: boolean;
|
||||
onQuantityChange?: (quantity: number) => void;
|
||||
}
|
||||
|
||||
export const QuantityControl = ({
|
||||
quantity,
|
||||
maxQuantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
onRemove,
|
||||
isPending,
|
||||
allowManualEdit = false,
|
||||
onQuantityChange,
|
||||
}: QuantityControlProps) => {
|
||||
const [openDeleteModal, setOpenDeleteModal] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(quantity.toString());
|
||||
|
||||
const handleEditStart = () => {
|
||||
if (allowManualEdit) {
|
||||
setIsEditing(true);
|
||||
setEditValue(quantity.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditConfirm = () => {
|
||||
const newQuantity = parseInt(editValue);
|
||||
if (!isNaN(newQuantity) && newQuantity > 0 && newQuantity <= maxQuantity) {
|
||||
onQuantityChange?.(newQuantity);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditValue(quantity.toString());
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleEditConfirm();
|
||||
} else if (e.key === "Escape") {
|
||||
handleEditCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 items-center rounded-[100px] bg-WHITE3 py-2 px-4">
|
||||
<Plus
|
||||
onClick={() => {
|
||||
if (quantity < maxQuantity) {
|
||||
onIncrease();
|
||||
}
|
||||
}}
|
||||
className={`${quantity >= maxQuantity ? "text-GRAY" : "text-BLACK"} cursor-pointer`}
|
||||
/>
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleEditConfirm}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="px-2 text-B14 font-bold w-12 text-center bg-transparent border-none outline-none"
|
||||
min="1"
|
||||
max={maxQuantity}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`px-2 text-B14 font-bold ${allowManualEdit ? "cursor-pointer hover:bg-WHITE3 rounded" : ""}`}
|
||||
onClick={handleEditStart}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleEditStart();
|
||||
}
|
||||
}}
|
||||
role={allowManualEdit ? "button" : undefined}
|
||||
tabIndex={allowManualEdit ? 0 : undefined}
|
||||
>
|
||||
{quantity}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{quantity > 1 || !onRemove ? (
|
||||
<Minus
|
||||
size={24}
|
||||
onClick={() => {
|
||||
if (quantity > 1 || !onRemove) {
|
||||
onDecrease();
|
||||
}
|
||||
}}
|
||||
className={`${(quantity <= 1 && onRemove) || (quantity <= 0 && !onRemove) ? "text-GRAY" : "text-BLACK"} cursor-pointer`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Trash2
|
||||
size={24}
|
||||
className="text-RED cursor-pointer"
|
||||
onClick={() => {
|
||||
setOpenDeleteModal(true);
|
||||
}}
|
||||
/>
|
||||
<RemoveCartModal
|
||||
isPending={isPending}
|
||||
onRemove={onRemove || (() => {})}
|
||||
onOpenChange={setOpenDeleteModal}
|
||||
open={openDeleteModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RemoveCartModalProps {
|
||||
isPending: boolean;
|
||||
onRemove: () => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const RemoveCartModal = ({
|
||||
isPending,
|
||||
onRemove,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RemoveCartModalProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogOverlay />
|
||||
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)]">
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className={"flex flex-col gap-3 items-center w-full"}>
|
||||
<Trash2 className="text-RED text-6" />
|
||||
<p className="text-B12 font-bold mt-1">حذف محصول از سبد خرید</p>
|
||||
<p className="text-R12">این محصول از سبد شما حذف شود؟</p>
|
||||
</div>
|
||||
<div className="flex flex-row gap-6 w-full">
|
||||
<Button
|
||||
className="w-[100%]"
|
||||
size={"sm"}
|
||||
variant="dark"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
className="w-[100%]"
|
||||
size={"sm"}
|
||||
variant="primary"
|
||||
onClick={() => onRemove()}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<div className="relative w-5 h-5 mr-2">
|
||||
<div className="absolute top-0 left-0 w-full h-full border-2 border-t-RED border-r-transparent border-b-transparent border-l-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
className="absolute top-0.5 left-0.5 w-4 h-4 border-2 border-t-transparent border-r-RED border-b-transparent border-l-transparent rounded-full animate-spin"
|
||||
style={{
|
||||
animationDirection: "reverse",
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
درحال حذف...
|
||||
</>
|
||||
) : (
|
||||
"حذف از سبد خرید"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
228
app/components/cart/ReceiverInformation.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { useState } from "react";
|
||||
import AppInput from "../../components/AppInput";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { FileText, User, Loader2 } from "lucide-react";
|
||||
import { formatPrice } from "../../utils/helpers";
|
||||
import { formatDateRange } from "./ShippingInformation";
|
||||
import { useParams } from "@remix-run/react";
|
||||
import {
|
||||
Address,
|
||||
CartItem,
|
||||
CartItem as CartItemType,
|
||||
SellerCourierList,
|
||||
} from "src/api/types";
|
||||
import { useVerifyDiscountCode } from "../../requestHandler/use-discount-hooks";
|
||||
|
||||
interface ReceiverInformationProps {
|
||||
selectedAddressInfo: Address | null;
|
||||
selectedCourierInfo: SellerCourierList | null;
|
||||
total: string;
|
||||
cartItems: CartItemType[];
|
||||
discountValue: number | null;
|
||||
}
|
||||
|
||||
export const ReceiverInformation = ({
|
||||
selectedAddressInfo,
|
||||
selectedCourierInfo,
|
||||
total,
|
||||
cartItems,
|
||||
discountValue,
|
||||
}: ReceiverInformationProps) => {
|
||||
const [discountCode, setDiscountCode] = useState<string>("");
|
||||
const { cartId } = useParams();
|
||||
const isFaceToFace = selectedCourierInfo?.courierIsFaceToFace;
|
||||
// Use the verify discount code hook
|
||||
const { mutate: verifyDiscountCode, isPending: isLoading } =
|
||||
useVerifyDiscountCode();
|
||||
|
||||
const sendDiscountCode = async () => {
|
||||
if (!cartId || !discountCode) return;
|
||||
|
||||
verifyDiscountCode(
|
||||
{
|
||||
code: parseInt(discountCode),
|
||||
cartId: cartId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDiscountCode("");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{!isFaceToFace && (
|
||||
<div className="py-4 flex flex-col gap-4 border-b px-4 w-full">
|
||||
<div className="gap-1 flex items-center">
|
||||
<div>
|
||||
<User size={24} />
|
||||
</div>
|
||||
<p className={"text-B16 font-bold"}>تحویل گیرنده</p>
|
||||
</div>
|
||||
|
||||
<AppInput
|
||||
inputDir="rtl"
|
||||
inputTitle={"نام تحویل گیرنده"}
|
||||
inputValue={selectedAddressInfo?.receiverName || ""}
|
||||
inputPlaceholder={"------------"}
|
||||
inputOnChange={() => {}}
|
||||
disabled={true}
|
||||
/>
|
||||
<AppInput
|
||||
inputDir="ltr"
|
||||
disabled={true}
|
||||
inputTitle={"شماره موبایل"}
|
||||
inputValue={selectedAddressInfo?.phoneNumber || ""}
|
||||
inputPlaceholder={"---------"}
|
||||
inputOnChange={() => {}}
|
||||
/>
|
||||
<div className="flex w-full gap-2 items-end">
|
||||
<AppInput
|
||||
inputDir="rtl"
|
||||
inputTitle={"کد تخفیف دارید؟"}
|
||||
inputValue={discountCode}
|
||||
inputPlaceholder={"کد تخفیف را اینجا وارد کنید"}
|
||||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setDiscountCode(value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
disabled={!discountCode || isLoading}
|
||||
size={"lg"}
|
||||
className="px-8"
|
||||
variant="dark"
|
||||
onClick={() => sendDiscountCode()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
درحال ثبت...
|
||||
</>
|
||||
) : (
|
||||
"ثبت"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<InvoiceContent
|
||||
selectedCourierInfo={selectedCourierInfo}
|
||||
selectedAddressInfo={selectedAddressInfo}
|
||||
cartItems={cartItems}
|
||||
total={total}
|
||||
discountValue={discountValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface InvoiceContentProps {
|
||||
selectedCourierInfo: SellerCourierList | null;
|
||||
selectedAddressInfo: Address | null;
|
||||
cartItems: CartItem[];
|
||||
total: string;
|
||||
discountValue: number | null;
|
||||
}
|
||||
export const InvoiceContent = ({
|
||||
selectedCourierInfo,
|
||||
selectedAddressInfo,
|
||||
cartItems,
|
||||
total,
|
||||
discountValue,
|
||||
}: InvoiceContentProps) => {
|
||||
const isFaceToFace = selectedCourierInfo?.courierIsFaceToFace;
|
||||
return (
|
||||
<div className="py-4 flex flex-col gap-4 px-4 w-full">
|
||||
<div className="gap-1 flex items-center">
|
||||
<div>
|
||||
<FileText size={24} />
|
||||
</div>
|
||||
<p className={"text-B16 font-bold"}>فاکتور خرید</p>
|
||||
</div>
|
||||
{!selectedCourierInfo ? (
|
||||
<div className="flex justify-center py-12 px-4">
|
||||
<Loader2 className="animate-spin" />
|
||||
<p className="text-B14">در حال بارگذاری...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isFaceToFace && (
|
||||
<div className="flex flex-col gap-4 px-4 w-full border-b pb-4">
|
||||
<div className="w-full justify-between flex gap-1">
|
||||
<p className="text-R12">کد پستی</p>
|
||||
{!selectedAddressInfo ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<p className="text-R12">{selectedAddressInfo?.postalCode}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex gap-1 justify-between">
|
||||
<p className="text-R12">زمان تحویل</p>
|
||||
<p className="text-R12">
|
||||
{formatDateRange(selectedCourierInfo?.estimatedDays)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full justify-between flex gap-1">
|
||||
{!selectedAddressInfo ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<p className="text-R12">
|
||||
آدرس:{selectedAddressInfo?.mainAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4 px-4 w-full border-b pb-4">
|
||||
{cartItems?.map((item, index) => (
|
||||
<div key={index} className="w-full justify-between flex gap-1">
|
||||
<p className="text-R12">{item.productVariant?.productTitle}</p>
|
||||
<p className="text-R12 tracking-wide">
|
||||
{formatPrice(item.productVariant?.discountPrice || "0")}x
|
||||
{item.quantity} تومان
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="w-full justify-between flex gap-1">
|
||||
<p className="text-R12">مقدار تخفیف</p>
|
||||
{!selectedCourierInfo ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<p className="text-R12 tracking-wide">
|
||||
{formatPrice(discountValue?.toString() || "0")} تومان
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full justify-between flex gap-1">
|
||||
<p className="text-R12">هزینه ارسال</p>
|
||||
{!selectedCourierInfo ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<p className="text-R12 tracking-wide">
|
||||
{selectedCourierInfo?.courierIsFreightCollect
|
||||
? "پس پرداخت"
|
||||
: `${formatPrice(
|
||||
selectedCourierInfo?.deliveryPrice?.toString() || "0"
|
||||
)} تومان`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full justify-between flex gap-1">
|
||||
<p className="text-B16 font-bold">مبلغ کل فاکتور</p>
|
||||
{!selectedCourierInfo ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<p className="text-B16 font-bold tracking-wide">
|
||||
{formatPrice(parseFloat(total).toString())} تومان
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
440
app/components/cart/ShippingInformation.tsx
Normal file
@ -0,0 +1,440 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
Plus,
|
||||
Menu,
|
||||
Truck,
|
||||
X,
|
||||
Ellipsis,
|
||||
CalendarDays,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { Button } from "../ui/button";
|
||||
import { useServiceGetAddresses } from "../../requestHandler/use-address-hooks";
|
||||
import { useServiceGetCouriers } from "../../requestHandler/use-courier-hooks";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DrawerContent, Drawer, DrawerHeader } from "../ui/drawer";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
||||
import { formatPrice } from "../../utils/helpers";
|
||||
import { useToast } from "../../hooks/use-toast";
|
||||
import { AdderssSettingDrawer } from "../../routes/profile.location._index";
|
||||
import jMoment from "moment-jalaali";
|
||||
import { Address, SellerCourierList } from "src/api/types";
|
||||
|
||||
export const ShippingInformation = ({
|
||||
sellerId,
|
||||
total,
|
||||
setSelectedAddressInfo,
|
||||
setSelectedCourierInfo,
|
||||
setShippingInfoIsLoading,
|
||||
selectedAddressInfo,
|
||||
cartId,
|
||||
}: {
|
||||
sellerId: string;
|
||||
total: string;
|
||||
setSelectedAddressInfo: (address: Address) => void;
|
||||
setSelectedCourierInfo: (courierId: SellerCourierList) => void;
|
||||
setShippingInfoIsLoading: (isLoading: boolean) => void;
|
||||
selectedAddressInfo?: Address | null;
|
||||
cartId?: string;
|
||||
}) => {
|
||||
const [selectedCourier, setSelectedCourier] =
|
||||
useState<SellerCourierList | null>(null);
|
||||
|
||||
const handleCourierSelection = (courier: SellerCourierList) => {
|
||||
console.log("sss");
|
||||
setSelectedCourier(courier);
|
||||
setSelectedCourierInfo(courier);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col w-full">
|
||||
<AddressSelection
|
||||
setSelectedAddressInfo={setSelectedAddressInfo}
|
||||
total={total}
|
||||
setShippingInfoIsLoading={setShippingInfoIsLoading}
|
||||
selectedAddressInfo={selectedAddressInfo}
|
||||
selectedCourier={selectedCourier}
|
||||
cartId={cartId}
|
||||
/>
|
||||
<ShippingInformationSelection
|
||||
setSelectedCourierInfo={handleCourierSelection}
|
||||
sellerId={sellerId}
|
||||
setShippingInfoIsLoading={setShippingInfoIsLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ShippingInformationSelection = ({
|
||||
sellerId,
|
||||
setSelectedCourierInfo,
|
||||
setShippingInfoIsLoading,
|
||||
}: {
|
||||
sellerId: string;
|
||||
setSelectedCourierInfo: (courierId: SellerCourierList) => void;
|
||||
setShippingInfoIsLoading: (isLoading: boolean) => void;
|
||||
}) => {
|
||||
const { data: couriersData, isLoading } = useServiceGetCouriers(sellerId);
|
||||
useEffect(() => {
|
||||
setShippingInfoIsLoading(isLoading);
|
||||
}, [isLoading]);
|
||||
const [selectedCourierIndex, setSelectedCourierIndex] = useState<string>("");
|
||||
const selectedCourier = couriersData?.filter(
|
||||
(courier: SellerCourierList) => courier.id === selectedCourierIndex
|
||||
)?.[0];
|
||||
const isFaceToFace = selectedCourier?.courierIsFaceToFace;
|
||||
|
||||
useEffect(() => {
|
||||
if (couriersData && couriersData.length > 0) {
|
||||
setSelectedCourierIndex(couriersData[0].id || "");
|
||||
}
|
||||
}, [couriersData]);
|
||||
useEffect(() => {
|
||||
setSelectedCourierInfo(
|
||||
couriersData?.find(
|
||||
(courier: SellerCourierList) => courier.id === selectedCourierIndex
|
||||
) as SellerCourierList
|
||||
);
|
||||
}, [selectedCourierIndex, couriersData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-4 flex flex-col gap-4">
|
||||
<div className="gap-1 px-4 flex items-center">
|
||||
<div>
|
||||
<Truck size={24} />
|
||||
</div>
|
||||
<p className={"text-B16 font-bold"}>انتخاب شیوه ارسال:</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="px-4">در حال بارگذاری...</p>
|
||||
) : couriersData ? (
|
||||
<>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => setSelectedCourierIndex(value)}
|
||||
value={selectedCourierIndex}
|
||||
className="flex gap-4 px-4"
|
||||
dir="rtl"
|
||||
>
|
||||
{couriersData.map((courier: SellerCourierList) => (
|
||||
<div key={courier.id} className="flex items-center gap-1">
|
||||
<RadioGroupItem
|
||||
value={courier.id || ""}
|
||||
id={`courier-${courier.id}`}
|
||||
className="size-5"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`courier-${courier.id}`}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<p className="text-R14">{courier.courierName}</p>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="py-6 px-4 bg-WHITE3 gap-4 flex flex-col">
|
||||
{isFaceToFace ? (
|
||||
<p className={"text-B12 line-clamp-1 w-full font-bold"}>
|
||||
روش ارسال حضوری است و در همان محل تحویل خواهد شد
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="gap-1 flex items-center">
|
||||
<div>
|
||||
<CalendarDays size={24} />
|
||||
</div>
|
||||
<p className={"text-B12 line-clamp-1 w-full font-bold"}>
|
||||
زمان تحویل سفارش:
|
||||
</p>
|
||||
<p className="text-R12 w-full">
|
||||
{formatDateRange(selectedCourier?.estimatedDays || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="gap-1 flex items-center">
|
||||
<div>
|
||||
<Truck size={24} />
|
||||
</div>
|
||||
<p className={"text-B12 line-clamp-1 w-full font-bold"}>
|
||||
هزینه ارسال:
|
||||
</p>
|
||||
<p className="text-R12 w-full">
|
||||
{selectedCourier?.courierIsFreightCollect
|
||||
? "پس پرداخت"
|
||||
: `${formatPrice(
|
||||
JSON.stringify(selectedCourier?.deliveryPrice)
|
||||
)} تومان`}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>هیچ روش ارسالی یافت نشد</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddressSelection = ({
|
||||
total,
|
||||
setSelectedAddressInfo,
|
||||
setShippingInfoIsLoading,
|
||||
selectedAddressInfo,
|
||||
selectedCourier,
|
||||
cartId,
|
||||
}: {
|
||||
total: string;
|
||||
setSelectedAddressInfo: (addressInfo: Address) => void;
|
||||
setShippingInfoIsLoading: (isLoading: boolean) => void;
|
||||
selectedAddressInfo?: Address | null;
|
||||
selectedCourier?: SellerCourierList | null;
|
||||
cartId?: string;
|
||||
}) => {
|
||||
const { data, isLoading } = useServiceGetAddresses();
|
||||
const [selectedAddress, setSelectedAddress] = useState<Address | null>(
|
||||
selectedAddressInfo || null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setShippingInfoIsLoading(isLoading);
|
||||
}, [isLoading]);
|
||||
|
||||
const defaultAddress = data?.find((address: Address) => address.isDefault);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAddress && defaultAddress) {
|
||||
setSelectedAddress(defaultAddress);
|
||||
setSelectedAddressInfo(defaultAddress);
|
||||
}
|
||||
}, [defaultAddress]);
|
||||
|
||||
const handleAddressChange = (address: Address) => {
|
||||
setSelectedAddress(address);
|
||||
setSelectedAddressInfo(address);
|
||||
};
|
||||
|
||||
const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
|
||||
const [selectedAddressForSettings, setSelectedAddressForSettings] =
|
||||
useState<Address | null>(null);
|
||||
|
||||
const openSettingsDrawer = (address: Address) => {
|
||||
setSelectedAddressForSettings(address);
|
||||
setSettingsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const isFaceToFace = selectedCourier?.courierIsFaceToFace;
|
||||
return (
|
||||
<>
|
||||
<div className="py-4 flex flex-col gap-4 border-b px-4">
|
||||
<div className="gap-1 flex items-center">
|
||||
<div>
|
||||
<MapPin size={24} />
|
||||
</div>
|
||||
<p className={"text-B16 font-bold"}>آدرس دریافت سفارش</p>
|
||||
</div>
|
||||
<div
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`w-full flex gap-2 items-center py-4`}
|
||||
>
|
||||
<div className={`flex flex-col w-full`}>
|
||||
{selectedAddress || defaultAddress ? (
|
||||
<>
|
||||
<p className="text-B14 font-bold text-GRAY">
|
||||
{selectedAddress?.title || defaultAddress?.title}
|
||||
</p>
|
||||
<p className="text-R12 line-clamp-1 text-GRAY">
|
||||
{selectedAddress?.mainAddress || defaultAddress?.mainAddress}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isFaceToFace ? (
|
||||
<>
|
||||
<p className="text-R12 text-GRAY">
|
||||
برای دریافت حضوری نیازی به آدرس نیست
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-B14 font-bold text-RED">
|
||||
آدرسی اضافه نشده است
|
||||
</p>
|
||||
<p className="text-R12 text-GRAY">
|
||||
برای ادامه خرید، لطفا یک آدرس جدید اضافه کنید
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(selectedAddress || defaultAddress) && (
|
||||
<ChangeDefaulAddressDrawer
|
||||
total={total}
|
||||
onOpenSettings={openSettingsDrawer}
|
||||
selectedAddress={selectedAddress}
|
||||
onAddressChange={handleAddressChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isFaceToFace && (
|
||||
<Link to={cartId ? `/profile/location/add?returnTo=cart&cartId=${cartId}` : "/profile/location/add"}>
|
||||
<Button size={"xl"} variant="primary" className="w-full">
|
||||
<Plus />
|
||||
افزودن آدرس جدید
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<AdderssSettingDrawer
|
||||
isOpen={settingsDrawerOpen}
|
||||
setIsOpen={setSettingsDrawerOpen}
|
||||
address={selectedAddressForSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const ChangeDefaulAddressDrawer = ({
|
||||
total,
|
||||
onOpenSettings,
|
||||
selectedAddress,
|
||||
onAddressChange,
|
||||
}: {
|
||||
total: string;
|
||||
onOpenSettings: (address: Address) => void;
|
||||
selectedAddress: Address | null;
|
||||
onAddressChange: (address: Address) => void;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: dataAddress } = useServiceGetAddresses();
|
||||
const [address, setAddress] = useState(selectedAddress?.id);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAddress) {
|
||||
setAddress(selectedAddress.id);
|
||||
}
|
||||
}, [selectedAddress]);
|
||||
|
||||
const changeShippingAddress = () => {
|
||||
const newAddress = dataAddress?.find(
|
||||
(addr: Address) => addr.id === address
|
||||
);
|
||||
if (newAddress) {
|
||||
onAddressChange(newAddress);
|
||||
setIsOpen(false);
|
||||
toast({
|
||||
title: "آدرس ارسال با موفقیت تغییر کرد",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu className="text-6" onClick={() => setIsOpen(true)} />
|
||||
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<div className="flex flex-row w-full relative items-center justify-center">
|
||||
<X
|
||||
className="absolute size-6 top-2 right-3"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<p className={"text-B12 font-bold pt-3"}>انتخاب آدرس ارسال</p>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
{dataAddress && (
|
||||
<div className="flex flex-col items-center border-b pt-2 mb-2 px-4 w-full">
|
||||
<RadioGroup
|
||||
onValueChange={(value) => setAddress(value)}
|
||||
value={address}
|
||||
className="w-full"
|
||||
>
|
||||
{dataAddress.map((address: Address, index: number) => (
|
||||
<AddressRow
|
||||
address={address}
|
||||
key={index}
|
||||
isLastItem={index === dataAddress?.length - 1}
|
||||
onOpenSettings={() => {
|
||||
setIsOpen(false);
|
||||
onOpenSettings(address);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 flex-row w-full py-1 px-4 bg-WHITE">
|
||||
<Button
|
||||
variant="dark"
|
||||
size="lg"
|
||||
className="w-full flex-1 font-bold"
|
||||
onClick={changeShippingAddress}
|
||||
>
|
||||
تغییر آدرس ارسال
|
||||
</Button>
|
||||
<div className="flex-1 gap-2 w-full flex flex-col items-end justify-center">
|
||||
<div className="flex gap-2 flex-col items-center">
|
||||
<p className="text-B14 font-bold text-GRAY">مبلغ کل فاکتور:</p>
|
||||
<p className="text-B14 font-bold">{formatPrice(total)} تومان</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddressRow = ({
|
||||
address,
|
||||
isLastItem,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
address: Address;
|
||||
isLastItem: boolean;
|
||||
onOpenSettings: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex py-4 items-center gap-2 flex-row-reverse ${!isLastItem ? "border-b" : ""}`}
|
||||
>
|
||||
<RadioGroupItem value={address?.id || ""} id={`radio-${address?.id}`} />
|
||||
<label
|
||||
className="w-full"
|
||||
style={{ direction: "rtl" }}
|
||||
htmlFor={`radio-${address?.id}`}
|
||||
>
|
||||
<p className="text-R14">{address?.title}</p>
|
||||
<p className="text-R12 line-clamp-1">{address?.mainAddress}</p>
|
||||
</label>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="p-1 hover:bg-WHITE3 rounded-full"
|
||||
aria-label="تنظیمات آدرس"
|
||||
>
|
||||
<Ellipsis size={24} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const formatDateRange = (
|
||||
untilDays?: number | null,
|
||||
created_at?: string
|
||||
) => {
|
||||
const startDate = created_at ? jMoment(created_at) : jMoment();
|
||||
if (!untilDays) return `از ${startDate.format("jYYYY/jMM/jDD")}`;
|
||||
const deliveryDate = startDate.clone().add(untilDays, "days");
|
||||
return `بین ${startDate.format("jYYYY/jMM/jDD")} تا ${deliveryDate.format("jYYYY/jMM/jDD")}`;
|
||||
};
|
||||
133
app/components/cart/ThrottledQuantityControl.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { QuantityControl } from "./QuantityControl";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface ThrottledQuantityControlProps {
|
||||
initialQuantity: number;
|
||||
maxQuantity: number;
|
||||
onQuantityUpdate: (quantity: number) => void;
|
||||
isPending?: boolean;
|
||||
allowManualEdit?: boolean;
|
||||
onRemove?: () => void;
|
||||
throttleDelay?: number;
|
||||
}
|
||||
|
||||
export const ThrottledQuantityControl = ({
|
||||
initialQuantity,
|
||||
maxQuantity,
|
||||
onQuantityUpdate,
|
||||
isPending: externalIsPending = false,
|
||||
allowManualEdit = false,
|
||||
onRemove,
|
||||
throttleDelay = 1000,
|
||||
}: ThrottledQuantityControlProps) => {
|
||||
const [quantity, setQuantity] = useState(initialQuantity);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const throttleTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (throttleTimerRef.current) {
|
||||
clearTimeout(throttleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuantity(initialQuantity);
|
||||
}, [initialQuantity]);
|
||||
|
||||
const performUpdate = useCallback(
|
||||
async (newQuantity: number) => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await onQuantityUpdate(newQuantity);
|
||||
} catch (error) {
|
||||
// Revert quantity on error
|
||||
if (isMountedRef.current) {
|
||||
setQuantity(initialQuantity);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onQuantityUpdate, initialQuantity]
|
||||
);
|
||||
|
||||
const scheduleUpdate = useCallback(
|
||||
(newQuantity: number) => {
|
||||
// Update local state immediately for responsive UI
|
||||
setQuantity(newQuantity);
|
||||
|
||||
// Clear existing timer
|
||||
if (throttleTimerRef.current) {
|
||||
clearTimeout(throttleTimerRef.current);
|
||||
}
|
||||
|
||||
// Schedule the API call after throttle delay
|
||||
throttleTimerRef.current = setTimeout(() => {
|
||||
performUpdate(newQuantity);
|
||||
}, throttleDelay);
|
||||
},
|
||||
[performUpdate, throttleDelay]
|
||||
);
|
||||
|
||||
const handleIncrease = useCallback(() => {
|
||||
if (quantity < maxQuantity && !isUpdating && !externalIsPending) {
|
||||
const newQuantity = quantity + 1;
|
||||
scheduleUpdate(newQuantity);
|
||||
}
|
||||
}, [quantity, maxQuantity, isUpdating, externalIsPending, scheduleUpdate]);
|
||||
|
||||
const handleDecrease = useCallback(() => {
|
||||
if (quantity > 0 && !isUpdating && !externalIsPending) {
|
||||
const newQuantity = quantity - 1;
|
||||
scheduleUpdate(newQuantity);
|
||||
}
|
||||
}, [quantity, isUpdating, externalIsPending, scheduleUpdate]);
|
||||
|
||||
const handleQuantityChange = useCallback(
|
||||
(newQuantity: number) => {
|
||||
if (
|
||||
newQuantity >= 0 &&
|
||||
newQuantity <= maxQuantity &&
|
||||
!isUpdating &&
|
||||
!externalIsPending
|
||||
) {
|
||||
scheduleUpdate(newQuantity);
|
||||
}
|
||||
},
|
||||
[maxQuantity, isUpdating, externalIsPending, scheduleUpdate]
|
||||
);
|
||||
|
||||
const isLoading = isUpdating || externalIsPending;
|
||||
|
||||
// Custom wrapper to show loading on entire component
|
||||
return (
|
||||
<div
|
||||
className={`relative ${isLoading ? "opacity-50 pointer-events-none" : ""}`}
|
||||
>
|
||||
<QuantityControl
|
||||
quantity={quantity}
|
||||
maxQuantity={maxQuantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
onRemove={onRemove}
|
||||
isPending={isLoading}
|
||||
allowManualEdit={allowManualEdit && !isLoading}
|
||||
onQuantityChange={handleQuantityChange}
|
||||
/>
|
||||
|
||||
{/* Loading overlay for entire component */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
83
app/components/explore/ExploreTopBar.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { memo, useCallback } from "react";
|
||||
import AppInput from "../AppInput";
|
||||
import { CircleX, Search } from "lucide-react";
|
||||
import { onSearch } from "~/utils/helpers";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface ExploreTopBarProps {
|
||||
setIsActiveSearchBar: (value: boolean) => void;
|
||||
searchValue: string;
|
||||
setSearchValue: (value: string) => void;
|
||||
}
|
||||
|
||||
const ExploreTopBar = memo(function ExploreTopBar({
|
||||
setIsActiveSearchBar,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
}: ExploreTopBarProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(e.target.value);
|
||||
}, [setSearchValue]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
onSearch(searchValue, navigate);
|
||||
}
|
||||
}, [searchValue, navigate]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsActiveSearchBar(true);
|
||||
}, [setIsActiveSearchBar]);
|
||||
|
||||
const handleSearchClick = useCallback(() => {
|
||||
onSearch(searchValue, navigate);
|
||||
}, [searchValue, navigate]);
|
||||
|
||||
const handleClearClick = useCallback(() => {
|
||||
setSearchValue("");
|
||||
}, [setSearchValue]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 px-4 w-full sticky top-0 z-30 bg-WHITE">
|
||||
<div className="flex items-center justify-center gap-2 rounded-[10px] w-full relative">
|
||||
<AppInput
|
||||
inputIcon={Search}
|
||||
inputDir="rtl"
|
||||
inputTitle=""
|
||||
className="bg-WHITE2 px-12 focus:border-blue-500"
|
||||
inputValue={searchValue}
|
||||
inputPlaceholder="جستجو..."
|
||||
inputOnChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
inputOnFocus={handleFocus}
|
||||
/>
|
||||
{searchValue && (
|
||||
<>
|
||||
<Button
|
||||
className="absolute transition-all duration-300 w-9 h-9 ease-in-out right-2 top-2 flex items-center justify-center cursor-pointer bg-blue-500 hover:bg-blue-200 rounded-lg"
|
||||
variant="primary"
|
||||
size="icon"
|
||||
onClick={handleSearchClick}
|
||||
>
|
||||
<Search className="w-4 h-4 text-WHITE" />
|
||||
</Button>
|
||||
<div
|
||||
onClick={handleClearClick}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleClearClick()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="absolute transition-all duration-300 ease-in-out left-2 top-3 w-6 h-6 flex items-center justify-center cursor-pointer hover:bg-gray-100 rounded-full p-1"
|
||||
>
|
||||
<CircleX className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ExploreTopBar;
|
||||
126
app/components/explore/MainSection.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { useProductsInfiniteQuery } from "../../requestHandler/use-explore-hooks";
|
||||
import { useGetHomeCategories } from "../../requestHandler/use-home-hooks";
|
||||
import TabContainer from "./TabContainer";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { Telescope } from "lucide-react";
|
||||
import MondrianProductList from "../MondrianProductList";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
const MainSection = () => {
|
||||
const tabs = ["دسته بندی ها", "همه"];
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// Use our custom infinite query hook
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
status,
|
||||
refetch,
|
||||
} = useProductsInfiniteQuery();
|
||||
|
||||
// Use the new categories hook
|
||||
const {
|
||||
data: categories,
|
||||
isLoading: categoriesLoading,
|
||||
isError: categoriesError,
|
||||
} = useGetHomeCategories();
|
||||
|
||||
// Flatten all products from all pages
|
||||
const allProducts = data?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
// Loading skeleton for categories
|
||||
const CategorySkeleton = () => (
|
||||
<div className="px-4 flex flex-col gap-6 w-full">
|
||||
<div className="grid grid-cols-2 gap-4 w-full">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="relative">
|
||||
<Skeleton className="w-full h-[120px] rounded-[15px]" />
|
||||
<Skeleton className="absolute bottom-2 left-2/4 -translate-x-1/2 w-20 h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="w-full h-16" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full pb-20">
|
||||
<TabContainer
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
tabs={tabs}
|
||||
/>
|
||||
{activeTab === 0 ? (
|
||||
categoriesLoading ? (
|
||||
<CategorySkeleton />
|
||||
) : categoriesError ? (
|
||||
<div className="px-4 flex flex-col items-center gap-4 py-8">
|
||||
<Telescope size={80} className="text-GRAY" />
|
||||
<p className="text-R14 text-GRAY text-center">
|
||||
خطا در بارگذاری دسته بندی ها
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 flex flex-col gap-6 w-full">
|
||||
<div className="grid grid-cols-2 gap-4 w-full">
|
||||
{categories?.map((category, index) => {
|
||||
return (
|
||||
<Link
|
||||
className="flex relative w-full cursor-pointer items-center gap-2"
|
||||
key={category.id || index}
|
||||
to={`/search/${category.categoryName}`}
|
||||
>
|
||||
<img
|
||||
src={category.imageUrl}
|
||||
className="w-full h-[120px] object-cover rounded-[15px]"
|
||||
alt={category.categoryName}
|
||||
/>
|
||||
<p className="text-B16 text-center line-clamp-1 w-full font-bold text-white absolute bottom-2 left-2/4 -translate-x-1/2">
|
||||
{category.categoryName}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-R14 text-GRAY">
|
||||
در ویترون، پوشاک تنها یک انتخاب نیست، بلکه تجربه ای از سبک و کیفیت
|
||||
است. هر دسته بندی با دقت طراحی شده تا شما را در مسیر ساخت استایل
|
||||
شخصی و متمایزتان همراهی کند.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<MondrianProductList
|
||||
products={allProducts.map((product) => {
|
||||
return {
|
||||
id: product.id,
|
||||
title: product.title,
|
||||
images: product.imageUrls || [],
|
||||
discountPercent: product.discountPercent || 0,
|
||||
discountPrice: product.discountPrice || 0,
|
||||
basePrice: product.basePrice
|
||||
? parseInt(product.basePrice)
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isLoading={status === "pending"}
|
||||
isError={status === "error"}
|
||||
refetch={refetch}
|
||||
emptyState={{
|
||||
image: <Telescope size={80} />,
|
||||
title: "محصولی در اکسپلور یافت نشد",
|
||||
description:
|
||||
"فکر میکنم بهتر باشه یه وقت دیگه تست کنید یا اینکه چیزی جدید بیاید.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default MainSection;
|
||||
248
app/components/explore/SearchSection.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import { useEffect, useState, memo, useMemo } from "react";
|
||||
import TabContainer from "./TabContainer";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { onSearch } from "../../utils/helpers";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { useSearchAutocomplete } from "../../requestHandler/use-search-hooks";
|
||||
import { AutocompleteSuggestionTypeEnum } from "../../../src/api/types";
|
||||
|
||||
const SearchSection = memo(function SearchSection({
|
||||
searchValue,
|
||||
}: {
|
||||
searchValue: string;
|
||||
}) {
|
||||
const tabs = useMemo(() => ["همه نتایج", "محصولات", "فروشگاهها"], []);
|
||||
const [activeSearchTab, setActiveSearchTab] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<TabContainer
|
||||
activeTab={activeSearchTab}
|
||||
setActiveTab={setActiveSearchTab}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<div className="px-4 flex flex-col gap-6 w-full">
|
||||
{searchValue ? (
|
||||
<SearchSuggestions
|
||||
activeSearchTab={activeSearchTab}
|
||||
searchValue={searchValue}
|
||||
/>
|
||||
) : (
|
||||
<SearchHistorySection />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const SearchSuggestions = memo(function SearchSuggestions({
|
||||
activeSearchTab,
|
||||
searchValue,
|
||||
}: {
|
||||
activeSearchTab: number;
|
||||
searchValue: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const debouncedSearchValue = useDebounce(searchValue, 500); // Reduced from 800ms to 500ms
|
||||
const { data: autocompleteData, isLoading } =
|
||||
useSearchAutocomplete(debouncedSearchValue);
|
||||
|
||||
// Memoize filtered suggestions to avoid recalculation on every render
|
||||
const filterSuggestions = useMemo(() => {
|
||||
if (!autocompleteData?.suggestions) return [];
|
||||
|
||||
return autocompleteData.suggestions
|
||||
.filter((sug) => {
|
||||
return (
|
||||
activeSearchTab === 0 ||
|
||||
(sug.type === AutocompleteSuggestionTypeEnum.Product &&
|
||||
activeSearchTab === 1) ||
|
||||
(sug.type === AutocompleteSuggestionTypeEnum.Seller &&
|
||||
activeSearchTab === 2)
|
||||
);
|
||||
})
|
||||
.map((sug) => ({
|
||||
...sug,
|
||||
text:
|
||||
sug.type === AutocompleteSuggestionTypeEnum.Product
|
||||
? sug.text
|
||||
: sug.name,
|
||||
}));
|
||||
}, [autocompleteData?.suggestions, activeSearchTab]);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex w-full h-[44px] justify-center items-center">
|
||||
<p className="text-B12 text-GRAY">در حال جستجو...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{filterSuggestions.length > 0 ? (
|
||||
<>
|
||||
{filterSuggestions.map((sug, index) => {
|
||||
const matchIndex = sug.text
|
||||
? sug.text
|
||||
.toLowerCase()
|
||||
.indexOf(searchValue ? searchValue.toLowerCase() : "")
|
||||
: -1;
|
||||
const beforeMatch =
|
||||
matchIndex >= 0 ? sug.text?.substring(0, matchIndex) : "";
|
||||
const matchText =
|
||||
matchIndex >= 0
|
||||
? sug.text?.substring(
|
||||
matchIndex,
|
||||
matchIndex + searchValue.length
|
||||
)
|
||||
: sug.text;
|
||||
const afterMatch =
|
||||
matchIndex >= 0
|
||||
? sug.text?.substring(matchIndex + searchValue.length)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
role="button"
|
||||
tabIndex={index}
|
||||
onKeyDown={() => {}}
|
||||
className="flex w-full h-[44px] justify-between items-center cursor-pointer hover:bg-WHITE2"
|
||||
>
|
||||
{sug.type === AutocompleteSuggestionTypeEnum.Product ? (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (sug.text) {
|
||||
onSearch(sug.text, navigate);
|
||||
}
|
||||
}}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={index}
|
||||
className="flex w-full items-center gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4 text-BLACK2" />
|
||||
<p className="text-B12 font-bold">
|
||||
<span className="text-GRAY">{beforeMatch}</span>
|
||||
<span className="text-BLACK2">{matchText}</span>
|
||||
<span className="text-GRAY">{afterMatch}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (sug.id) {
|
||||
navigate(`/seller/${sug.id}`);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2"
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={index}
|
||||
>
|
||||
<Search className="w-4 h-4 text-BLACK2" />
|
||||
<p className="text-B12 font-bold">
|
||||
<span className="text-GRAY">{beforeMatch}</span>
|
||||
<span className="text-BLACK2">{matchText}</span>
|
||||
<span className="text-GRAY">{afterMatch}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
// Empty state
|
||||
<div
|
||||
onClick={() => {
|
||||
onSearch(searchValue, navigate);
|
||||
}}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex w-full items-center gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4 text-BLACK2" />
|
||||
<p className="text-B12 font-bold">{searchValue}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const SearchHistorySection = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const history = localStorage.getItem("searchValues");
|
||||
if (history) {
|
||||
setSearchHistory(history.split("&&"));
|
||||
}
|
||||
}, []);
|
||||
const deleteSearchHistory = (searchValue: string) => {
|
||||
localStorage.setItem(
|
||||
"searchValues",
|
||||
searchHistory.filter((value) => value !== searchValue).join("&&") || ""
|
||||
);
|
||||
setSearchHistory(searchHistory.filter((value) => value !== searchValue));
|
||||
};
|
||||
const deleteAllSearchHistory = () => {
|
||||
localStorage.removeItem("searchValues");
|
||||
setSearchHistory([]);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{searchHistory.length > 0 ? (
|
||||
<>
|
||||
<div className="flex w-full h-[44px] justify-between items-center">
|
||||
<p className="text-B12 font-bold">تاریخچه جستجو</p>
|
||||
{searchHistory.length > 0 && (
|
||||
<button
|
||||
onClick={deleteAllSearchHistory}
|
||||
className="text-R10 text-BLUE hover:underline cursor-pointer"
|
||||
>
|
||||
حذف همه
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{searchHistory.map((searchValue, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
role="button"
|
||||
tabIndex={index}
|
||||
onKeyDown={() => {}}
|
||||
className="flex w-full h-[44px] justify-between items-center"
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
onSearch(searchValue, navigate);
|
||||
}}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={index}
|
||||
className="flex w-full items-center gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4 text-BLACK2" />
|
||||
<p className="text-B12 font-bold text-BLACK2">
|
||||
{searchValue}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteSearchHistory(searchValue);
|
||||
}}
|
||||
className="hover:underline cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4 text-BLACK2" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : // Empty state
|
||||
null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SearchSection;
|
||||
35
app/components/explore/TabContainer.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
const TabContainer = ({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
tabs,
|
||||
}: {
|
||||
activeTab: number;
|
||||
setActiveTab: (value: number) => void;
|
||||
tabs: string[];
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full flex gap-8 justify-center pb-[2px] border-b mt-2">
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => setActiveTab(index)}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={index}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<p
|
||||
className={`text-B12 border-b-[2px] font-bold px-2 transition-all duration-300 ease-in-out ${
|
||||
index === activeTab
|
||||
? "text-BLACK border-BLACK"
|
||||
: "text-GRAY border-transparent"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default TabContainer;
|
||||
108
app/components/home/HomePageTopBar.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { useState, useEffect, useMemo, memo, useCallback } from "react";
|
||||
import logo from "../../assets/logo/SVG-07.svg";
|
||||
|
||||
const HomePageTopBar = memo(() => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const tabs = useMemo(() => ["تخفیفها", "برترین فروشگاهها", "برای شما"], []);
|
||||
|
||||
// Scroll to section when tab is clicked
|
||||
const scrollToSection = useCallback((sectionId: string) => {
|
||||
const section = document.getElementById(sectionId);
|
||||
if (section) {
|
||||
section.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update active tab based on scroll position with throttling
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
const handleScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const section1 = document.getElementById("section1");
|
||||
const section2 = document.getElementById("section2");
|
||||
const section3 = document.getElementById("section3");
|
||||
|
||||
if (section1 && section2 && section3) {
|
||||
const scrollPosition = window.scrollY;
|
||||
|
||||
if (scrollPosition >= section3.offsetTop - 100) {
|
||||
setActiveTab(2);
|
||||
} else if (scrollPosition >= section2.offsetTop - 100) {
|
||||
setActiveTab(1);
|
||||
} else {
|
||||
setActiveTab(0);
|
||||
}
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const handleTabClick = useCallback(
|
||||
(index: number) => {
|
||||
setActiveTab(index);
|
||||
scrollToSection(`section${index + 1}`);
|
||||
},
|
||||
[scrollToSection]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 flex-row h-[48px] w-full bg-WHITE items-center px-4 sticky top-0 z-30">
|
||||
<div className="flex relative items-center gap-2 justify-center w-full">
|
||||
<div
|
||||
className="absolute right-0"
|
||||
onClick={() => scrollToTop()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
scrollToTop();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src={logo} className="w-8 h-8 object-contain" alt="logo" />
|
||||
</div>
|
||||
<div className="flex gap-4 justify-center">
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleTabClick(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleTabClick(index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={index}
|
||||
className="flex items-center"
|
||||
>
|
||||
<p
|
||||
className={`text-B12 border-b-[2px] transition-all duration-300 ease-in-out py-1 ${
|
||||
index === activeTab
|
||||
? "text-BLACK border-BLACK font-bold"
|
||||
: "text-GRAY border-transparent"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
HomePageTopBar.displayName = "HomePageTopBar";
|
||||
|
||||
export default HomePageTopBar;
|
||||
166
app/components/home/InvoiceModal.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { useNavigate, useSearchParams } from "@remix-run/react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
} from "../../components/ui/dialog";
|
||||
import { useGetInvoiceAndShippingInfo } from "../../requestHandler/use-order-hooks";
|
||||
import { InvoiceContent } from "../../components/cart/ReceiverInformation";
|
||||
import sucess_pay from "../../assets/icons/cart/sucess_pay.svg";
|
||||
import failure_pay from "../../assets/icons/cart/failure_pay.svg";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { CartItem } from "src/api/types";
|
||||
|
||||
interface InvoiceModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const InvoiceModal = ({ open, onOpenChange }: InvoiceModalProps) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const invoiceNumber = searchParams.get("invoice") || "";
|
||||
const shippingInfoId = searchParams.get("shippinginfo") || "";
|
||||
const successPay = searchParams.get("status") === "success";
|
||||
const {
|
||||
data: invoiceData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useGetInvoiceAndShippingInfo(invoiceNumber, shippingInfoId);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open);
|
||||
if (!open) {
|
||||
navigate("/");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogOverlay className="bg-BLACK/10" />
|
||||
<DialogContent
|
||||
className={`p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)]`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 items-center w-full">
|
||||
{successPay ? (
|
||||
<>
|
||||
<SuccessPay />
|
||||
{isLoading ? (
|
||||
<p className="text-sm">در حال بارگزاری...</p>
|
||||
) : isError ? (
|
||||
<div className="text-red-500">
|
||||
<p className="text-sm">خطا در دریافت اطلاعات</p>
|
||||
<p className="text-xs">{(error as Error)?.message}</p>
|
||||
</div>
|
||||
) : invoiceData ? (
|
||||
<InvoiceContent
|
||||
discountValue={
|
||||
Number(invoiceData.invoice.discountValue) || null
|
||||
}
|
||||
selectedCourierInfo={{
|
||||
estimatedDays:
|
||||
invoiceData.shippingInfo.estimatedDeliveryDays,
|
||||
deliveryPrice: Number(
|
||||
invoiceData.shippingInfo.deliveryPrice
|
||||
),
|
||||
seller: "",
|
||||
courier: "",
|
||||
}}
|
||||
total={invoiceData.invoice.totalAmount || ""}
|
||||
selectedAddressInfo={{
|
||||
postalCode: invoiceData.shippingInfo.postalCode || "",
|
||||
mainAddress: invoiceData.shippingInfo.mainAddress || "",
|
||||
title: "",
|
||||
receiverName: "",
|
||||
phoneNumber: "",
|
||||
}}
|
||||
cartItems={
|
||||
invoiceData.invoice.items?.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
productVariant: item.variant,
|
||||
priceAtPurchase: item.unitPrice,
|
||||
} as CartItem;
|
||||
}) || []
|
||||
} //
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">اطلاعاتی یافت نشد</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-full bg-VITROWN_BLUE text-white"
|
||||
size={"lg"}
|
||||
variant="dark"
|
||||
onClick={() => {
|
||||
navigate("/");
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
تایید
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<FailurePay onOpenChange={onOpenChange} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const SuccessPay = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center w-full px-4 border-b py-6">
|
||||
<div className="flex flex-col gap-4 items-center w-full px-2">
|
||||
<img
|
||||
src={sucess_pay}
|
||||
alt="invoice"
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
<p className="text-B16 text-center font-bold">سفارش با موفقیت ثبت شد</p>
|
||||
<p className="text-R12 text-center">
|
||||
وضعیت سفارش و موجودی کیف پول خود را در قسمت پروفایل مشاهده کنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const FailurePay = ({
|
||||
onOpenChange,
|
||||
}: {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center w-full px-4 py-6">
|
||||
<div className="flex flex-col gap-4 items-center w-full px-2">
|
||||
<img
|
||||
src={failure_pay}
|
||||
alt="invoice"
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
<p className="text-B16 text-center font-bold">
|
||||
پرداخت با خطا مواجه شد!
|
||||
</p>
|
||||
<p className="text-R12 text-center">
|
||||
در صورت کسر وجه، مبلغ حداکثر تا ۷۲ ساعت آینده به حساب شما بازگردانده
|
||||
خواهد شد.
|
||||
</p>
|
||||
<Button
|
||||
size={"lg"}
|
||||
variant="dark"
|
||||
className="bg-VITROWN_BLUE text-white"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
متوجه شدم
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoiceModal;
|
||||
223
app/components/product/BottomBar.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { ShoppingCart, X, Check } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import discountRed from "~/assets/icons/product/discount-red.svg";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "~/components/ui/drawer";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useAddToCart } from "../../requestHandler/use-cart-hooks";
|
||||
import { findVariantId, formatPrice } from "../../utils/helpers";
|
||||
import { ProductDetail } from "src/api/types";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
import { LoginRequiredDialog } from "../LoginRequiredDialog";
|
||||
|
||||
interface BottomBarProps {
|
||||
product: ProductDetail;
|
||||
selectedColor: string | null;
|
||||
selectedSize: string | null;
|
||||
currentPrice: string;
|
||||
currentDiscountPrice: string | null;
|
||||
currentStock: number | null;
|
||||
}
|
||||
|
||||
interface PriceDisplayProps {
|
||||
currentPrice: string;
|
||||
currentDiscountPrice: string | null;
|
||||
}
|
||||
|
||||
const PriceDisplay = ({
|
||||
currentPrice,
|
||||
currentDiscountPrice,
|
||||
}: PriceDisplayProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{currentDiscountPrice && (
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-GRAY line-through decoration-RED decoration-2 text-B14">
|
||||
{formatPrice(currentPrice)}
|
||||
</p>
|
||||
<img src={discountRed} alt="discount-red" className="w-6 h-6" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-B14 font-bold">
|
||||
{formatPrice(currentDiscountPrice || currentPrice)}
|
||||
</p>
|
||||
<p className="text-B14 font-bold">تومان</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AddToCartButtonProps {
|
||||
isOutOfStock: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
|
||||
<Button
|
||||
variant="dark"
|
||||
size="lg"
|
||||
className="font-bold flex items-center gap-2 bg-VITROWN_BLUE text-white"
|
||||
disabled={isOutOfStock}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isOutOfStock ? (
|
||||
<span className="text-B14 font-bold">ناموجود</span>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingCart size={24} />
|
||||
افزودن به سبد خرید
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
interface CartDrawerProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const CartDrawer = ({ isOpen, onOpenChange }: CartDrawerProps) => (
|
||||
<Drawer open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="flex flex-col py-3">
|
||||
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
|
||||
<X
|
||||
className="absolute right-2 cursor-pointer"
|
||||
size={24}
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<DrawerTitle className="text-B12 font-bold">سبد خرید</DrawerTitle>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<Check className="text-green-500" size={48} />
|
||||
<p className="text-B14 font-bold mr-2">
|
||||
محصول به سبد خرید اضافه شد!
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<Link to="/cart" className="w-full">
|
||||
<Button
|
||||
variant="dark"
|
||||
size="lg"
|
||||
className="w-full font-bold"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
مشاهده سبد خرید
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full font-bold"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
ادامه خرید
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
export const BottomBar = ({
|
||||
product,
|
||||
selectedColor,
|
||||
selectedSize,
|
||||
currentPrice,
|
||||
currentDiscountPrice,
|
||||
currentStock,
|
||||
}: BottomBarProps) => {
|
||||
const { toast } = useToast();
|
||||
const { user } = useRootData();
|
||||
const [isCartDrawerOpen, setIsCartDrawerOpen] = useState(false);
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
|
||||
// Use the addToCart mutation hook
|
||||
const addToCartMutation = useAddToCart();
|
||||
|
||||
const isOutOfStock = currentStock === null || currentStock <= 0;
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
// Check if user is logged in
|
||||
if (!user?.access_token) {
|
||||
setIsLoginModalOpen(true);
|
||||
return;
|
||||
}
|
||||
if (currentStock !== null && currentStock > 0) {
|
||||
// Find the variant ID based on selected color and size
|
||||
const variantId = findVariantId(product, selectedColor, selectedSize);
|
||||
|
||||
if (!variantId) {
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "محصول با ویژگیهای انتخابی شما یافت نشد",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add to cart using React Query mutation
|
||||
await addToCartMutation.mutateAsync({
|
||||
variantId,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "سبد خرید",
|
||||
description: "محصول با موفقیت به سبد خرید اضافه شد",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
setIsCartDrawerOpen(true);
|
||||
} catch (error) {
|
||||
console.error("Error adding to cart:", error);
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "افزودن محصول به سبد خرید با مشکل مواجه شد",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-md mx-auto fixed gap-2 bottom-[calc(50px+env(safe-area-inset-bottom))] left-0 right-0 border-t border-inner-border bg-WHITE p-4 z-50 flex flex-row">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<AddToCartButton
|
||||
isOutOfStock={isOutOfStock}
|
||||
onClick={handleAddToCart}
|
||||
/>
|
||||
</div>
|
||||
<PriceDisplay
|
||||
currentPrice={currentPrice}
|
||||
currentDiscountPrice={currentDiscountPrice}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CartDrawer
|
||||
isOpen={isCartDrawerOpen}
|
||||
onOpenChange={setIsCartDrawerOpen}
|
||||
/>
|
||||
<LoginRequiredDialog
|
||||
loginTitle="برای افزودن محصول به سبد خرید ابتدا باید وارد حساب کاربری خود شوید."
|
||||
isOpen={isLoginModalOpen}
|
||||
onOpenChange={setIsLoginModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
38
app/components/product/ColorSelector.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface ColorSelectorProps {
|
||||
colors: string[] | undefined;
|
||||
selectedColor: string | null;
|
||||
setSelectedColor: (color: string) => void;
|
||||
}
|
||||
|
||||
export const ColorSelector = ({
|
||||
colors,
|
||||
selectedColor,
|
||||
setSelectedColor,
|
||||
}: ColorSelectorProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-10 px-4">
|
||||
<p className="text-B18 font-bold">انتخاب رنگ</p>
|
||||
<div className="gap-4 flex">
|
||||
{colors?.map((enColor: string, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="w-10 border h-10 rounded-md flex items-center justify-center"
|
||||
style={{ backgroundColor: enColor }}
|
||||
onClick={() => setSelectedColor(enColor)}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{selectedColor === enColor && (
|
||||
<Check size={24} className="text-WHITE" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
app/components/product/ContactButton.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
import { ProductDetail } from "src/api/types";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useCreateChatThread } from "~/requestHandler/use-chat-hooks";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
import { useState } from "react";
|
||||
import { LoginRequiredDialog } from "~/components/LoginRequiredDialog";
|
||||
|
||||
export const ContactButton = ({ product }: { product: ProductDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useRootData();
|
||||
const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false);
|
||||
const { mutate: createChatThread, isPending: isCreatingChatThread } =
|
||||
useCreateChatThread();
|
||||
const handleCreateChatThread = () => {
|
||||
if (!user) {
|
||||
setIsLoginDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!product.seller?.id) return;
|
||||
createChatThread(product.seller.id, {
|
||||
onSuccess: (threadData) => {
|
||||
navigate(`/profile/threads/${threadData.id}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="mt-10 px-4 pb-4 w-full">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateChatThread}
|
||||
disabled={!product.seller?.id || isCreatingChatThread}
|
||||
size="lg"
|
||||
className="w-full font-bold"
|
||||
>
|
||||
<MessageCircleMore size={24} />
|
||||
{isCreatingChatThread ? "در حال اتصال..." : "گفتگو با فروشنده"}
|
||||
</Button>
|
||||
</div>
|
||||
<LoginRequiredDialog
|
||||
loginTitle="برای ارسال پیام به فروشنده ابتدا باید وارد حساب کاربری خود شوید."
|
||||
isOpen={isLoginDialogOpen}
|
||||
onOpenChange={setIsLoginDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
57
app/components/product/InactiveProductView.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { EyeOff, Home, Search } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
|
||||
export const InactiveProductView = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-4 bg-WHITE">
|
||||
<div className="flex flex-col items-center gap-6 max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="w-24 h-24 rounded-full bg-WHITE3 flex items-center justify-center">
|
||||
<EyeOff size={48} className="text-BLACK2" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-B24 font-bold text-BLACK">محصول غیرفعال است</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-R16 text-BLACK2 leading-relaxed">
|
||||
این محصول در حال حاضر توسط فروشنده غیرفعال شده است و امکان خرید آن
|
||||
وجود ندارد.
|
||||
</p>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="w-full p-4 bg-WHITE3 rounded-lg">
|
||||
<p className="text-R14 text-BLACK2">
|
||||
ممکن است این محصول موقتاً موجود نباشد یا فروشنده آن را از فروش خارج
|
||||
کرده باشد.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3 w-full mt-4">
|
||||
<Button
|
||||
variant="dark"
|
||||
size="lg"
|
||||
className="w-full font-bold"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<Home size={20} />
|
||||
بازگشت به صفحه اصلی
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full font-bold"
|
||||
onClick={() => navigate("/explore")}
|
||||
>
|
||||
<Search size={20} />
|
||||
جستجوی محصولات دیگر
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
336
app/components/product/ProductActions.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
import {
|
||||
BookmarkMinus,
|
||||
Bookmark,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Share2,
|
||||
Star,
|
||||
X,
|
||||
Loader2,
|
||||
Copy,
|
||||
Layers,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "~/components/ui/drawer";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import {
|
||||
useServiceGetProductRating,
|
||||
useServiceGetComments,
|
||||
useServiceBookmarkProduct,
|
||||
useServiceDeleteBookmark,
|
||||
useServiceCheckBookmark,
|
||||
} from "~/utils/RequestHandler";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
import { LoginRequiredDialog } from "../LoginRequiredDialog";
|
||||
import { AddToSetDrawer } from "../sets";
|
||||
import AIPromoteModal from "../AIPromoteModal";
|
||||
|
||||
export const ProductActions = ({ productId }: { productId: string }) => {
|
||||
const [isShareDrawerOpen, setIsShareDrawerOpen] = useState(false);
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const [isAddToSetDrawerOpen, setIsAddToSetDrawerOpen] = useState(false);
|
||||
const [isAITryOnModalOpen, setIsAITryOnModalOpen] = useState(false);
|
||||
const [showAIFeatureTooltip, setShowAIFeatureTooltip] = useState(false);
|
||||
|
||||
// Show AI feature tooltip on first visit
|
||||
useEffect(() => {
|
||||
const hasSeenAIFeature = localStorage.getItem("hasSeenAITryOnFeature");
|
||||
if (!hasSeenAIFeature) {
|
||||
setShowAIFeatureTooltip(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismissAITooltip = () => {
|
||||
setShowAIFeatureTooltip(false);
|
||||
localStorage.setItem("hasSeenAITryOnFeature", "true");
|
||||
};
|
||||
|
||||
const handleOpenAIModal = () => {
|
||||
handleDismissAITooltip();
|
||||
setIsAITryOnModalOpen(true);
|
||||
};
|
||||
const [loginDialogMessage, setLoginDialogMessage] = useState(
|
||||
"برای افزودن محصول به علاقهمندیها ابتدا باید وارد حساب کاربری خود شوید."
|
||||
);
|
||||
const { toast } = useToast();
|
||||
const { user } = useRootData();
|
||||
|
||||
const { data: ratingData, isLoading: ratingLoading } =
|
||||
useServiceGetProductRating(productId || "");
|
||||
const { data: commentsData } = useServiceGetComments(1, productId || "");
|
||||
const { data: bookmarkData, isLoading: isCheckingBookmark } =
|
||||
useServiceCheckBookmark(productId || "");
|
||||
const { mutate: bookmarkProduct, isPending: isBookmarking } =
|
||||
useServiceBookmarkProduct();
|
||||
const { mutate: deleteBookmark, isPending: isDeletingBookmark } =
|
||||
useServiceDeleteBookmark();
|
||||
|
||||
const isBookmarked = bookmarkData?.bookmarked === true;
|
||||
const averageRating = ratingData?.averageRating || 0;
|
||||
const commentCount = commentsData?.count || 0;
|
||||
|
||||
const handleBookmark = () => {
|
||||
// Check if user is logged in
|
||||
if (!user?.access_token) {
|
||||
setIsLoginModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBookmarked) {
|
||||
deleteBookmark(productId, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "محصول از علاقهمندیها حذف شد",
|
||||
variant: "default",
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "عملیات با خطا مواجه شد. لطفا دوباره تلاش کنید.",
|
||||
variant: "destructive",
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
bookmarkProduct(productId, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "محصول به علاقهمندیها اضافه شد",
|
||||
variant: "default",
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "عملیات با خطا مواجه شد. لطفا دوباره تلاش کنید.",
|
||||
variant: "destructive",
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToSet = () => {
|
||||
// Check if user is logged in
|
||||
if (!user?.access_token) {
|
||||
setLoginDialogMessage(
|
||||
"برای افزودن محصول به ست ابتدا باید وارد حساب کاربری خود شوید."
|
||||
);
|
||||
setIsLoginModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setIsAddToSetDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleShare = (platform: string) => {
|
||||
const url = window.location.href;
|
||||
let shareUrl = "";
|
||||
|
||||
if (platform === "telegram") {
|
||||
shareUrl = `https://t.me/share/url?url=${encodeURIComponent(url)}`;
|
||||
} else if (platform === "whatsapp") {
|
||||
shareUrl = `https://api.whatsapp.com/send?text=${encodeURIComponent(url)}`;
|
||||
} else if (platform === "copy") {
|
||||
navigator.clipboard.writeText(url);
|
||||
toast({
|
||||
title: "لینک کپی شد",
|
||||
description: "لینک محصول در کلیپبورد کپی شد.",
|
||||
variant: "default",
|
||||
duration: 2000,
|
||||
});
|
||||
setIsShareDrawerOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareUrl) {
|
||||
window.open(shareUrl, "_blank");
|
||||
setIsShareDrawerOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full mt-4 px-4 justify-between">
|
||||
<div className="flex gap-8">
|
||||
{isCheckingBookmark ? (
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
) : isBookmarked ? (
|
||||
<>
|
||||
{isDeletingBookmark ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Bookmark
|
||||
size={20}
|
||||
className="text-PRIMARY fill-current cursor-pointer"
|
||||
onClick={handleBookmark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isBookmarking ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<BookmarkMinus
|
||||
size={20}
|
||||
className="cursor-pointer"
|
||||
onClick={handleBookmark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Share2
|
||||
size={20}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsShareDrawerOpen(true)}
|
||||
/>
|
||||
<Layers
|
||||
size={20}
|
||||
className="cursor-pointer"
|
||||
onClick={handleAddToSet}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Sparkles
|
||||
size={20}
|
||||
className="cursor-pointer animate-pulse text-PRIMARY"
|
||||
onClick={handleOpenAIModal}
|
||||
/>
|
||||
{showAIFeatureTooltip && (
|
||||
<div className="absolute bottom-full right-0 mb-2 z-50 animate-bounce">
|
||||
<div className="relative bg-gradient-to-r from-purple-600 to-pink-500 text-WHITE px-3 py-2 rounded-lg shadow-xl whitespace-nowrap border border-white/20">
|
||||
<button
|
||||
onClick={handleDismissAITooltip}
|
||||
className="absolute -top-1 -right-1 bg-WHITE text-black rounded-full w-4 h-4 flex items-center justify-center text-xs font-bold shadow-md"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={14} />
|
||||
<span className="text-R12 font-bold">
|
||||
پرو با هوش مصنوعی!
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-R10 opacity-90 mt-0.5">
|
||||
این محصول رو تو تن خودت ببین
|
||||
</p>
|
||||
{/* Arrow */}
|
||||
<div className="absolute top-full right-1 w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-pink-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex gap-1 items-center">
|
||||
{!ratingLoading && averageRating > 0 && (
|
||||
<>
|
||||
<p className="text-B12 font-bold text-GRAY">({commentCount})</p>
|
||||
<p className="text-B12 font-bold">{averageRating.toFixed(1)}</p>
|
||||
<Star className="text-BLACK fill-current" size={20} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer open={isShareDrawerOpen} onOpenChange={setIsShareDrawerOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="flex flex-col">
|
||||
<div className="flex relative items-center justify-center w-full">
|
||||
<X
|
||||
className="absolute right-0 cursor-pointer hover:bg-BLACK/5 rounded-full p-1 transition-colors"
|
||||
size={28}
|
||||
onClick={() => setIsShareDrawerOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<DrawerTitle className="text-B16 font-bold">
|
||||
اشتراکگذاری محصول
|
||||
</DrawerTitle>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-4 flex flex-col gap-3">
|
||||
<p className="text-R12 text-GRAY text-center mb-2">
|
||||
این محصول را با دوستان خود به اشتراک بگذارید
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-[#0088cc]/10 hover:bg-[#0088cc]/20 transition-colors"
|
||||
onClick={() => handleShare("telegram")}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full bg-[#0088cc] flex items-center justify-center">
|
||||
<Send size={20} className="text-WHITE" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start flex-1">
|
||||
<span className="text-B14 font-bold text-BLACK">تلگرام</span>
|
||||
<span className="text-R12 text-GRAY">
|
||||
ارسال به مخاطبین تلگرام
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-[#25D366]/10 hover:bg-[#25D366]/20 transition-colors"
|
||||
onClick={() => handleShare("whatsapp")}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full bg-[#25D366] flex items-center justify-center">
|
||||
<MessageSquare size={20} className="text-WHITE" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start flex-1">
|
||||
<span className="text-B14 font-bold text-BLACK">واتساپ</span>
|
||||
<span className="text-R12 text-GRAY">
|
||||
ارسال به مخاطبین واتساپ
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-BLACK/5 hover:bg-BLACK/10 transition-colors"
|
||||
onClick={() => handleShare("copy")}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full bg-BLACK flex items-center justify-center">
|
||||
<Copy size={20} className="text-WHITE" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start flex-1">
|
||||
<span className="text-B14 font-bold text-BLACK">کپی لینک</span>
|
||||
<span className="text-R12 text-GRAY">
|
||||
کپی لینک در کلیپبورد
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<LoginRequiredDialog
|
||||
loginTitle={loginDialogMessage}
|
||||
isOpen={isLoginModalOpen}
|
||||
onOpenChange={setIsLoginModalOpen}
|
||||
/>
|
||||
|
||||
<AddToSetDrawer
|
||||
isOpen={isAddToSetDrawerOpen}
|
||||
onOpenChange={setIsAddToSetDrawerOpen}
|
||||
productId={productId}
|
||||
/>
|
||||
|
||||
<AIPromoteModal
|
||||
isOpen={isAITryOnModalOpen}
|
||||
onClose={() => setIsAITryOnModalOpen(false)}
|
||||
productId={productId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
347
app/components/product/ProductDetailView.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import { useState, useEffect, useMemo, memo } from "react";
|
||||
import {
|
||||
ProductHeader,
|
||||
ProductImageCarousel,
|
||||
ProductActions,
|
||||
ProductInfo,
|
||||
ColorSelector,
|
||||
SizeSelector,
|
||||
ContactButton,
|
||||
ReviewSection,
|
||||
BottomBar,
|
||||
} from "./index";
|
||||
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks";
|
||||
import MondrianProductList from "../MondrianProductList";
|
||||
|
||||
interface ProductDetailViewProps {
|
||||
product: ProductDetailType;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export const ProductDetailView = memo(function ProductDetailView({
|
||||
product,
|
||||
isOwner,
|
||||
}: ProductDetailViewProps) {
|
||||
// Local state for product active status (to handle real-time updates)
|
||||
const [productActiveStatus, setProductActiveStatus] = useState(
|
||||
product.isActive || false
|
||||
);
|
||||
|
||||
const [isChangeStatusDialogOpen, setIsChangeStatusDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
// Fetch similar products with infinite scroll
|
||||
const {
|
||||
data: similarProductsData,
|
||||
isLoading: isLoadingSimilar,
|
||||
isError: isErrorSimilar,
|
||||
refetch: refetchSimilar,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useSimilarProductsInfinite(product.id || "", 20);
|
||||
|
||||
// Sync local state when product prop changes (e.g., on navigation)
|
||||
useEffect(() => {
|
||||
setProductActiveStatus(product.isActive || false);
|
||||
}, [product.isActive]);
|
||||
|
||||
// Memoize available colors and sizes
|
||||
const { availableColors, availableSizes } = useMemo(
|
||||
() => ({
|
||||
availableColors:
|
||||
product.attributes?.COLOR?.filter((p) => p.value !== "UNSELECTED") ||
|
||||
[],
|
||||
availableSizes:
|
||||
product.attributes?.SIZE?.filter((p) => p.value !== "UNSELECTED") || [],
|
||||
}),
|
||||
[product.attributes]
|
||||
);
|
||||
|
||||
// Get variants with stock
|
||||
const stockedVariants = useMemo(
|
||||
() =>
|
||||
product.variants?.filter(
|
||||
(variant) => variant.stock && variant.stock > 0
|
||||
) || [],
|
||||
[product.variants]
|
||||
);
|
||||
|
||||
// Memoize in-stock colors and sizes
|
||||
const { inStockColors, inStockSizes } = useMemo(() => {
|
||||
const colors = availableColors.filter((color) => {
|
||||
return stockedVariants.some(
|
||||
(variant) => variant.resolvedAttributes?.COLOR === color.value
|
||||
);
|
||||
});
|
||||
|
||||
const sizes = availableSizes.filter((size) => {
|
||||
return stockedVariants.some(
|
||||
(variant) => variant.resolvedAttributes?.SIZE === size.value
|
||||
);
|
||||
});
|
||||
|
||||
return { inStockColors: colors, inStockSizes: sizes };
|
||||
}, [availableColors, availableSizes, stockedVariants]);
|
||||
|
||||
// Memoize stock checks
|
||||
const { hasAnyStock, hasColorVariants, hasSizeVariants } = useMemo(
|
||||
() => ({
|
||||
hasAnyStock: stockedVariants.length > 0,
|
||||
hasColorVariants: inStockColors.length > 0,
|
||||
hasSizeVariants: inStockSizes.length > 0,
|
||||
}),
|
||||
[stockedVariants.length, inStockColors.length, inStockSizes.length]
|
||||
);
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(
|
||||
hasColorVariants && inStockColors.length > 0
|
||||
? (inStockColors[0].info as unknown as { detail: string })?.detail
|
||||
: null
|
||||
);
|
||||
|
||||
const [selectedSize, setSelectedSize] = useState<string | null>(
|
||||
hasSizeVariants && inStockSizes.length > 0 ? inStockSizes[0].value : null
|
||||
);
|
||||
|
||||
// Handle product status change
|
||||
const handleProductStatusChange = (newStatus: boolean) => {
|
||||
setProductActiveStatus(newStatus);
|
||||
};
|
||||
|
||||
const [currentPrice, setCurrentPrice] = useState(product.basePrice);
|
||||
const [currentDiscountPrice, setCurrentDiscountPrice] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [currentStock, setCurrentStock] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const matchingVariant = product.variants?.find((variant) => {
|
||||
let colorMatch = true;
|
||||
let sizeMatch = true;
|
||||
|
||||
// Check color match only if product has color variants
|
||||
if (hasColorVariants) {
|
||||
if (selectedColor) {
|
||||
// Find the color value that matches the selected color detail
|
||||
const selectedColorValue = inStockColors.find(
|
||||
(color) =>
|
||||
(color.info as unknown as { detail: string })?.detail ===
|
||||
selectedColor
|
||||
)?.value;
|
||||
colorMatch = variant.resolvedAttributes?.COLOR === selectedColorValue;
|
||||
} else {
|
||||
colorMatch = false; // No color selected but product has color variants
|
||||
}
|
||||
}
|
||||
|
||||
// Check size match only if product has size variants
|
||||
if (hasSizeVariants) {
|
||||
if (selectedSize) {
|
||||
sizeMatch = variant.resolvedAttributes?.SIZE === selectedSize;
|
||||
} else {
|
||||
sizeMatch = false; // No size selected but product has size variants
|
||||
}
|
||||
}
|
||||
|
||||
return colorMatch && sizeMatch;
|
||||
});
|
||||
|
||||
if (matchingVariant) {
|
||||
setCurrentPrice(matchingVariant.price || product.basePrice);
|
||||
setCurrentDiscountPrice(
|
||||
matchingVariant.discountPrice &&
|
||||
matchingVariant.discountPrice !== "0" &&
|
||||
Number(matchingVariant.discountPrice) !==
|
||||
Number(matchingVariant.price)
|
||||
? matchingVariant.discountPrice
|
||||
: null
|
||||
);
|
||||
setCurrentStock(matchingVariant.stock || null);
|
||||
} else {
|
||||
// If no matching variant found, but we have variants and no color/size requirements
|
||||
// Use the first available variant (for products with no color/size variants)
|
||||
if (!hasColorVariants && !hasSizeVariants && stockedVariants.length > 0) {
|
||||
const firstVariant = stockedVariants[0];
|
||||
setCurrentPrice(firstVariant.price || product.basePrice);
|
||||
setCurrentDiscountPrice(
|
||||
firstVariant.discountPrice &&
|
||||
firstVariant.discountPrice !== "0" &&
|
||||
Number(firstVariant.discountPrice) !== Number(firstVariant.price)
|
||||
? firstVariant.discountPrice
|
||||
: null
|
||||
);
|
||||
setCurrentStock(firstVariant.stock || null);
|
||||
} else {
|
||||
setCurrentPrice(product.basePrice);
|
||||
setCurrentDiscountPrice(null);
|
||||
setCurrentStock(null);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
selectedColor,
|
||||
selectedSize,
|
||||
product,
|
||||
inStockColors,
|
||||
hasColorVariants,
|
||||
hasSizeVariants,
|
||||
stockedVariants,
|
||||
]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex flex-col pb-20">
|
||||
{!isOwner && <ProductHeader product={product} />}
|
||||
<ProductImageCarousel
|
||||
images={product.imageUrls || []}
|
||||
productId={product.id}
|
||||
storeId={product.seller?.username}
|
||||
isOwner={isOwner}
|
||||
borderRadius={0}
|
||||
isActive={productActiveStatus}
|
||||
controlsIsOn={true}
|
||||
onStatusChange={handleProductStatusChange}
|
||||
isChangeStatusDialogOpen={isChangeStatusDialogOpen}
|
||||
setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen}
|
||||
/>
|
||||
{/* Inactive Product Status */}
|
||||
{!productActiveStatus && isOwner ? (
|
||||
<div className="flex gap-1 px-4 w-full justify-between items-center mt-2">
|
||||
<div className="flex items-center w-full gap-2 p-2 bg-WHITE3 rounded-lg">
|
||||
<EyeOff size={16} className="text-BLACK2 font-bold" />
|
||||
<span className="text-B12 font-bold text-BLACK2">
|
||||
این محصول در حال حاضر غیرفعال است
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="dark"
|
||||
size="sm"
|
||||
className="text-B10 h-8 rounded-md"
|
||||
onClick={() => setIsChangeStatusDialogOpen(true)}
|
||||
>
|
||||
فعال کردن
|
||||
</Button>
|
||||
</div>
|
||||
) : !isOwner ? (
|
||||
<ProductActions productId={product.id || ""} />
|
||||
) : null}
|
||||
<ProductInfo
|
||||
productDiscountPercent={Number(
|
||||
!currentDiscountPrice ||
|
||||
!currentPrice ||
|
||||
Number(currentDiscountPrice) === Number(currentPrice)
|
||||
? 0
|
||||
: (
|
||||
((Number(currentPrice) - Number(currentDiscountPrice)) /
|
||||
Number(currentPrice)) *
|
||||
100
|
||||
).toFixed(2)
|
||||
)}
|
||||
product={{ ...product, isActive: productActiveStatus }}
|
||||
/>
|
||||
|
||||
{hasAnyStock ? (
|
||||
<>
|
||||
{hasColorVariants && (
|
||||
<ColorSelector
|
||||
colors={inStockColors.map(
|
||||
(color) => (color.info as unknown as { detail: string })?.detail
|
||||
)}
|
||||
selectedColor={selectedColor}
|
||||
setSelectedColor={setSelectedColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasSizeVariants && (
|
||||
<SizeSelector
|
||||
sizes={inStockSizes.map((size) => size.value || "")}
|
||||
selectedSize={selectedSize}
|
||||
setSelectedSize={setSelectedSize}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 mt-10 px-4">
|
||||
<p className="text-B18 font-bold">موجود نیست</p>
|
||||
</div>
|
||||
)}
|
||||
{isOwner && (
|
||||
<div className="w-full p-4 flex justify-center items-center">
|
||||
<Button
|
||||
variant="dark"
|
||||
size="sm"
|
||||
className="text-B10 h-8 rounded-md bg-VITROWN_BLUE text-white"
|
||||
onClick={() => {
|
||||
navigate(`/product/${product.id}?mode=store`);
|
||||
}}
|
||||
>
|
||||
<Eye size={16} className="text-WHITE" />
|
||||
دیدن محصول از نگاه مشتری
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{productActiveStatus && (
|
||||
<>
|
||||
{!isOwner && <ContactButton product={product} />}
|
||||
<ReviewSection productId={product.id || ""} isOwner={isOwner} />
|
||||
{/* Similar Products Section */}
|
||||
{!isOwner && (
|
||||
<section className="w-full gap-2 flex flex-col max-w-[100vw] my-6">
|
||||
<div className="w-full px-4">
|
||||
<p className="text-B16 font-bold">محصولات مشابه</p>
|
||||
<p className="text-R14">محصولات مرتبط که ممکن است بپسندید</p>
|
||||
</div>
|
||||
<MondrianProductList
|
||||
products={
|
||||
similarProductsData?.pages
|
||||
?.flatMap((page) => page.results || [])
|
||||
.map((product) => ({
|
||||
id: product?.id || "",
|
||||
title: product?.title || "",
|
||||
images: Array.isArray(product?.imageUrls)
|
||||
? product.imageUrls
|
||||
: [],
|
||||
discountPercent: product?.discountPercent || 0,
|
||||
discountPrice: product?.discountPrice || 0,
|
||||
basePrice: product?.basePrice || 0,
|
||||
})) || []
|
||||
}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isLoading={isLoadingSimilar}
|
||||
isError={isErrorSimilar}
|
||||
refetch={refetchSimilar}
|
||||
emptyState={{
|
||||
title: "محصول مشابهی یافت نشد",
|
||||
description: "در حال حاضر محصول مشابهی برای نمایش وجود ندارد",
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{/* Bottom bar handles variant selection and pricing */}
|
||||
</>
|
||||
)}
|
||||
{!isOwner && (
|
||||
<BottomBar
|
||||
currentPrice={currentPrice}
|
||||
currentDiscountPrice={currentDiscountPrice}
|
||||
currentStock={currentStock}
|
||||
product={product}
|
||||
selectedColor={
|
||||
inStockColors.find(
|
||||
(color) =>
|
||||
(color.info as unknown as { detail: string })?.detail ===
|
||||
selectedColor
|
||||
)?.value || null
|
||||
}
|
||||
selectedSize={selectedSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
82
app/components/product/ProductHeader.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Link, useNavigate } from "@remix-run/react";
|
||||
import { ProductDetail } from "src/api/types";
|
||||
import { useCreateChatThread } from "~/requestHandler/use-chat-hooks";
|
||||
import { useState } from "react";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
import { LoginRequiredDialog } from "~/components/LoginRequiredDialog";
|
||||
import SellerLogo from "~/components/SellerLogo";
|
||||
import { safeGoBack } from "~/utils/helpers";
|
||||
interface ProductHeaderProps {
|
||||
product: ProductDetail;
|
||||
}
|
||||
|
||||
export const ProductHeader = ({ product }: ProductHeaderProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useRootData();
|
||||
const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false);
|
||||
const { mutate: createChatThread, isPending: isCreatingChatThread } =
|
||||
useCreateChatThread();
|
||||
const handleCreateChatThread = () => {
|
||||
if (!user) {
|
||||
setIsLoginDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!product.seller?.id) return;
|
||||
createChatThread(product.seller.id, {
|
||||
onSuccess: (threadData) => {
|
||||
navigate(`/profile/threads/${threadData.id}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex flex-row sticky z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border"
|
||||
style={
|
||||
{
|
||||
top: 0,
|
||||
height: "calc(65px + env(safe-area-inset-top, 0px))",
|
||||
paddingTop: "env(safe-area-inset-top, 0px)",
|
||||
position: "sticky",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<ArrowRight
|
||||
onClick={() => safeGoBack(navigate)}
|
||||
className="absolute right-5"
|
||||
style={{ top: "calc(env(safe-area-inset-top, 0px) + 20px)" }}
|
||||
/>
|
||||
<Link to={`/seller/${product.seller?.username}`} className="flex gap-2">
|
||||
<SellerLogo src={product.seller?.storeLogo} alt={"seller-image"} />
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="text-B12 max-w-[100px] text-ellipsis font-bold text-overflow-ellipsis overflow-hidden whitespace-nowrap">
|
||||
{product.seller?.username}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div
|
||||
className="absolute left-5"
|
||||
style={{ top: "calc(env(safe-area-inset-top, 0px) + 40px - 20px)" }}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateChatThread}
|
||||
disabled={!product.seller?.id || isCreatingChatThread}
|
||||
size="lg"
|
||||
className="w-full h-[28px] rounded-[8px]"
|
||||
>
|
||||
{isCreatingChatThread ? "در حال اتصال..." : "ارسال پیام"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<LoginRequiredDialog
|
||||
loginTitle="برای ارسال پیام به فروشنده ابتدا باید وارد حساب کاربری خود شوید."
|
||||
isOpen={isLoginDialogOpen}
|
||||
onOpenChange={setIsLoginDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
449
app/components/product/ProductImageCarousel.tsx
Normal file
@ -0,0 +1,449 @@
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { safeGoBack } from "~/utils/helpers";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
type CarouselApi,
|
||||
} from "~/components/ui/carousel";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "~/components/ui/drawer";
|
||||
import { Dialog, DialogContent, DialogOverlay } from "~/components/ui/dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useCarousel } from "~/hooks/useCarousel";
|
||||
import { isVideo } from "../MondrianProductList";
|
||||
import {
|
||||
Edit3,
|
||||
Trash2,
|
||||
X,
|
||||
EllipsisVertical,
|
||||
EyeOff,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useDeleteProduct,
|
||||
useToggleProductStatus,
|
||||
} from "~/requestHandler/use-product-hooks";
|
||||
import placeholderImage from "~/assets/icons/product.png";
|
||||
import placeholderImageDark from "~/assets/icons/product-dark.png";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
|
||||
interface ProductImageCarouselProps {
|
||||
images: string[];
|
||||
productId?: string;
|
||||
storeId?: string;
|
||||
hideIndex?: boolean;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
isOwner?: boolean;
|
||||
isActive?: boolean;
|
||||
controlsIsOn?: boolean;
|
||||
onStatusChange?: (newStatus: boolean) => void;
|
||||
isChangeStatusDialogOpen?: boolean;
|
||||
setIsChangeStatusDialogOpen?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ProductImageCarousel = ({
|
||||
images,
|
||||
productId,
|
||||
storeId,
|
||||
hideIndex = false,
|
||||
height,
|
||||
borderRadius,
|
||||
controlsIsOn = false,
|
||||
isOwner = false,
|
||||
isActive = true,
|
||||
onStatusChange,
|
||||
isChangeStatusDialogOpen,
|
||||
setIsChangeStatusDialogOpen,
|
||||
}: ProductImageCarouselProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [currentActiveStatus, setCurrentActiveStatus] = useState(isActive);
|
||||
const { theme } = useRootData();
|
||||
const { api, setApi } = useCarousel({
|
||||
onSelect: useCallback(
|
||||
(carouselApi: CarouselApi) => {
|
||||
if (!carouselApi) return;
|
||||
setCurrentIndex(carouselApi.selectedScrollSnap());
|
||||
},
|
||||
[setCurrentIndex]
|
||||
),
|
||||
});
|
||||
|
||||
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
|
||||
|
||||
// Hooks for product management
|
||||
const deleteProductMutation = useDeleteProduct();
|
||||
const toggleProductStatusMutation = useToggleProductStatus();
|
||||
|
||||
// Sync local state with prop changes
|
||||
useEffect(() => {
|
||||
setCurrentActiveStatus(isActive);
|
||||
}, [isActive]);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleEditProduct = () => {
|
||||
if (productId && storeId) {
|
||||
navigate(`/store/add/manual/${storeId}?edit=${productId}`);
|
||||
}
|
||||
setIsDrawerOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setIsDrawerOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
const handleDeactiveClick = () => {
|
||||
setIsDrawerOpen(false);
|
||||
if (setIsChangeStatusDialogOpen) setIsChangeStatusDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (productId) {
|
||||
deleteProductMutation.mutate(productId, {
|
||||
onSuccess: () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
// Navigate back or refresh the page
|
||||
navigate(-1);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmStatusChange = () => {
|
||||
if (productId) {
|
||||
const newStatus = !currentActiveStatus;
|
||||
toggleProductStatusMutation.mutate(
|
||||
{ productId, isActive: newStatus },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (setIsChangeStatusDialogOpen) setIsChangeStatusDialogOpen(false);
|
||||
// Update local state
|
||||
setCurrentActiveStatus(newStatus);
|
||||
// Call parent callback if provided
|
||||
if (onStatusChange) {
|
||||
onStatusChange(newStatus);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full aspect-square relative overflow-hidden"
|
||||
style={{
|
||||
height: height ? `${height}px` : "100%",
|
||||
borderRadius: borderRadius !== undefined ? `${borderRadius}px` : "10px",
|
||||
}}
|
||||
>
|
||||
{!hideIndex && images.length > 1 && (
|
||||
<div className="absolute bottom-3 left-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 px-3 py-2 rounded-[10px] text-B12">
|
||||
{images.length} / {currentIndex + 1}
|
||||
</div>
|
||||
)}
|
||||
{isOwner && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
className="absolute top-3 left-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
|
||||
>
|
||||
<EllipsisVertical size={20} className="text-WHITE2" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => safeGoBack(navigate)}
|
||||
className="absolute top-3 right-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
|
||||
>
|
||||
<ArrowRight size={20} className="text-WHITE2" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Carousel
|
||||
setApi={setApi}
|
||||
opts={{
|
||||
align: "start",
|
||||
loop: true,
|
||||
}}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<CarouselContent className="-ml-0 h-full">
|
||||
{images.map((media, index) => (
|
||||
<CarouselItem key={index} className={`pl-0 h-full`}>
|
||||
<div className="w-full aspect-square overflow-hidden h-full relative">
|
||||
{isVideo(media) ? (
|
||||
<div className="w-full h-full relative">
|
||||
<video
|
||||
ref={(el) => (videoRefs.current[index] = el)}
|
||||
src={media}
|
||||
className="w-full object-cover"
|
||||
style={{
|
||||
height: height ? `${height}px` : "100%",
|
||||
borderRadius:
|
||||
borderRadius !== undefined
|
||||
? `${borderRadius}px`
|
||||
: "10px",
|
||||
}}
|
||||
controls={controlsIsOn}
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const img = document.createElement("img");
|
||||
img.src =
|
||||
theme === "dark"
|
||||
? placeholderImageDark
|
||||
: placeholderImage;
|
||||
img.alt = `تصویر ${index + 1}`;
|
||||
img.className = "w-full object-contain";
|
||||
img.style.height = height ? `${height}px` : "100%";
|
||||
img.style.borderRadius =
|
||||
borderRadius !== undefined
|
||||
? `${borderRadius}px`
|
||||
: "10px";
|
||||
img.style.backgroundColor =
|
||||
theme === "dark" ? "#121212" : "#f2f0f0";
|
||||
parent.replaceChild(img, target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={
|
||||
media ||
|
||||
(theme === "dark"
|
||||
? placeholderImageDark
|
||||
: placeholderImage)
|
||||
}
|
||||
loading={index === 0 ? "eager" : "lazy"}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src =
|
||||
theme === "dark"
|
||||
? placeholderImageDark
|
||||
: placeholderImage;
|
||||
e.currentTarget.style.objectFit = "contain";
|
||||
e.currentTarget.style.backgroundColor =
|
||||
theme === "dark" ? "#121212" : "#f2f0f0";
|
||||
}}
|
||||
alt={`تصویر ${index + 1}`}
|
||||
className={`w-full ${media ? "object-cover" : "object-contain"}`}
|
||||
style={{
|
||||
height: height ? `${height}px` : "100%",
|
||||
borderRadius:
|
||||
borderRadius !== undefined
|
||||
? `${borderRadius}px`
|
||||
: "10px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
index === currentIndex ? "bg-WHITE" : "bg-WHITE/50"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Drawer */}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="flex items-center relative justify-center">
|
||||
<X
|
||||
size={24}
|
||||
className="text-BLACK2 absolute left-4"
|
||||
onClick={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
<DrawerTitle className="text-center text-B12 font-bold">
|
||||
تنظیمات محصول
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex gap-10 p-4 pb-6 items-center justify-center">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1"
|
||||
onClick={handleEditProduct}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleEditProduct();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="edit product"
|
||||
>
|
||||
<div className="bg-BLACK h-12 w-12 shrink-0 flex flex-col items-center justify-center rounded-full">
|
||||
<Edit3 size={24} className="text-WHITE" />
|
||||
</div>
|
||||
<p className="text-R12 text-center">ویرایش محصول</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1"
|
||||
onClick={handleDeactiveClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleDeactiveClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={
|
||||
currentActiveStatus ? "deactive product" : "active product"
|
||||
}
|
||||
>
|
||||
<div className="bg-BLACK h-12 w-12 shrink-0 flex flex-col items-center justify-center rounded-full">
|
||||
{currentActiveStatus ? (
|
||||
<EyeOff size={24} className="text-WHITE" />
|
||||
) : (
|
||||
<Eye size={24} className="text-WHITE" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-R12 text-center">
|
||||
{currentActiveStatus ? "عدم نمایش محصول" : "نمایش محصول"}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1"
|
||||
onClick={handleDeleteClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleDeleteClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Delete product"
|
||||
>
|
||||
<div className="bg-red-500 h-12 w-12 shrink-0 flex flex-col items-center justify-center rounded-full">
|
||||
<Trash2 size={24} className="text-WHITE" />
|
||||
</div>
|
||||
<p className="text-R12 text-center">حذف محصول</p>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogOverlay className="bg-BLACK/50 z-[51]" />
|
||||
<DialogContent className="p-0 bg-WHITE overflow-y-auto rounded-[15px] w-[calc(100%-40px)] z-[51]">
|
||||
<div className="flex flex-col gap-6 items-center w-full p-6 overflow-hidden">
|
||||
<div className="text-center flex flex-col gap-4 items-center justify-center">
|
||||
<Trash2 className="text-BLACK" size={32} />
|
||||
<h3 className="text-B16 font-bold">حذف محصول</h3>
|
||||
<p className="text-R14 text-BLACK2">
|
||||
آیا میخواهید این محصول را حذف کنید؟
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
variant="dark"
|
||||
size="xl"
|
||||
className="w-full"
|
||||
disabled={deleteProductMutation.isPending}
|
||||
>
|
||||
{deleteProductMutation.isPending ? "در حال حذف..." : "حذف پست"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsDeleteDialogOpen(false)}
|
||||
variant="outline"
|
||||
size="xl"
|
||||
className="w-full"
|
||||
disabled={deleteProductMutation.isPending}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Status Change Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={isChangeStatusDialogOpen}
|
||||
onOpenChange={setIsChangeStatusDialogOpen}
|
||||
>
|
||||
<DialogOverlay className="bg-BLACK/50 z-[51]" />
|
||||
<DialogContent className="p-0 bg-WHITE overflow-y-auto rounded-[15px] w-[calc(100%-40px)] z-[51]">
|
||||
<div className="flex flex-col gap-6 items-center w-full p-6 overflow-hidden">
|
||||
<div className="text-center flex flex-col gap-4 items-center justify-center">
|
||||
{currentActiveStatus ? (
|
||||
<EyeOff className="text-BLACK" size={32} />
|
||||
) : (
|
||||
<Eye className="text-BLACK" size={32} />
|
||||
)}
|
||||
<h3 className="text-B16 font-bold">
|
||||
{currentActiveStatus ? "عدم نمایش محصول" : "نمایش محصول"}
|
||||
</h3>
|
||||
<p className="text-R14 text-BLACK2">
|
||||
{currentActiveStatus
|
||||
? "آیا میخواهید این محصول را غیرفعال کنید؟"
|
||||
: "آیا میخواهید این محصول را فعال کنید؟"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
onClick={handleConfirmStatusChange}
|
||||
variant="dark"
|
||||
size="xl"
|
||||
className="w-full"
|
||||
disabled={toggleProductStatusMutation.isPending}
|
||||
>
|
||||
{toggleProductStatusMutation.isPending
|
||||
? "در حال تغییر..."
|
||||
: currentActiveStatus
|
||||
? "غیرفعال کردن"
|
||||
: "فعال کردن"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (setIsChangeStatusDialogOpen)
|
||||
setIsChangeStatusDialogOpen(false);
|
||||
}}
|
||||
variant="outline"
|
||||
size="xl"
|
||||
className="w-full"
|
||||
disabled={toggleProductStatusMutation.isPending}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
30
app/components/product/ProductInfo.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import discountRed from "~/assets/icons/product/discount-red.svg";
|
||||
import { ProductDetail } from "src/api/types";
|
||||
|
||||
interface ProductInfoProps {
|
||||
product: ProductDetail;
|
||||
productDiscountPercent: number;
|
||||
}
|
||||
|
||||
export const ProductInfo = ({
|
||||
product,
|
||||
productDiscountPercent,
|
||||
}: ProductInfoProps) => (
|
||||
<div className="flex flex-col gap-2 mt-8 px-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
{productDiscountPercent && productDiscountPercent > 0 ? (
|
||||
<div className="flex gap-[2px] items-center">
|
||||
<p className="text-B14 font-bold text-RED mt-1">
|
||||
{productDiscountPercent || "0"}
|
||||
</p>
|
||||
<img src={discountRed} alt="discount-red" className="w-6 h-6" />
|
||||
</div>
|
||||
) : null}
|
||||
{productDiscountPercent && productDiscountPercent > 0 ? (
|
||||
<div className="w-[6px] h-[6px] rounded-full bg-BLACK" />
|
||||
) : null}
|
||||
<p className="text-B18 font-bold">{product.title}</p>
|
||||
</div>
|
||||
<p className="text-R12 text-GRAY">{product.description}</p>
|
||||
</div>
|
||||
);
|
||||
193
app/components/product/Review.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { Star, X, Reply } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "~/components/ui/drawer";
|
||||
import AppTextArea from "~/components/AppTextArea";
|
||||
import { useServiceCreateComment } from "~/requestHandler/use-comments-hooks";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { useIsOwnerProduct } from "~/requestHandler/use-product-hooks";
|
||||
|
||||
export interface ReviewProps {
|
||||
id?: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
date: string;
|
||||
comment: string;
|
||||
sellerReply?: {
|
||||
date: string;
|
||||
comment: string;
|
||||
};
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export const Review = ({
|
||||
id,
|
||||
productId,
|
||||
name,
|
||||
rating,
|
||||
isLast,
|
||||
date,
|
||||
comment,
|
||||
sellerReply,
|
||||
}: ReviewProps) => {
|
||||
const [isReplyDrawerOpen, setIsReplyDrawerOpen] = useState(false);
|
||||
const [replyComment, setReplyComment] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const { user } = useRootData();
|
||||
const createCommentMutation = useServiceCreateComment();
|
||||
|
||||
const { data: isOwnerProduct } = useIsOwnerProduct(productId);
|
||||
const isOwner = isOwnerProduct?.isOwner;
|
||||
|
||||
const authToken = user?.access_token
|
||||
? user.access_token.replace(/^"|"$/g, "")
|
||||
: "";
|
||||
|
||||
const handleReplySubmit = async () => {
|
||||
// Check if user is authenticated
|
||||
if (!authToken) {
|
||||
setError("لطفا ابتدا وارد حساب کاربری خود شوید.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Form validation
|
||||
if (!replyComment || replyComment.trim().length < 1) {
|
||||
setError("لطفا پاسخ خود را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
setError("خطا در شناسایی نظر.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await createCommentMutation.mutateAsync({
|
||||
product: productId,
|
||||
comment: replyComment,
|
||||
parent: id,
|
||||
});
|
||||
|
||||
// Reset form and close drawer on success
|
||||
setReplyComment("");
|
||||
setIsReplyDrawerOpen(false);
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: "پاسخ شما با موفقیت ثبت شد",
|
||||
description: "پاسخ شما بعد از بررسی به زودی اضافه خواهد شد.",
|
||||
variant: "default",
|
||||
duration: 3000,
|
||||
});
|
||||
} catch {
|
||||
setError("خطا در ثبت پاسخ. لطفا مجددا تلاش کنید.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex gap-3 py-2 border-b flex-col w-full ${isLast ? "border-b-0" : ""}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<p className="text-B14 font-bold">{name}</p>
|
||||
<div className="flex items-center gap-[2px]">
|
||||
<p className="text-B12 font-bold">{rating}</p>
|
||||
<Star className="text-BLACK fill-current" size={24} />
|
||||
</div>
|
||||
<p className="text-B12 font-bold text-GRAY">{date}</p>
|
||||
{/* Reply button */}
|
||||
{isOwner && (
|
||||
<div className="mr-2">
|
||||
<button
|
||||
onClick={() => setIsReplyDrawerOpen(true)}
|
||||
className="text-BLUE text-R12 flex items-center gap-1 hover:underline"
|
||||
>
|
||||
<Reply style={{ transform: "rotateY(180deg)" }} size={16} />
|
||||
پاسخ
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-R12">{comment}</p>
|
||||
</div>
|
||||
{sellerReply && (
|
||||
<div className="rounded-[10px] bg-WHITE2 p-2">
|
||||
<div className="flex gap-4 items-center">
|
||||
<p className="text-B14 font-bold">پاسخ فروشنده</p>
|
||||
<p className="text-B12 font-bold text-GRAY">{sellerReply.date}</p>
|
||||
</div>
|
||||
<p className="text-R12">{sellerReply.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply Drawer */}
|
||||
<Drawer open={isReplyDrawerOpen} onOpenChange={setIsReplyDrawerOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="flex flex-col py-3">
|
||||
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
|
||||
<X
|
||||
className="absolute right-2 cursor-pointer"
|
||||
size={24}
|
||||
onClick={() => setIsReplyDrawerOpen(false)}
|
||||
/>
|
||||
<DrawerTitle className="text-B12 font-bold">پاسخ شما</DrawerTitle>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
{/* Original comment display */}
|
||||
<div className="bg-WHITE2 rounded-[10px] p-3">
|
||||
<div className="flex gap-4 items-center mb-2">
|
||||
<p className="text-B14 font-bold">{name}</p>
|
||||
<div className="flex items-center gap-[2px]">
|
||||
<p className="text-B12 font-bold">{rating}</p>
|
||||
<Star className="text-BLACK fill-current" size={16} />
|
||||
</div>
|
||||
<p className="text-B12 font-bold text-GRAY">{date}</p>
|
||||
</div>
|
||||
<p className="text-R12">{comment}</p>
|
||||
</div>
|
||||
|
||||
{/* Reply input */}
|
||||
<AppTextArea
|
||||
inputTitle="پاسخ شما:"
|
||||
inputValue={replyComment}
|
||||
inputOnChange={(e) => setReplyComment(e.target.value)}
|
||||
inputPlaceholder="پاسخ خود را در این قسمت وارد کنید..."
|
||||
inputDir="rtl"
|
||||
/>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="text-red-500 text-R12 text-right">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
variant="dark"
|
||||
size="lg"
|
||||
className="w-full font-bold"
|
||||
onClick={handleReplySubmit}
|
||||
disabled={createCommentMutation.isPending}
|
||||
>
|
||||
{createCommentMutation.isPending ? "در حال ثبت..." : "ثبت پاسخ"}
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
413
app/components/product/ReviewSection.tsx
Normal file
@ -0,0 +1,413 @@
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Review } from "./Review";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ChevronLeft, ChevronRight, Pencil, X, Star } from "lucide-react";
|
||||
import longArrow from "~/assets/icons/product/longArrow.svg";
|
||||
import {
|
||||
useServiceGetComments,
|
||||
useServiceCreateComment,
|
||||
useServiceSubmitRating,
|
||||
} from "~/utils/RequestHandler";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "../ui/drawer";
|
||||
import AppTextArea from "~/components/AppTextArea";
|
||||
import { useRootData } from "~/hooks/use-root-data";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { ProductComment } from "src/api/types";
|
||||
import { formatDate } from "~/utils/helpers";
|
||||
import { LoginRequiredDialog } from "../LoginRequiredDialog";
|
||||
|
||||
// Pagination component
|
||||
const Pagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className="p-2 hover:bg-WHITE3 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
|
||||
{[...Array(totalPages)].map((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
// Show current page, first, last, and pages around current
|
||||
if (
|
||||
pageNumber === 1 ||
|
||||
pageNumber === totalPages ||
|
||||
(pageNumber >= currentPage - 1 && pageNumber <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
key={pageNumber}
|
||||
onClick={() => onPageChange(pageNumber)}
|
||||
className={`w-6 h-6 rounded-[5px] text-B12 font-bold flex items-center justify-center ${
|
||||
currentPage === pageNumber
|
||||
? "bg-BLACK text-WHITE"
|
||||
: "hover:bg-WHITE3"
|
||||
}`}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Show ellipsis for gaps
|
||||
if (
|
||||
(pageNumber === 2 && currentPage > 3) ||
|
||||
(pageNumber === totalPages - 1 && currentPage < totalPages - 2)
|
||||
) {
|
||||
return <span key={pageNumber}>...</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="p-2 hover:bg-WHITE3 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReviewList = ({ productId }: { productId: string }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const perPage = 10; // Show 3 comments per page as requested
|
||||
|
||||
const { data, isLoading, error } = useServiceGetComments(page, productId);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="p-4 text-center min-h-[120px] flex items-center justify-center">
|
||||
در حال بارگذاری نظرات...
|
||||
</div>
|
||||
);
|
||||
if (error)
|
||||
return (
|
||||
<div className="p-4 text-center min-h-[120px] flex items-center justify-center text-red-500">
|
||||
خطا در بارگذاری نظرات
|
||||
</div>
|
||||
);
|
||||
if (!data || !data.results || data.results.length === 0)
|
||||
return (
|
||||
<div className="p-4 text-center min-h-[120px] flex items-center justify-center">
|
||||
نظری ثبت نشده است
|
||||
</div>
|
||||
);
|
||||
|
||||
const totalPages = !data.count ? 1 : Math.ceil(data.count / perPage);
|
||||
|
||||
return (
|
||||
<div className="flex px-4 gap-3 py-2 flex-col w-full">
|
||||
{data.results?.map((comment: ProductComment, index: number) => (
|
||||
<Review
|
||||
isLast={!!data.results && index === data.results.length - 1}
|
||||
key={comment.id}
|
||||
id={comment.id}
|
||||
productId={productId}
|
||||
name={comment.user?.username || ""}
|
||||
rating={parseInt(comment.userRating || "0")}
|
||||
date={formatDate(comment.createdAt || "")}
|
||||
comment={comment.comment || ""}
|
||||
sellerReply={
|
||||
comment.replies?.[0]
|
||||
? {
|
||||
date: formatDate(comment.replies[0].createdAt || ""),
|
||||
comment: comment.replies[0].comment || "",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReviewSection = ({
|
||||
productId,
|
||||
isOwner = false,
|
||||
}: {
|
||||
productId: string;
|
||||
isOwner?: boolean;
|
||||
}) => {
|
||||
// Get product ID from the URL if not provided
|
||||
const resolvedProductId =
|
||||
productId || window.location.pathname.split("/").pop() || "";
|
||||
|
||||
// Get total comment count using the same service
|
||||
const { data } = useServiceGetComments(1, resolvedProductId);
|
||||
|
||||
const commentCount = data?.count || 0;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="border-b w-full px-4">
|
||||
<div className="flex py-6 w-full items-center justify-between">
|
||||
<p className="text-B18 font-bold">
|
||||
نظر خریداران {commentCount ? `(${commentCount})` : null}
|
||||
</p>
|
||||
{!isOwner && <SubmitReviewDrawer productId={resolvedProductId} />}
|
||||
</div>
|
||||
</div>
|
||||
<ReviewList productId={resolvedProductId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SubmitReviewDrawer = ({ productId }: { productId: string }) => {
|
||||
const [submitReviewDrawer, setSubmitReviewDrawer] = useState(false);
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [hoverRating, setHoverRating] = useState<number | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const textareaRef = useRef<HTMLDivElement>(null);
|
||||
const drawerContentRef = useRef<HTMLDivElement>(null);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
|
||||
// Get authentication token from localStorage
|
||||
const { user } = useRootData();
|
||||
const authToken = user?.access_token
|
||||
? user.access_token.replace(/^"|"$/g, "")
|
||||
: "";
|
||||
|
||||
// Get the mutations
|
||||
const createCommentMutation = useServiceCreateComment();
|
||||
const submitRatingMutation = useServiceSubmitRating();
|
||||
|
||||
// Handle keyboard open/close using visualViewport API
|
||||
useEffect(() => {
|
||||
if (!submitReviewDrawer) return;
|
||||
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return;
|
||||
|
||||
const handleViewportResize = () => {
|
||||
// Calculate keyboard height from the difference between window height and viewport height
|
||||
const newKeyboardHeight = window.innerHeight - viewport.height;
|
||||
setKeyboardHeight(Math.max(0, newKeyboardHeight));
|
||||
|
||||
// Scroll textarea into view when keyboard opens
|
||||
if (newKeyboardHeight > 0 && textareaRef.current) {
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
viewport.addEventListener("resize", handleViewportResize);
|
||||
viewport.addEventListener("scroll", handleViewportResize);
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", handleViewportResize);
|
||||
viewport.removeEventListener("scroll", handleViewportResize);
|
||||
};
|
||||
}, [submitReviewDrawer]);
|
||||
|
||||
// Handle star rating click
|
||||
const handleRatingClick = (newRating: number) => {
|
||||
setRating(newRating);
|
||||
};
|
||||
|
||||
// Handle star hover effects
|
||||
const handleRatingHover = (hoveredRating: number | null) => {
|
||||
setHoverRating(hoveredRating);
|
||||
};
|
||||
|
||||
// Handle comment input change
|
||||
const handleCommentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setComment(e.target.value);
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async () => {
|
||||
// Check if user is authenticated
|
||||
if (!authToken) {
|
||||
setError("لطفا ابتدا وارد حساب کاربری خود شوید.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Form validation
|
||||
if (!rating) {
|
||||
setError("لطفا به این محصول امتیاز دهید.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!comment || comment.trim().length < 1) {
|
||||
setError("لطفا نظر خود را درباره این محصول بنویسید.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Submit rating first
|
||||
await submitRatingMutation.mutateAsync({
|
||||
product_id: productId,
|
||||
rating: rating,
|
||||
});
|
||||
|
||||
// Then submit comment
|
||||
await createCommentMutation.mutateAsync({
|
||||
product: productId,
|
||||
comment: comment,
|
||||
parent: null,
|
||||
});
|
||||
|
||||
// Reset form and close drawer on success
|
||||
setComment("");
|
||||
setRating(null);
|
||||
setSubmitReviewDrawer(false);
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: "نظر شما با موفقیت ثبت شد",
|
||||
description: "نظر شما بعد از بررسی به زودی اضافه خواهد شد.",
|
||||
});
|
||||
} catch {
|
||||
console.error("Error submitting review:");
|
||||
setError("خطا در ثبت نظر. لطفا مجددا تلاش کنید.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine the star fill based on rating and hover state
|
||||
const getStarFill = (starPosition: number) => {
|
||||
const currentRating = hoverRating !== null ? hoverRating : rating;
|
||||
return currentRating !== null && starPosition <= currentRating
|
||||
? "fill-current"
|
||||
: "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginRequiredDialog
|
||||
loginTitle="برای ثبت نظر و امتیاز ابتدا باید وارد حساب کاربری خود شوید."
|
||||
isOpen={isLoginModalOpen}
|
||||
onOpenChange={setIsLoginModalOpen}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size={"lg"}
|
||||
className="font-bold"
|
||||
onClick={() => {
|
||||
if (!user?.access_token) {
|
||||
setIsLoginModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setSubmitReviewDrawer(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={24} />
|
||||
ثبت نظر و امتیاز
|
||||
</Button>
|
||||
<Drawer open={submitReviewDrawer} onOpenChange={setSubmitReviewDrawer}>
|
||||
<DrawerContent className="max-h-[95vh] flex flex-col">
|
||||
<DrawerHeader className="flex flex-col py-3 shrink-0 sticky top-0 bg-WHITE z-10 border-b">
|
||||
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
|
||||
<X
|
||||
className="absolute right-2 cursor-pointer"
|
||||
size={24}
|
||||
onClick={() => setSubmitReviewDrawer(false)}
|
||||
/>
|
||||
<DrawerTitle className="text-B12 font-bold">
|
||||
ثبت نظر و امتیاز
|
||||
</DrawerTitle>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
|
||||
<div
|
||||
ref={drawerContentRef}
|
||||
className="p-4 flex flex-col gap-6 overflow-y-auto overscroll-contain"
|
||||
style={{ paddingBottom: keyboardHeight > 0 ? `${keyboardHeight + 16}px` : undefined }}
|
||||
>
|
||||
{/* Rating section */}
|
||||
<div className="flex flex-col gap-0">
|
||||
<p className="text-R14 text-right w-full">
|
||||
به این محصول امتیاز دهید:
|
||||
</p>
|
||||
<div className="flex gap-2 mt-3 ltr">
|
||||
{[1, 2, 3, 4, 5].map((starPosition) => (
|
||||
<Star
|
||||
key={starPosition}
|
||||
size={48}
|
||||
className={`cursor-pointer transition-all stroke-[1.5px] ${getStarFill(starPosition)}`}
|
||||
onClick={() => handleRatingClick(starPosition)}
|
||||
onMouseEnter={() => handleRatingHover(starPosition)}
|
||||
onMouseLeave={() => handleRatingHover(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<img
|
||||
src={longArrow}
|
||||
alt="longArrow"
|
||||
className="w-[110px] h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comment section - Using AppTextArea instead of AppInput */}
|
||||
<div ref={textareaRef}>
|
||||
<AppTextArea
|
||||
inputTitle="نظر شما راجع به محصول:"
|
||||
inputValue={comment}
|
||||
inputOnChange={handleCommentChange}
|
||||
inputPlaceholder="نظر خود راجع به این محصول را در این قسمت وارد کنید."
|
||||
inputDir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="text-red-500 text-R12 text-right">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<div className="flex gap-3 mt-2">
|
||||
<Button
|
||||
variant="dark"
|
||||
size="lg"
|
||||
className="flex-1 fon text-[16px] t-bold"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Pencil size={24} />
|
||||
{isSubmitting ? "در حال ارسال..." : "ثبت نظر"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="flex-1 text-[16px] "
|
||||
onClick={() => setSubmitReviewDrawer(false)}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
33
app/components/product/SizeSelector.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
interface SizeSelectorProps {
|
||||
sizes: string[] | undefined;
|
||||
selectedSize: string | null;
|
||||
setSelectedSize: (size: string) => void;
|
||||
}
|
||||
|
||||
export const SizeSelector = ({
|
||||
sizes,
|
||||
selectedSize,
|
||||
setSelectedSize,
|
||||
}: SizeSelectorProps) => (
|
||||
<div className="flex flex-col gap-4 mt-10 px-4">
|
||||
<p className="text-B18 font-bold">انتخاب سایز</p>
|
||||
<div className="gap-4 flex">
|
||||
{sizes?.map((size: string, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-10 h-10 rounded-md flex border border-BLACK border-inner-border items-center justify-center ${
|
||||
selectedSize === size
|
||||
? "bg-BLACK text-WHITE"
|
||||
: "bg-WHITE text-BLACK"
|
||||
}`}
|
||||
onClick={() => setSelectedSize(size)}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<p className="text-R12">{size}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
11
app/components/product/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export { ProductHeader } from "./ProductHeader";
|
||||
export { ProductImageCarousel } from "./ProductImageCarousel";
|
||||
export { ProductActions } from "./ProductActions";
|
||||
export { ProductInfo } from "./ProductInfo";
|
||||
export { ColorSelector } from "./ColorSelector";
|
||||
export { SizeSelector } from "./SizeSelector";
|
||||
export { ContactButton } from "./ContactButton";
|
||||
export { ReviewSection, ReviewList } from "./ReviewSection";
|
||||
export { Review } from "./Review";
|
||||
export { BottomBar } from "./BottomBar";
|
||||
export type { ReviewProps } from "./Review";
|
||||