This commit is contained in:
abolfazl 2026-04-29 01:44:16 +03:30
parent bf63a1d33d
commit 0293655fc1
321 changed files with 71782 additions and 0 deletions

26
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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",
},
];

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

BIN
app/assets/icons/filter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 B

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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"
);

View 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>
</>
);
}

View 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;

View 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>
);
}

View 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
View 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;
}

View 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>
);
}

View 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}</>;
}

View 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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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)} &nbsp;تا ارسال مجدد کد
</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>
);
}

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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);
}

View 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>
);
};

View 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>
)}
</>
);
};

View 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>
);

View 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>
)}
</>
);

View 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>
);

View 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>
);
};

View 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>
);
};

View 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")}`;
};

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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}
/>
</>
);
};

View 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>
);
};

View 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}
/>
</>
);
};

View 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>
);
};

View 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}
/>
</>
);
};

View 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>
);
});

View 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}
/>
</>
);
};

View 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>
);
};

View 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>
);

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
);

View 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";

Some files were not shown because too many files have changed in this diff Show More