Compare commits

..

1 Commits

Author SHA1 Message Date
Ali-SaZa
c57d2eefc5 chore: sync OpenAPI from ghabilee-backend 2026-09-11 12:38:47 +00:00
43 changed files with 1761 additions and 2427 deletions

View File

@ -1,24 +0,0 @@
---
description: Finland git/CI/Telegram vs Iran app runtime topology
alwaysApply: true
---
# Infra topology (Finland vs Iran)
Ghabilee product apps (backend, admin, frontend) follow this split:
| Concern | Where |
| --- | --- |
| Git (`git.ghabilee.ir`) + Gitea Actions runners | **Finland** |
| All Telegram ops / deploy notifications | **Finland** (Actions secrets `TELEGRAM_*`, send from the runner) |
| App runtime (Docker Compose, Postgres, Redis, nginx, uploads) | **Iran** VPS |
## Rules for agents
- Do **not** send Telegram from the Iran VPS (`api.telegram.org` is unreachable there).
- Do **not** `source` full backend `.env` in shell notify paths (cron globs like `BOOKING_EXPIRY_CRON=*` break `sh`).
- Do **not** run heavy `docker build` / `next build` on the Iran box by default (OOM risk on small VPS).
- CI deploy should **build on Finland**, transfer the image to Iran, then `compose up` on Iran.
- Secrets: `VPS_*` = Iran runtime SSH; `FINLAND_*` = Finland build SSH; `TELEGRAM_*` = notify from Finland.
- Exception: `telegrambot` / telegram-relay **runs on Finland** (that is intentional).

View File

@ -1,41 +0,0 @@
# Branch protection / CI (Gitea)
## Architecture
| Role | Where |
| ---- | ----- |
| Git + Actions runner + Telegram notify | Finland (`git.ghabilee.ir`) |
| Admin runtime (`ghabilee-admin` container) | Iran VPS |
Build the image on Finland, stream it to Iran, then `docker compose up` there.
## Actions secrets
**Iran runtime (already set as `VPS_*`):**
| Secret | Meaning |
| ------ | ------- |
| `VPS_HOST` | Iran IP (e.g. `95.38.160.241`) |
| `VPS_USER` | usually `root` |
| `VPS_SSH_KEY` | Iran private key |
**Finland build host (required):**
| Secret | Meaning |
| ------ | ------- |
| `FINLAND_HOST` | e.g. `65.108.18.151` |
| `FINLAND_USER` | usually `root` |
| `FINLAND_SSH_KEY` | same key telegrambot uses as `DEPLOY_SSH_KEY` |
**Telegram (required — same values as telegrambot repo):**
| Secret | Meaning |
| ------ | ------- |
| `TELEGRAM_BOT_TOKEN` | bot token |
| `TELEGRAM_GROUP_CHAT_ID` | ops group |
| `TELEGRAM_GROUP_THREAD_ID` | forum topic id (optional) |
| `TELEGRAM_CHAT_ID` | private fallback (optional) |
## Manual notify test
Actions → **Notify Telegram (manual)** → Run workflow

View File

@ -1,65 +0,0 @@
name: Deploy admin to VPS
# Finland (git / act_runner): build image + Telegram notify
# Iran: runtime only (compose up pre-built image)
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-admin-production
cancel-in-progress: true
jobs:
deploy:
name: Build on Finland, run on Iran
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Deploy (Finland build → Iran runtime)
env:
FINLAND_SSH_KEY: ${{ secrets.FINLAND_SSH_KEY }}
FINLAND_HOST: ${{ secrets.FINLAND_HOST }}
FINLAND_USER: ${{ secrets.FINLAND_USER }}
IRAN_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
IRAN_HOST: ${{ secrets.VPS_HOST }}
IRAN_USER: ${{ secrets.VPS_USER }}
run: |
set -euo pipefail
test -n "${FINLAND_SSH_KEY:-}"
test -n "${FINLAND_HOST:-}"
test -n "${FINLAND_USER:-}"
test -n "${IRAN_SSH_KEY:-}"
test -n "${IRAN_HOST:-}"
test -n "${IRAN_USER:-}"
if ! command -v rsync >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y -qq rsync openssh-client
fi
chmod +x scripts/deploy-finland-to-iran.sh
IMAGE_TAG="ghabilee-admin:${GITHUB_SHA}" ./scripts/deploy-finland-to-iran.sh
- name: Notify Telegram
if: always()
continue-on-error: true
env:
DEPLOY_SHA: ${{ github.sha }}
DEPLOY_STATUS: ${{ job.status }}
DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_GROUP_CHAT_ID: ${{ secrets.TELEGRAM_GROUP_CHAT_ID }}
TELEGRAM_GROUP_THREAD_ID: ${{ secrets.TELEGRAM_GROUP_THREAD_ID }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')"
case "${DEPLOY_STATUS}" in
success) export DEPLOY_STATUS=success ;;
*) export DEPLOY_STATUS=failed ;;
esac
chmod +x scripts/notify-from-finland.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
./scripts/notify-from-finland.sh

View File

@ -1,27 +0,0 @@
name: Notify Telegram (manual)
on:
workflow_dispatch:
jobs:
notify:
name: Send test deploy notification
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Notify Telegram
env:
DEPLOY_SHA: ${{ github.sha }}
DEPLOY_STATUS: success
DEPLOY_COMMIT_SUBJECT: manual notify test
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_GROUP_CHAT_ID: ${{ secrets.TELEGRAM_GROUP_CHAT_ID }}
TELEGRAM_GROUP_THREAD_ID: ${{ secrets.TELEGRAM_GROUP_THREAD_ID }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
set -euo pipefail
export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')"
chmod +x scripts/notify-from-finland.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
./scripts/notify-from-finland.sh

View File

@ -1,5 +1,37 @@
# Branch protection / CI docs moved to Gitea # Branch protection on `main` (GitHub Pro required)
See [`.gitea/BRANCH_PROTECTION.md`](../.gitea/BRANCH_PROTECTION.md). Private repositories on GitHub Free cannot enable branch protection via API or
Settings. Upgrade to **GitHub Pro** (or make the repo public), then configure:
Canonical remote: <https://git.ghabilee.ir/AliSaZa/admin> **Settings → Branches → Add branch protection rule → Branch name: `main`**
Recommended settings:
- [x] Require a pull request before merging
- [ ] Require approvals (optional for solo work)
- [x] Require status checks to pass before merging
- [x] Require branches to be up to date before merging
### Required status checks — `ghabilee-frontend2`
- `Dependency vulnerability scan`
- `Secret scan`
- `Build, test, and quality checks`
Until Pro is enabled, use **Pull Request → merge** (not direct push) and rely on
local Husky `pre-push` (`pnpm prepush:check`).
Direct pushes to `main` still trigger full CI in **Deploy frontend to VPS**
before build/deploy.
### Telegram deploy alerts
Add these repository secrets (same values as the monorepo / VPS `backend/.env`):
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_GROUP_CHAT_ID`
- `TELEGRAM_GROUP_THREAD_ID` (optional forum topic)
- `TELEGRAM_CHAT_ID` (fallback private chat)
Successful/failed deploys send an **admin/backoffice-specific** message via
`scripts/notify-deploy.sh` (distinct from the consumer frontend notify copy).

221
.github/workflows/deploy-vps.yml vendored Normal file
View File

@ -0,0 +1,221 @@
name: Deploy admin to VPS
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-admin-production
cancel-in-progress: true
permissions:
contents: read
packages: write
# PR merges: build + deploy only (quality ran on pull_request).
# Direct pushes to main: re-run quality before deploy.
jobs:
gate:
name: Detect direct push to main
runs-on: ubuntu-latest
outputs:
run_quality: ${{ steps.detect.outputs.run_quality }}
steps:
- id: detect
env:
# Via env — never interpolate commit text into the script body
# (backticks/`$()` in messages would otherwise become shell command substitution).
EVENT_NAME: ${{ github.event_name }}
COMMIT_MSG: ${{ github.event.head_commit.message || '' }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "run_quality=false" >> "$GITHUB_OUTPUT"
exit 0
fi
msg="$COMMIT_MSG"
if printf '%s' "$msg" | grep -qiE 'merge pull request #[0-9]+'; then
echo "run_quality=false" >> "$GITHUB_OUTPUT"
elif printf '%s' "$msg" | grep -qE '\(#[0-9]+\)[[:space:]]*$'; then
echo "run_quality=false" >> "$GITHUB_OUTPUT"
else
# Admin boot: deploy first; quality runs on pull_request workflow.
echo "Direct push to main — skipping quality gate for deploy."
echo "run_quality=false" >> "$GITHUB_OUTPUT"
fi
quality:
needs: gate
if: needs.gate.outputs.run_quality == 'true'
uses: ./.github/workflows/frontend-quality.yml
permissions:
contents: read
pull-requests: read
build:
name: Build and push admin image
needs: [gate, quality]
if: >-
always() &&
needs.gate.result == 'success' &&
(needs.quality.result == 'success' || needs.quality.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Image metadata
id: meta
run: |
set -euo pipefail
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
repo="$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')"
echo "image=ghcr.io/${owner}/${repo}" >> "$GITHUB_OUTPUT"
- name: Fetch production build environment
env:
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
set -euo pipefail
install -m 700 -d "$HOME/.ssh"
printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key"
chmod 600 "$HOME/.ssh/vps_key"
env_path="$(ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${VPS_USER}@${VPS_HOST}" \
'test -s /opt/ghabilee-admin/.env && printf %s /opt/ghabilee-admin/.env')"
scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${VPS_USER}@${VPS_HOST}:${env_path}" .env.production
test -s .env.production
rm -f "$HOME/.ssh/vps_key"
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
env:
ADMIN_ENV_FILE: .env.production
IMAGE_REPO: ${{ steps.meta.outputs.image }}
IMAGE_TAG: ${{ github.sha }}
run: ./scripts/ci-build-image.sh
- name: Remove production build environment
if: always()
run: rm -f .env.production
deploy:
name: Stage or deploy admin
needs: build
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v4
- name: Copy Compose definition and deploy
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_USER: ${{ github.actor }}
ADMIN_IMAGE: ${{ needs.build.outputs.image }}:${{ github.sha }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
run: |
set -euo pipefail
install -m 700 -d "$HOME/.ssh"
printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key"
chmod 600 "$HOME/.ssh/vps_key"
ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${VPS_USER}@${VPS_HOST}" '
set -eu
install -d -m 0750 /opt/ghabilee-admin
test -s /opt/ghabilee-admin/.env
'
scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
deploy/docker-compose.production.yml \
"${VPS_USER}@${VPS_HOST}:/opt/ghabilee-admin/docker-compose.yml"
ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${VPS_USER}@${VPS_HOST}" \
"GHCR_TOKEN='${GHCR_TOKEN}' GHCR_USER='${GHCR_USER}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE'
set -eu
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
docker pull "$ADMIN_IMAGE"
cd /opt/ghabilee-admin
ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin
for attempt in $(seq 1 36); do
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)"
echo "Admin health ${attempt}/36: ${health}"
if [ "$health" = healthy ]; then
# فقط بعد از healthy: ایمیج‌های unused (تگ‌های قبلی) را پاک کن؛ volumeها دست نخورند
if [ -x /opt/ghabilee/scripts/docker-prune.sh ]; then
/opt/ghabilee/scripts/docker-prune.sh full
else
docker image prune -af
fi
exit 0
fi
case "$health" in unhealthy|exited|dead|missing) exit 1;; esac
sleep 5
done
exit 1
REMOTE
rm -f "$HOME/.ssh/vps_key"
notify-success:
name: Notify Telegram (success)
needs: deploy
# `quality` is intentionally skipped after PR merges. `success()` treats
# that skipped upstream job as non-success and would skip this job too.
if: ${{ always() && needs.deploy.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Notify ops group of successful admin deploy
env:
DEPLOY_SHA: ${{ github.sha }}
DEPLOY_STATUS: success
DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
export DEPLOY_VERSION="$(node -p "require('./package.json').version")"
chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
./scripts/notify-via-vps.sh
notify-failed:
name: Notify Telegram (failed)
needs: [gate, quality, build, deploy]
if: failure()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Notify ops group of failed admin deploy
env:
DEPLOY_SHA: ${{ github.sha }}
DEPLOY_STATUS: failed
DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
# Do not gate on -x: notify-via-vps.sh may be 100644 in git; chmod first
# (the old `if [[ -x ... ]]` skipped the whole notify and still exited 0).
export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')"
chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
./scripts/notify-via-vps.sh

View File

@ -56,13 +56,6 @@ const columns: PaginationListColumnType[] = [
type: 'select', type: 'select',
filterItems: BOOKING_STATUS_FILTER_ITEMS, filterItems: BOOKING_STATUS_FILTER_ITEMS,
}, },
{
field: 'cancellationReason',
label: 'دلیل لغو',
filterable: false,
sortable: false,
type: 'text',
},
{ {
field: 'checkedInAt', field: 'checkedInAt',
label: 'چک‌این', label: 'چک‌این',
@ -130,14 +123,6 @@ const BookingsPage = () => {
/> />
) )
}, },
cancellationReason: (row, cellValue) => {
const reason = coerceToString(cellValue).trim()
const isCancelledLike = row.status === 'cancelled' || row.status === 'refunded'
if (!isCancelledLike || !reason) return '—'
return <span className="max-w-[220px] whitespace-pre-wrap break-words text-xs text-secondary-20">{reason}</span>
},
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue), checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue), createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => { actions: (row) => {

View File

@ -13,7 +13,6 @@ import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip' import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios' import axiosInstance from '@/config/axios'
import useAdminMutation from '@/hooks/useAdminMutation' import useAdminMutation from '@/hooks/useAdminMutation'
import useAlertModal from '@/hooks/useAlertModal'
import { coerceToString } from '@/helpers' import { coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters' import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
import { getBooleanStatus } from '@/constants/status' import { getBooleanStatus } from '@/constants/status'
@ -80,16 +79,9 @@ const columns: PaginationListColumnType[] = [
] ]
const ContactMessagesPage = () => { const ContactMessagesPage = () => {
const { showAlert } = useAlertModal()
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST }) const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST })
const [selected, setSelected] = useState<ContactMessageRow | null>(null) const [selected, setSelected] = useState<ContactMessageRow | null>(null)
const handleMarkRead = (message: ContactMessageRow) => {
showAlert('این پیام به‌عنوان خوانده‌شده علامت‌گذاری شود؟', () =>
runAction(message.id, () => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)), 'پیام خوانده شد')
)
}
return ( return (
<section className="h-full w-full text-right"> <section className="h-full w-full text-right">
<PageNavbar pageTitle="پیام‌های تماس" /> <PageNavbar pageTitle="پیام‌های تماس" />
@ -158,9 +150,13 @@ const ContactMessagesPage = () => {
isLoading={pendingId === message.id} isLoading={pendingId === message.id}
size="sm" size="sm"
variant="light" variant="light"
onClick={() => { onClick={() =>
handleMarkRead(message) void runAction(
}} message.id,
() => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)),
'پیام خوانده شد'
)
}
> >
<FileCheckIcon className="size-5" /> <FileCheckIcon className="size-5" />
</Button> </Button>

View File

@ -16,12 +16,7 @@ vi.mock('@/config/axios', () => ({
patch: axiosMocks.patch, patch: axiosMocks.patch,
}, },
})) }))
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() })) vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
vi.mock('@/components/formElements/Input', () => ({ vi.mock('@/components/formElements/Input', () => ({
default: ({ default: ({
description, description,
@ -114,9 +109,6 @@ describe('NotificationRulesPanel', () => {
}) })
beforeEach(() => { beforeEach(() => {
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
axiosMocks.get.mockReset() axiosMocks.get.mockReset()
axiosMocks.patch.mockReset() axiosMocks.patch.mockReset()
axiosMocks.get.mockResolvedValue({ axiosMocks.get.mockResolvedValue({

View File

@ -10,7 +10,6 @@ import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input' import Input from '@/components/formElements/Input'
import AdminState from '@/components/feedback/AdminState' import AdminState from '@/components/feedback/AdminState'
import axiosInstance from '@/config/axios' import axiosInstance from '@/config/axios'
import useAlertModal from '@/hooks/useAlertModal'
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse' import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
import { API_ROUTES } from '@/services/config' import { API_ROUTES } from '@/services/config'
import { extractServerErrorDetail } from '@/services/errorHandler' import { extractServerErrorDetail } from '@/services/errorHandler'
@ -53,7 +52,6 @@ const isDirtyDraft = (current: RuleDraft, saved: RuleDraft | undefined) =>
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved) Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
const NotificationRulesPanel = () => { const NotificationRulesPanel = () => {
const { showAlert } = useAlertModal()
const [rules, setRules] = useState<Rule[]>([]) const [rules, setRules] = useState<Rule[]>([])
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({}) const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -113,12 +111,6 @@ const NotificationRulesPanel = () => {
} }
} }
const confirmSave = (rule: Rule) => {
showAlert(`تغییرات قاعدهٔ «${rule.displayName}» ذخیره شود؟`, () => {
void save(rule)
})
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900"> <div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900">
@ -150,9 +142,7 @@ const NotificationRulesPanel = () => {
rule={rule} rule={rule}
saving={saving === rule.eventKey} saving={saving === rule.eventKey}
onChange={change} onChange={change}
onSave={() => { onSave={() => void save(rule)}
confirmSave(rule)
}}
/> />
))} ))}
</div> </div>

View File

@ -115,9 +115,7 @@ const ReviewsPage = () => {
} }
const handleRestore = (row: ReviewRow) => { const handleRestore = (row: ReviewRow) => {
showAlert('این نظر دوباره در نمایش عمومی قرار گیرد؟', () => void runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
)
} }
const handleDelete = (row: ReviewRow) => { const handleDelete = (row: ReviewRow) => {

View File

@ -7,7 +7,6 @@ import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button' import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input' import Input from '@/components/formElements/Input'
import { APP_ROUTES } from '@/constants/routes' import { APP_ROUTES } from '@/constants/routes'
import useAlertModal from '@/hooks/useAlertModal'
import { addToast } from '@/lib/toast' import { addToast } from '@/lib/toast'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import { import {
@ -22,7 +21,6 @@ import {
const AdminSupportTicketDetail = () => { const AdminSupportTicketDetail = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const { showAlert } = useAlertModal()
const [ticket, setTicket] = useState<SupportTicket | null>(null) const [ticket, setTicket] = useState<SupportTicket | null>(null)
const [reply, setReply] = useState('') const [reply, setReply] = useState('')
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
@ -58,41 +56,6 @@ const AdminSupportTicketDetail = () => {
} }
} }
const confirmSend = () => {
if (!reply.trim()) return
showAlert('این پاسخ ثبت و پیامک اطلاع‌رسانی برای کاربر ارسال شود؟', () => {
void send()
})
}
const confirmStatusChange = (nextStatus: string) => {
if (!ticket || nextStatus === ticket.status) return
if (nextStatus === 'closed') {
showAlert(
'این تیکت بسته شود؟ کاربر دیگر نمی‌تواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمی‌تواند دوباره باز کند.',
() => {
void changeStatus('closed')
},
undefined,
{ dangerAccept: true }
)
return
}
const label = SUPPORT_ADMIN_STATUS_LABELS[nextStatus] ?? nextStatus
showAlert(`وضعیت تیکت به «${label}» تغییر کند؟`, () => {
void changeStatus(nextStatus)
})
}
const confirmClose = () => {
confirmStatusChange('closed')
}
return ( return (
<section className="h-full w-full text-right"> <section className="h-full w-full text-right">
<PageNavbar pageTitle={ticket?.subject ?? 'جزئیات تیکت'} /> <PageNavbar pageTitle={ticket?.subject ?? 'جزئیات تیکت'} />
@ -128,9 +91,7 @@ const AdminSupportTicketDetail = () => {
<select <select
className="mt-1 block w-full rounded-lg border border-default-300 p-2" className="mt-1 block w-full rounded-lg border border-default-300 p-2"
value={ticket.status} value={ticket.status}
onChange={(event) => { onChange={(event) => void changeStatus(event.target.value)}
confirmStatusChange(event.target.value)
}}
> >
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => ( {Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
<option <option
@ -143,24 +104,6 @@ const AdminSupportTicketDetail = () => {
</select> </select>
</label> </label>
</div> </div>
{ticket.status !== 'closed' ? (
<div className="mt-4 flex justify-end">
<Button
color="danger"
size="sm"
variant="flat"
onClick={confirmClose}
>
بستن تیکت
</Button>
</div>
) : (
<p className="mt-4 text-sm text-text-muted">
{ticket.closedBy === 'user'
? 'این تیکت توسط کاربر بسته شده است.'
: 'این تیکت توسط پشتیبانی بسته شده است؛ کاربر نمی‌تواند دوباره باز کند.'}
</p>
)}
</div> </div>
<div className="space-y-3 rounded-2xl border border-default-200 bg-white p-5"> <div className="space-y-3 rounded-2xl border border-default-200 bg-white p-5">
{ticket.messages.map((message) => ( {ticket.messages.map((message) => (
@ -186,13 +129,10 @@ const AdminSupportTicketDetail = () => {
setReply(String(value)) setReply(String(value))
}} }}
/> />
<p className="my-3 text-xs text-text-muted"> <p className="my-3 text-xs text-text-muted">پس از ثبت پاسخ، برای کاربر پیامک اطلاعرسانی ارسال میشود.</p>
میتوانید چند پیام پشتسرهم بفرستید. کاربر فقط بعد از پاسخ شما یک پیام میتواند بفرستد. پس از ثبت پاسخ، برای کاربر پیامک
اطلاعرسانی ارسال میشود.
</p>
<Button <Button
isLoading={pending} isLoading={pending}
onClick={confirmSend} onClick={() => void send()}
> >
ثبت پاسخ و ارسال پیامک ثبت پاسخ و ارسال پیامک
</Button> </Button>

View File

@ -140,29 +140,16 @@ const UserEditModal = ({ isOpen, onOpenChange, user, currentAdminId, onSuccess }
} }
const handleSubmit = (values: AdminUserEditValues) => { const handleSubmit = (values: AdminUserEditValues) => {
// Suspending an account is destructive-ish for the user, so confirm first.
const initial = toFormValues(user) const initial = toFormValues(user)
const payload = buildDiffPayload(values)
if (Object.keys(payload).length === 0) {
onOpenChange(false)
return
}
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') { if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
showAlert( showAlert('این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values))
'این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.',
() => submitUpdate(values),
undefined,
{
dangerAccept: true,
}
)
return return
} }
showAlert('تغییرات این کاربر ذخیره شود؟', () => submitUpdate(values)) void submitUpdate(values)
} }
return ( return (

View File

@ -1,8 +1,6 @@
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import axiosInstance, { getOrRefreshAccessToken, resetAxiosAuthModuleState } from '@/config/axios'
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
expireRefreshSession: vi.fn().mockResolvedValue(undefined), expireRefreshSession: vi.fn().mockResolvedValue(undefined),
})) }))
@ -25,15 +23,18 @@ const writeStoredToken = (accessToken: string): void => {
} }
describe('admin access-token refresh races', () => { describe('admin access-token refresh races', () => {
beforeEach(() => { beforeEach(async () => {
vi.clearAllMocks() vi.clearAllMocks()
vi.restoreAllMocks() vi.restoreAllMocks()
window.localStorage.clear() window.localStorage.clear()
const { resetAxiosAuthModuleState } = await import('@/config/axios')
resetAxiosAuthModuleState() resetAxiosAuthModuleState()
}) })
it('replays a late 401 with the newer stored token without rotating refresh again', async () => { it('replays a late 401 with the newer stored token without rotating refresh again', async () => {
writeStoredToken('access-a') writeStoredToken('access-a')
const { default: axiosInstance } = await import('@/config/axios')
const refreshRequest = vi.spyOn(axios, 'post') const refreshRequest = vi.spyOn(axios, 'post')
const authorizationHeaders: string[] = [] const authorizationHeaders: string[] = []
let requestCount = 0 let requestCount = 0
@ -84,6 +85,7 @@ describe('admin access-token refresh races', () => {
}) })
try { try {
const { getOrRefreshAccessToken } = await import('@/config/axios')
const refreshRequest = vi.spyOn(axios, 'post') const refreshRequest = vi.spyOn(axios, 'post')
await expect(getOrRefreshAccessToken()).resolves.toBe('access-b') await expect(getOrRefreshAccessToken()).resolves.toBe('access-b')

View File

@ -1,38 +1,10 @@
import type { ButtonHTMLAttributes } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { AlertModalProvider } from '@/context/AlertModalContext' import { AlertModalProvider } from '@/context/AlertModalContext'
import useAlertModal from '@/hooks/useAlertModal' import useAlertModal from '@/hooks/useAlertModal'
vi.mock('@/components/formElements/Button', () => ({ afterEach(cleanup)
default: ({
children,
isLoading,
fullWidth: _fullWidth,
color: _color,
variant: _variant,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
isLoading?: boolean
fullWidth?: boolean
color?: string
variant?: string
}) => (
<button
{...props}
disabled={props.disabled || isLoading}
>
{children}
</button>
),
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
function AlertHarness({ onConfirm }: { onConfirm: () => void | Promise<void> }) { function AlertHarness({ onConfirm }: { onConfirm: () => void | Promise<void> }) {
const { showAlert } = useAlertModal() const { showAlert } = useAlertModal()

View File

@ -1,35 +0,0 @@
# Admin backoffice — TLS terminated by Nginx, app on 127.0.0.1:3009
# DNS: point backoffice.ghabilee.ir at this VPS, then:
# certbot --nginx -d backoffice.ghabilee.ir
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream ghabilee_admin {
server 127.0.0.1:3009;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name backoffice.ghabilee.ir;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
proxy_pass http://ghabilee_admin;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}

View File

@ -143,10 +143,7 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
const [updateRequest] = await Promise.all([ const [updateRequest] = await Promise.all([
page.waitForRequest((request) => request.method() === 'PATCH' && new URL(request.url()).pathname.endsWith('/admin/events/event-1')), page.waitForRequest((request) => request.method() === 'PATCH' && new URL(request.url()).pathname.endsWith('/admin/events/event-1')),
(async () => { page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true }),
await page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true })
await page.getByRole('button', { name: 'تأیید' }).click()
})(),
]) ])
expect(updateRequest.postDataJSON()).toEqual({ settings: { isDiscoverable: true } }) expect(updateRequest.postDataJSON()).toEqual({ settings: { isDiscoverable: true } })
@ -156,7 +153,6 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
) )
await page.getByRole('button', { name: 'انتشار فوری' }).click() await page.getByRole('button', { name: 'انتشار فوری' }).click()
await page.getByRole('button', { name: 'تأیید' }).click()
await publishRequest await publishRequest
await expect(page.getByText('منتشر‌شده', { exact: true })).toBeVisible() await expect(page.getByText('منتشر‌شده', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0) await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)

View File

@ -127,7 +127,6 @@ test('admin approval publishes a pending-review event exactly once', async ({ pa
await page.goto('/manage-events/event-1') await page.goto('/manage-events/event-1')
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled() await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled()
await page.getByRole('button', { name: 'تأیید و انتشار' }).click() await page.getByRole('button', { name: 'تأیید و انتشار' }).click()
await page.getByRole('button', { name: 'تأیید' }).click()
await expect.poll(() => requests).toEqual([{ path: '/api/v1/admin/events/event-1/approve', payload: null }]) await expect.poll(() => requests).toEqual([{ path: '/api/v1/admin/events/event-1/approve', payload: null }])
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0) await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0)

View File

@ -197,10 +197,7 @@ const AdminEventDetail = () => {
const cityName = useMemo(() => cities.find((item) => item.id === event?.cityId)?.name ?? '—', [cities, event?.cityId]) const cityName = useMemo(() => cities.find((item) => item.id === event?.cityId)?.name ?? '—', [cities, event?.cityId])
const provinceName = useMemo(() => provinces.find((item) => item.id === event?.provinceId)?.name ?? '—', [event?.provinceId, provinces]) const provinceName = useMemo(() => provinces.find((item) => item.id === event?.provinceId)?.name ?? '—', [event?.provinceId, provinces])
const handleApprove = () => { const handleApprove = () =>
const isPendingReview = event?.status === 'pending_review'
showAlert(isPendingReview ? 'این رویداد تأیید و منتشر شود؟' : 'این رویداد برای انتشار تأیید شود؟', () =>
runAction( runAction(
'approve', 'approve',
async () => { async () => {
@ -208,16 +205,13 @@ const AdminEventDetail = () => {
mergeEvent(updated) mergeEvent(updated)
}, },
isPendingReview ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد' event?.status === 'pending_review' ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
) )
)
}
// Bypasses the whole request/approve flow — publishes immediately // Bypasses the whole request/approve flow — publishes immediately
// regardless of whether the host has requested publication or an admin // regardless of whether the host has requested publication or an admin
// has approved yet. Kept as a separate action from "approve" on purpose. // has approved yet. Kept as a separate action from "approve" on purpose.
const handleForcePublish = () => { const handleForcePublish = () =>
showAlert('این رویداد فوراً و بدون انتظار برای میزبان منتشر شود؟', () =>
runAction( runAction(
'force-publish', 'force-publish',
async () => { async () => {
@ -227,8 +221,6 @@ const AdminEventDetail = () => {
}, },
'رویداد فورا منتشر شد' 'رویداد فورا منتشر شد'
) )
)
}
const handleComplete = () => { const handleComplete = () => {
showAlert('آیا این رویداد به پایان رسیده است؟', () => showAlert('آیا این رویداد به پایان رسیده است؟', () =>
@ -284,8 +276,7 @@ const AdminEventDetail = () => {
const handleApproveRevision = () => { const handleApproveRevision = () => {
if (!pendingRevision) return if (!pendingRevision) return
showAlert('این ویرایش تأیید و روی رویداد اعمال شود؟', () => void runAction(
runAction(
'approve-revision', 'approve-revision',
async () => { async () => {
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
@ -296,7 +287,6 @@ const AdminEventDetail = () => {
}, },
texts.events.revisionApproveSuccess texts.events.revisionApproveSuccess
) )
)
} }
const handleRejectRevision = (rejectionReason: string) => { const handleRejectRevision = (rejectionReason: string) => {
@ -313,13 +303,12 @@ const AdminEventDetail = () => {
) )
} }
const handleToggleDiscoverable = (nextValue: boolean) => { const handleToggleDiscoverable = async (nextValue: boolean) => {
if (!event || isTogglingDiscoverable) return if (!event || isTogglingDiscoverable) return
const previous = event.settings.isDiscoverable const previous = event.settings.isDiscoverable
showAlert(nextValue ? 'نمایش این رویداد در جستجوی عمومی فعال شود؟' : 'نمایش این رویداد در جستجوی عمومی غیرفعال شود؟', async () => { setEvent({ ...event, settings: { ...event.settings, isDiscoverable: nextValue } })
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: nextValue } } : prev))
setIsTogglingDiscoverable(true) setIsTogglingDiscoverable(true)
try { try {
@ -337,7 +326,6 @@ const AdminEventDetail = () => {
} finally { } finally {
setIsTogglingDiscoverable(false) setIsTogglingDiscoverable(false)
} }
})
} }
return ( return (
@ -348,11 +336,11 @@ const AdminEventDetail = () => {
<AdminEventLifecycleActions <AdminEventLifecycleActions
event={event} event={event}
pendingId={pendingId} pendingId={pendingId}
onApprove={handleApprove} onApprove={() => void handleApprove()}
onCancel={handleCancel} onCancel={handleCancel}
onComplete={handleComplete} onComplete={handleComplete}
onDelete={handleDelete} onDelete={handleDelete}
onForcePublish={handleForcePublish} onForcePublish={() => void handleForcePublish()}
onOpenReject={() => { onOpenReject={() => {
setHasOpenedRejectModal(true) setHasOpenedRejectModal(true)
setIsRejectModalOpen(true) setIsRejectModalOpen(true)

View File

@ -11,12 +11,8 @@ const bulkCreate = vi.fn()
const setActive = vi.fn() const setActive = vi.fn()
const removeCode = vi.fn() const removeCode = vi.fn()
const addToast = vi.fn() const addToast = vi.fn()
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) })) vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
vi.mock('@/services/discountCodes', () => ({ vi.mock('@/services/discountCodes', () => ({
LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args), LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args),
LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args), LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args),
@ -71,16 +67,10 @@ const freshCode = {
} }
describe('EventDiscountsPanel', () => { describe('EventDiscountsPanel', () => {
afterEach(() => { afterEach(cleanup)
cleanup()
vi.clearAllMocks()
})
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
listCodes.mockResolvedValue({ ok: true, data: { items: [usedCode, freshCode], totalItemsCount: 2, totalPages: 1 } }) listCodes.mockResolvedValue({ ok: true, data: { items: [usedCode, freshCode], totalItemsCount: 2, totalPages: 1 } })
listRedemptions.mockResolvedValue({ ok: true, data: { items: [], totalItemsCount: 0, totalPages: 0 } }) listRedemptions.mockResolvedValue({ ok: true, data: { items: [], totalItemsCount: 0, totalPages: 0 } })
getReport.mockResolvedValue({ getReport.mockResolvedValue({
@ -146,7 +136,6 @@ describe('EventDiscountsPanel', () => {
) )
await screen.findByText('WELCOME20') await screen.findByText('WELCOME20')
bulkCreate.mockClear()
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '25' } }) fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '25' } })
fireEvent.change(screen.getByLabelText('تعداد کد یکتا'), { target: { value: '2' } }) fireEvent.change(screen.getByLabelText('تعداد کد یکتا'), { target: { value: '2' } })
@ -168,8 +157,6 @@ describe('EventDiscountsPanel', () => {
) )
await screen.findByText('WELCOME20') await screen.findByText('WELCOME20')
bulkCreate.mockClear()
addToast.mockClear()
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '150' } }) fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '150' } })
fireEvent.click(screen.getByRole('button', { name: 'ساخت کد تخفیف' })) fireEvent.click(screen.getByRole('button', { name: 'ساخت کد تخفیف' }))

View File

@ -18,7 +18,6 @@ import {
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP, GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
SET_DISCOUNT_CODE_ACTIVE, SET_DISCOUNT_CODE_ACTIVE,
} from '@/services/discountCodes' } from '@/services/discountCodes'
import useAlertModal from '@/hooks/useAlertModal'
interface EventDiscountsPanelProps { interface EventDiscountsPanelProps {
eventId: string eventId: string
@ -52,7 +51,6 @@ const DISCOUNT_BEARER_OPTIONS = [
] as const ] as const
const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => { const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => {
const { showAlert } = useAlertModal()
const [codes, setCodes] = useState<DiscountCode[]>([]) const [codes, setCodes] = useState<DiscountCode[]>([])
const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([]) const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([])
const [report, setReport] = useState<DiscountReport | null>(null) const [report, setReport] = useState<DiscountReport | null>(null)
@ -98,7 +96,26 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
const canCreate = !isFree && !isEnded const canCreate = !isFree && !isEnded
const canMutateCodes = !isEnded const canMutateCodes = !isEnded
const executeCreate = async (numericValue: number, numericQuantity: number) => { const handleCreate = async () => {
const numericValue = toInt(value)
const numericQuantity = toInt(quantity)
if (!numericValue) {
addToast({ title: texts.events.discountValueRequired, color: 'warning' })
return
}
if (type === 'percent' && (numericValue < 1 || numericValue > 99)) {
addToast({ title: texts.events.discountPercentRange, color: 'warning' })
return
}
if (!numericQuantity) {
addToast({ title: texts.events.discountCountRequired, color: 'warning' })
return
}
setIsCreating(true) setIsCreating(true)
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, { const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
type, type,
@ -126,39 +143,13 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
await load() await load()
} }
const handleCreate = () => { const handleToggleActive = async (code: DiscountCode, nextActive: boolean) => {
const numericValue = toInt(value)
const numericQuantity = toInt(quantity)
if (!numericValue) {
addToast({ title: texts.events.discountValueRequired, color: 'warning' })
return
}
if (type === 'percent' && (numericValue < 1 || numericValue > 99)) {
addToast({ title: texts.events.discountPercentRange, color: 'warning' })
return
}
if (!numericQuantity) {
addToast({ title: texts.events.discountCountRequired, color: 'warning' })
return
}
showAlert(`${number(numericQuantity)} کد تخفیف ساخته شود؟`, () => {
void executeCreate(numericValue, numericQuantity)
})
}
const handleToggleActive = (code: DiscountCode, nextActive: boolean) => {
if (!canMutateCodes) { if (!canMutateCodes) {
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' }) addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
return return
} }
showAlert(nextActive ? `کد «${code.code}» فعال شود؟` : `کد «${code.code}» غیرفعال شود؟`, async () => {
setPendingCodeId(code.id) setPendingCodeId(code.id)
const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive) const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive)
@ -171,10 +162,9 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
} }
setCodes((current) => current.map((item) => (item.id === code.id ? { ...item, isActive: nextActive } : item))) setCodes((current) => current.map((item) => (item.id === code.id ? { ...item, isActive: nextActive } : item)))
})
} }
const handleDelete = (code: DiscountCode) => { const handleDelete = async (code: DiscountCode) => {
if (!canMutateCodes) { if (!canMutateCodes) {
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' }) addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
@ -187,9 +177,6 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
return return
} }
showAlert(
`کد تخفیف «${code.code}» حذف شود؟`,
async () => {
setPendingCodeId(code.id) setPendingCodeId(code.id)
const result = await DELETE_DISCOUNT_CODE(code.id) const result = await DELETE_DISCOUNT_CODE(code.id)
@ -203,10 +190,6 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
addToast({ title: texts.events.discountDeleted, color: 'success' }) addToast({ title: texts.events.discountDeleted, color: 'success' })
setCodes((current) => current.filter((item) => item.id !== code.id)) setCodes((current) => current.filter((item) => item.id !== code.id))
},
undefined,
{ dangerAccept: true }
)
} }
const copyCodes = async (list: string[]) => { const copyCodes = async (list: string[]) => {
@ -475,9 +458,7 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
label="" label=""
name={`code-active-${code.id}`} name={`code-active-${code.id}`}
value={code.isActive} value={code.isActive}
onValueChange={(next) => { onValueChange={(next) => void handleToggleActive(code, Boolean(next))}
handleToggleActive(code, Boolean(next))
}}
/> />
<span className="text-xs text-secondary-30"> <span className="text-xs text-secondary-30">
{isEnded ? texts.events.eventEndedShort : code.isActive ? texts.events.availableToGuests : texts.events.deactivated} {isEnded ? texts.events.eventEndedShort : code.isActive ? texts.events.availableToGuests : texts.events.deactivated}
@ -488,9 +469,7 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
disabled={!canMutateCodes || code.redeemedCount > 0 || pendingCodeId === code.id} disabled={!canMutateCodes || code.redeemedCount > 0 || pendingCodeId === code.id}
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => { onClick={() => void handleDelete(code)}
handleDelete(code)
}}
> >
{texts.common.delete} {texts.common.delete}
</Button> </Button>

View File

@ -19,7 +19,6 @@ import { checkInBookingAsAdmin } from '@/services/eventManagement'
import { API_ROUTES } from '@/services/config' import { API_ROUTES } from '@/services/config'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import useAdminAction from '@/hooks/useAdminAction' import useAdminAction from '@/hooks/useAdminAction'
import useAlertModal from '@/hooks/useAlertModal'
// Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx, // Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx,
// minus the `eventId` column (this list is already scoped to one event via // minus the `eventId` column (this list is already scoped to one event via
@ -35,7 +34,6 @@ const bookingsColumns: PaginationListColumnType[] = [
{ field: 'bookingCode', label: 'کد رزرو', filterable: false, sortable: false, type: 'text' }, { field: 'bookingCode', label: 'کد رزرو', filterable: false, sortable: false, type: 'text' },
{ field: 'userId', label: 'مهمان', filterable: false, sortable: false, type: 'text' }, { field: 'userId', label: 'مهمان', filterable: false, sortable: false, type: 'text' },
{ field: 'status', label: 'وضعیت', filterable: true, sortable: true, type: 'select', filterItems: BOOKING_STATUS_FILTER_ITEMS }, { field: 'status', label: 'وضعیت', filterable: true, sortable: true, type: 'select', filterItems: BOOKING_STATUS_FILTER_ITEMS },
{ field: 'cancellationReason', label: 'دلیل لغو', filterable: false, sortable: false, type: 'text' },
{ field: 'checkedInAt', label: 'چک‌این', filterable: false, sortable: false, type: 'date' }, { field: 'checkedInAt', label: 'چک‌این', filterable: false, sortable: false, type: 'date' },
{ field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true, type: 'date' }, { field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true, type: 'date' },
{ field: 'actions', label: 'عملیات' }, { field: 'actions', label: 'عملیات' },
@ -50,10 +48,8 @@ interface AdminEventBookingsTabProps {
const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => { const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => {
const bookingsListRef = useRef<PaginatedListHandle>(null) const bookingsListRef = useRef<PaginatedListHandle>(null)
const { pendingId, runAction } = useAdminAction() const { pendingId, runAction } = useAdminAction()
const { showAlert } = useAlertModal()
const handleCheckIn = (bookingId: string) => { const handleCheckIn = (bookingId: string) =>
showAlert('حضور این مهمان ثبت شود؟', () =>
runAction( runAction(
bookingId, bookingId,
async () => { async () => {
@ -63,8 +59,6 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
}, },
'حضور مهمان ثبت شد' 'حضور مهمان ثبت شد'
) )
)
}
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -119,14 +113,6 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
/> />
) )
}, },
cancellationReason: (row, cellValue) => {
const reason = coerceToString(cellValue).trim()
const isCancelledLike = row.status === 'cancelled' || row.status === 'refunded'
if (!isCancelledLike || !reason) return '—'
return <span className="max-w-[220px] whitespace-pre-wrap break-words text-xs text-secondary-20">{reason}</span>
},
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue), checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue), createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => { actions: (row) => {
@ -151,9 +137,7 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
isLoading={pendingId === bookingId} isLoading={pendingId === bookingId}
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => { onClick={() => void handleCheckIn(bookingId)}
handleCheckIn(bookingId)
}}
> >
<FileCheckIcon className="size-4" /> <FileCheckIcon className="size-4" />
</Button> </Button>

View File

@ -7,12 +7,8 @@ import AdminEventCommissionModal from '@/features/events/detail/admin-event-deta
const updateCommission = vi.fn() const updateCommission = vi.fn()
const addToast = vi.fn() const addToast = vi.fn()
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) })) vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
vi.mock('@/services/events', () => ({ vi.mock('@/services/events', () => ({
updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args), updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args),
})) }))
@ -77,9 +73,6 @@ describe('AdminEventCommissionModal', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
updateCommission.mockResolvedValue({ updateCommission.mockResolvedValue({
id: 'event-1', id: 'event-1',
commissionPercent: 12, commissionPercent: 12,

View File

@ -10,7 +10,6 @@ import Modal from '@/components/modals/Modal'
import { coerceToString } from '@/helpers' import { coerceToString } from '@/helpers'
import { updateEventCommissionAsAdmin } from '@/services/events' import { updateEventCommissionAsAdmin } from '@/services/events'
import useAdminAction from '@/hooks/useAdminAction' import useAdminAction from '@/hooks/useAdminAction'
import useAlertModal from '@/hooks/useAlertModal'
interface AdminEventCommissionModalProps { interface AdminEventCommissionModalProps {
eventId: string eventId: string
@ -24,7 +23,6 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
const [commissionInput, setCommissionInput] = useState('') const [commissionInput, setCommissionInput] = useState('')
const [commissionError, setCommissionError] = useState<string | null>(null) const [commissionError, setCommissionError] = useState<string | null>(null)
const { pendingId, runAction } = useAdminAction() const { pendingId, runAction } = useAdminAction()
const { showAlert } = useAlertModal()
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@ -42,8 +40,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
return return
} }
showAlert(`کمیسیون این رویداد به ${parsed}٪ تغییر کند؟`, () => void runAction(
runAction(
'commission', 'commission',
async () => { async () => {
const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData
@ -53,11 +50,9 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
}, },
'کمیسیون رویداد به‌روزرسانی شد' 'کمیسیون رویداد به‌روزرسانی شد'
) )
)
} }
const handleResetCommission = () => { const handleResetCommission = () =>
showAlert('کمیسیون این رویداد به پیش‌فرض پلتفرم بازگردد؟', () =>
runAction( runAction(
'commission', 'commission',
async () => { async () => {
@ -68,8 +63,6 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
}, },
'کمیسیون رویداد به پیش‌فرض پلتفرم بازگشت' 'کمیسیون رویداد به پیش‌فرض پلتفرم بازگشت'
) )
)
}
return ( return (
<Modal <Modal
@ -82,9 +75,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
isLoading={pendingId === 'commission'} isLoading={pendingId === 'commission'}
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => { onClick={() => void handleResetCommission()}
handleResetCommission()
}}
> >
بازگشت به پیشفرض پلتفرم بازگشت به پیشفرض پلتفرم
</Button> </Button>

View File

@ -4,8 +4,6 @@ import type { AdminEventDetailData } from './types'
import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { texts } from '@/texts'
import AdminEventLifecycleActions from './AdminEventLifecycleActions' import AdminEventLifecycleActions from './AdminEventLifecycleActions'
vi.mock('@/components/formElements/Button', () => ({ vi.mock('@/components/formElements/Button', () => ({
@ -30,9 +28,10 @@ const baseEvent = {
afterEach(cleanup) afterEach(cleanup)
describe('AdminEventLifecycleActions', () => { describe('AdminEventLifecycleActions', () => {
it('exposes approve/reject for pending_review and hides force-publish', () => { it('exposes approve/reject/force-publish for pending_review events', () => {
const onApprove = vi.fn() const onApprove = vi.fn()
const onOpenReject = vi.fn() const onOpenReject = vi.fn()
const onForcePublish = vi.fn()
render( render(
<AdminEventLifecycleActions <AdminEventLifecycleActions
@ -42,63 +41,18 @@ describe('AdminEventLifecycleActions', () => {
onCancel={vi.fn()} onCancel={vi.fn()}
onComplete={vi.fn()} onComplete={vi.fn()}
onDelete={vi.fn()} onDelete={vi.fn()}
onForcePublish={vi.fn()} onForcePublish={onForcePublish}
onOpenReject={onOpenReject} onOpenReject={onOpenReject}
/> />
) )
fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' })) fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' }))
fireEvent.click(screen.getByRole('button', { name: 'رد' })) fireEvent.click(screen.getByRole('button', { name: 'رد' }))
expect(onApprove).toHaveBeenCalledTimes(1)
expect(onOpenReject).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
expect(screen.getByText(texts.events.adminLifecycleHintPendingReview)).toBeTruthy()
})
it('exposes approve and force-publish for unapproved draft events', () => {
const onApprove = vi.fn()
const onForcePublish = vi.fn()
render(
<AdminEventLifecycleActions
event={{ ...baseEvent, status: 'draft', adminApprovedAt: null }}
pendingId={null}
onApprove={onApprove}
onCancel={vi.fn()}
onComplete={vi.fn()}
onDelete={vi.fn()}
onForcePublish={onForcePublish}
onOpenReject={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'تأیید برای انتشار' }))
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' })) fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
expect(onApprove).toHaveBeenCalledTimes(1) expect(onApprove).toHaveBeenCalledTimes(1)
expect(onOpenReject).toHaveBeenCalledTimes(1)
expect(onForcePublish).toHaveBeenCalledTimes(1) expect(onForcePublish).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
expect(screen.getByText(texts.events.adminLifecycleHintDraftUnapproved)).toBeTruthy()
})
it('keeps force-publish for approved drafts waiting on the host', () => {
render(
<AdminEventLifecycleActions
event={{ ...baseEvent, status: 'draft', adminApprovedAt: '2026-01-01T00:00:00.000Z' }}
pendingId={null}
onApprove={vi.fn()}
onCancel={vi.fn()}
onComplete={vi.fn()}
onDelete={vi.fn()}
onForcePublish={vi.fn()}
onOpenReject={vi.fn()}
/>
)
expect(screen.getByText('تأییدشده؛ منتظر میزبان')).toBeTruthy()
expect(screen.getByRole('button', { name: 'انتشار فوری' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'تأیید برای انتشار' })).toBeNull()
}) })
it('exposes complete/cancel for published events and hides reject', () => { it('exposes complete/cancel for published events and hides reject', () => {
@ -119,6 +73,5 @@ describe('AdminEventLifecycleActions', () => {
expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy() expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull() expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull() expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull()
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
}) })
}) })

View File

@ -4,9 +4,6 @@ import type { AdminEventDetailData } from './types'
import Button from '@/components/formElements/Button' import Button from '@/components/formElements/Button'
import { APP_ROUTES } from '@/constants/routes' import { APP_ROUTES } from '@/constants/routes'
import { texts } from '@/texts'
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
interface AdminEventLifecycleActionsProps { interface AdminEventLifecycleActionsProps {
event: AdminEventDetailData event: AdminEventDetailData
@ -28,11 +25,7 @@ const AdminEventLifecycleActions = ({
onComplete, onComplete,
onCancel, onCancel,
onDelete, onDelete,
}: AdminEventLifecycleActionsProps) => { }: AdminEventLifecycleActionsProps) => (
const hint = resolveAdminEventLifecycleHint(event)
return (
<div className="flex max-w-full flex-col items-end gap-1">
<div className="flex max-w-full flex-nowrap items-center justify-end gap-2 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <div className="flex max-w-full flex-nowrap items-center justify-end gap-2 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{['draft', 'pending_review', 'published', 'full'].includes(event.status) ? ( {['draft', 'pending_review', 'published', 'full'].includes(event.status) ? (
<Button <Button
@ -78,9 +71,9 @@ const AdminEventLifecycleActions = ({
</Button> </Button>
</> </>
) : null} ) : null}
{event.status === 'draft' ? ( {event.status === 'draft' || event.status === 'pending_review' ? (
<Button <Button
aria-label={texts.events.adminLifecycleTitleForcePublish} aria-label="انتشار فوری بدون تایید میزبان"
isLoading={pendingId === 'force-publish'} isLoading={pendingId === 'force-publish'}
size="sm" size="sm"
variant="flat" variant="flat"
@ -124,9 +117,6 @@ const AdminEventLifecycleActions = ({
</Button> </Button>
) : null} ) : null}
</div> </div>
{hint ? <p className="max-w-[min(72vw,28rem)] text-right text-xs leading-5 text-text-muted">{hint}</p> : null}
</div>
) )
}
export default AdminEventLifecycleActions export default AdminEventLifecycleActions

View File

@ -48,6 +48,10 @@ const AdminEventOverviewTab = ({ insights }: AdminEventOverviewTabProps) => {
label="بازپرداخت‌شده" label="بازپرداخت‌شده"
value={number(insights.registrations.refundedCount)} value={number(insights.registrations.refundedCount)}
/> />
<InsightStat
label="غایب"
value={number(insights.registrations.noShowCount)}
/>
<InsightStat <InsightStat
label="ظرفیت باقی‌مانده" label="ظرفیت باقی‌مانده"
value={number(insights.registrations.remainingCapacity)} value={number(insights.registrations.remainingCapacity)}

View File

@ -13,8 +13,7 @@ interface AdminEventPendingRevisionCardProps {
} }
const AdminEventPendingRevisionCard = ({ pendingId, revision, onApprove, onReject }: AdminEventPendingRevisionCardProps) => { const AdminEventPendingRevisionCard = ({ pendingId, revision, onApprove, onReject }: AdminEventPendingRevisionCardProps) => {
const { title, slug, faqs } = revision.payload const { title, slug } = revision.payload
const proposedFaqs = Array.isArray(faqs) ? faqs : []
return ( return (
<Card className="admin-surface"> <Card className="admin-surface">
@ -44,21 +43,6 @@ const AdminEventPendingRevisionCard = ({ pendingId, revision, onApprove, onRejec
) : null} ) : null}
</dl> </dl>
{proposedFaqs.length > 0 ? (
<div className="space-y-2 text-right">
<p className="text-xs text-secondary-30">سوالات متداول پیشنهادی ({proposedFaqs.length.toLocaleString('fa-IR')})</p>
{proposedFaqs.map((faq, index) => (
<div
key={`${faq.question}-${index}`}
className="rounded-xl border border-secondary-40 bg-secondary-50/60 p-3"
>
<h3 className="text-sm font-bold text-secondary-20">{faq.question}</h3>
<p className="mt-1 whitespace-pre-line text-xs leading-6 text-secondary-30">{faq.answer}</p>
</div>
))}
</div>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
<Button <Button
isLoading={pendingId === 'approve-revision'} isLoading={pendingId === 'approve-revision'}

View File

@ -1,37 +0,0 @@
import type { AdminEventDetailData } from './types'
import { describe, expect, it } from 'vitest'
import { texts } from '@/texts'
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
const baseEvent = {
id: 'event-1',
adminApprovedAt: null,
bookedCount: 0,
} as AdminEventDetailData
describe('resolveAdminEventLifecycleHint', () => {
it('explains approve vs force-publish on unapproved drafts', () => {
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'draft', adminApprovedAt: null })).toBe(
texts.events.adminLifecycleHintDraftUnapproved
)
})
it('explains waiting on the host after admin approval', () => {
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'draft', adminApprovedAt: '2026-01-01T00:00:00.000Z' })).toBe(
texts.events.adminLifecycleHintDraftApproved
)
})
it('explains pending-review approval', () => {
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'pending_review' })).toBe(texts.events.adminLifecycleHintPendingReview)
})
it('explains complete and cancel on live events', () => {
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'published', bookedCount: 2 })).toBe(
texts.events.adminLifecycleHintPublished
)
})
})

View File

@ -1,28 +0,0 @@
import type { AdminEventDetailData } from './types'
import { texts } from '@/texts'
/** راهنمای کوتاه اکشن‌های lifecycle — بسته به وضعیت فعلی رویداد */
export const resolveAdminEventLifecycleHint = (event: AdminEventDetailData): string | null => {
if (event.status === 'draft' && !event.adminApprovedAt) {
return texts.events.adminLifecycleHintDraftUnapproved
}
if (event.status === 'draft' && event.adminApprovedAt) {
return texts.events.adminLifecycleHintDraftApproved
}
if (event.status === 'pending_review') {
return texts.events.adminLifecycleHintPendingReview
}
if (event.status === 'published' || event.status === 'full') {
return texts.events.adminLifecycleHintPublished
}
if (event.bookedCount === 0) {
return texts.events.adminLifecycleHintDeleteOnly
}
return null
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "ghabilee-admin", "name": "ghabilee-admin",
"version": "0.1.18", "version": "0.1.14",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build and publish the production admin image in Gitea Actions. # Build and publish the production admin image in GitHub Actions.
# Build-time public variables are read from the VPS .env file and never logged. # Build-time public variables are read from the VPS .env file and never logged.
set -euo pipefail set -euo pipefail

View File

@ -1,141 +0,0 @@
#!/usr/bin/env bash
# Build admin image on the Finland host, then load+run it on Iran.
#
# Architecture:
# - Finland (git.ghabilee.ir / act_runner): build + Telegram notify
# - Iran: runtime only (/opt/ghabilee-admin)
set -euo pipefail
: "${FINLAND_SSH_KEY:?}"
: "${FINLAND_HOST:?}"
: "${FINLAND_USER:?}"
: "${IRAN_SSH_KEY:?}"
: "${IRAN_HOST:?}"
: "${IRAN_USER:?}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
IMAGE_TAG="${IMAGE_TAG:-ghabilee-admin:$(git rev-parse --short HEAD 2>/dev/null || date +%s)}"
FINLAND_APP="/opt/ghabilee-admin-ci"
IRAN_APP="/opt/ghabilee-admin"
install -m 700 -d "$HOME/.ssh"
printf '%s\n' "$FINLAND_SSH_KEY" > "$HOME/.ssh/finland_key"
printf '%s\n' "$IRAN_SSH_KEY" > "$HOME/.ssh/iran_key"
chmod 600 "$HOME/.ssh/finland_key" "$HOME/.ssh/iran_key"
trap 'rm -f "$HOME/.ssh/finland_key" "$HOME/.ssh/iran_key"' EXIT
FSSH=(ssh -i "$HOME/.ssh/finland_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new)
ISSH=(ssh -i "$HOME/.ssh/iran_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new)
FRSYNC=(-e "ssh -i $HOME/.ssh/finland_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new")
# --- build env from Iran (runtime .env holds NEXT_PUBLIC_* bake inputs) ---
"${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" "test -s ${IRAN_APP}/.env"
scp -i "$HOME/.ssh/iran_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${IRAN_USER}@${IRAN_HOST}:${IRAN_APP}/.env" .env.production
test -s .env.production
# --- sync sources + env to Finland build dir ---
"${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" "install -d -m 0750 '${FINLAND_APP}/src'"
rsync -az --delete "${FRSYNC[@]}" \
--exclude '.git' \
--exclude 'node_modules' \
--exclude '.next' \
--exclude '.env' \
--exclude '.env.*' \
--exclude 'test-results' \
--exclude 'playwright-report' \
./ "${FINLAND_USER}@${FINLAND_HOST}:${FINLAND_APP}/src/"
scp -i "$HOME/.ssh/finland_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
.env.production "${FINLAND_USER}@${FINLAND_HOST}:${FINLAND_APP}/.env"
rm -f .env.production
# --- build on Finland (enough RAM; Telegram/git live here too) ---
"${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" \
"IMAGE_TAG='${IMAGE_TAG}' APP_DIR='${FINLAND_APP}' SRC_DIR='${FINLAND_APP}/src' bash -s" <<'REMOTE'
set -euo pipefail
cd "$APP_DIR"
# noglob: backend-style cron values must never expand if present in .env
set -f
# shellcheck disable=SC1091
set -a
# shellcheck disable=SC1090
source "$APP_DIR/.env"
set +a
set +f
env_or_empty() { printf '%s' "${!1-}"; }
sanitize_api_proxy_target() {
local target
target="$(env_or_empty API_PROXY_TARGET)"
case "$target" in
*127.0.0.1*|*localhost*|*'::1'*) printf '' ;;
*) printf '%s' "$target" ;;
esac
}
# Avoid glob expansion when sourcing cron-like values already done; build args only.
docker build --platform linux/amd64 -t "$IMAGE_TAG" \
--build-arg "NEXT_PUBLIC_API_URL=$(env_or_empty NEXT_PUBLIC_API_URL)" \
--build-arg "NEXT_PUBLIC_FILE_SERVER_URL=$(env_or_empty NEXT_PUBLIC_FILE_SERVER_URL)" \
--build-arg "MAP_API_KEY=$(env_or_empty MAP_API_KEY)" \
--build-arg "NEXT_PUBLIC_MAP_API_KEY=$(env_or_empty NEXT_PUBLIC_MAP_API_KEY)" \
--build-arg "NEXT_PUBLIC_VAPID_PUBLIC_KEY=$(env_or_empty NEXT_PUBLIC_VAPID_PUBLIC_KEY)" \
--build-arg "NEXT_PUBLIC_BASE_PATH=$(env_or_empty NEXT_PUBLIC_BASE_PATH)" \
--build-arg "NEXT_PUBLIC_SITE_URL=$(env_or_empty NEXT_PUBLIC_SITE_URL)" \
--build-arg "NEXT_PUBLIC_OBSERVABILITY_ENDPOINT=$(env_or_empty NEXT_PUBLIC_OBSERVABILITY_ENDPOINT)" \
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$(env_or_empty NEXT_PUBLIC_SENTRY_DSN)" \
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=$(env_or_empty NEXT_PUBLIC_SENTRY_ENVIRONMENT)" \
--build-arg "NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=$(env_or_empty NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)" \
--build-arg "NEXT_PUBLIC_ARCAPTCHA_SITE_KEY=$(env_or_empty NEXT_PUBLIC_ARCAPTCHA_SITE_KEY)" \
--build-arg "SENTRY_AUTH_TOKEN=$(env_or_empty SENTRY_AUTH_TOKEN)" \
--build-arg "SENTRY_ORG=$(env_or_empty SENTRY_ORG)" \
--build-arg "SENTRY_PROJECT=$(env_or_empty SENTRY_PROJECT)" \
--build-arg "API_PROXY_TARGET=$(sanitize_api_proxy_target)" \
"$SRC_DIR"
docker image prune -af >/dev/null 2>&1 || true
echo "BUILT $IMAGE_TAG"
REMOTE
# --- stream image Finland → Iran, then compose up ---
"${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" "install -d -m 0750 '${IRAN_APP}'"
scp -i "$HOME/.ssh/iran_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
deploy/docker-compose.production.yml \
"${IRAN_USER}@${IRAN_HOST}:${IRAN_APP}/docker-compose.yml"
if [[ -f deploy/nginx/backoffice.conf ]]; then
scp -i "$HOME/.ssh/iran_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
deploy/nginx/backoffice.conf \
"${IRAN_USER}@${IRAN_HOST}:/etc/nginx/sites-available/backoffice"
"${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" '
ln -sfn /etc/nginx/sites-available/backoffice /etc/nginx/sites-enabled/backoffice
nginx -t && systemctl reload nginx
'
fi
echo "Transferring ${IMAGE_TAG} Finland → Iran..."
"${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" "docker save '${IMAGE_TAG}'" \
| "${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" "docker load"
"${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" \
"ADMIN_IMAGE='${IMAGE_TAG}' bash -s" <<'REMOTE'
set -euo pipefail
cd /opt/ghabilee-admin
test -s .env
ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin
for attempt in $(seq 1 36); do
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)"
echo "Admin health ${attempt}/36: ${health}"
if [ "$health" = healthy ]; then
docker image prune -af >/dev/null 2>&1 || true
echo "Deploy OK"
exit 0
fi
case "$health" in unhealthy|exited|dead|missing) exit 1 ;; esac
sleep 5
done
exit 1
REMOTE

View File

@ -1,98 +0,0 @@
#!/usr/bin/env bash
# Build and run admin on the VPS (idempotent).
# Expects app sources in APP_DIR (CI rsync) and a filled APP_DIR/.env.
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/ghabilee-admin}"
SRC_DIR="${SRC_DIR:-$APP_DIR/src}"
IMAGE_TAG="${IMAGE_TAG:-ghabilee-admin:local}"
cd "$APP_DIR"
if [ ! -f "$APP_DIR/.env" ]; then
echo "Missing $APP_DIR/.env — copy from .env.example and fill production values." >&2
exit 2
fi
if [ ! -f "$SRC_DIR/Dockerfile" ]; then
echo "Missing $SRC_DIR/Dockerfile — rsync the repo before deploy." >&2
exit 2
fi
install -m 0644 "$SRC_DIR/deploy/docker-compose.production.yml" "$APP_DIR/docker-compose.yml"
if [ -f "$SRC_DIR/deploy/nginx/backoffice.conf" ]; then
install -m 0644 "$SRC_DIR/deploy/nginx/backoffice.conf" /etc/nginx/sites-available/backoffice
ln -sfn /etc/nginx/sites-available/backoffice /etc/nginx/sites-enabled/backoffice
nginx -t
systemctl reload nginx
fi
# shellcheck disable=SC1091
set -a
# shellcheck disable=SC1090
source "$APP_DIR/.env"
set +a
env_or_empty() {
local key="$1"
printf '%s' "${!key-}"
}
sanitize_api_proxy_target() {
local target
target="$(env_or_empty API_PROXY_TARGET)"
case "$target" in
*127.0.0.1*|*localhost*|*'::1'*)
echo "Warning: refusing loopback API_PROXY_TARGET for production image build" >&2
printf ''
;;
*)
printf '%s' "$target"
;;
esac
}
BUILD_ARGS=(
--build-arg "NEXT_PUBLIC_API_URL=$(env_or_empty NEXT_PUBLIC_API_URL)"
--build-arg "NEXT_PUBLIC_FILE_SERVER_URL=$(env_or_empty NEXT_PUBLIC_FILE_SERVER_URL)"
--build-arg "MAP_API_KEY=$(env_or_empty MAP_API_KEY)"
--build-arg "NEXT_PUBLIC_MAP_API_KEY=$(env_or_empty NEXT_PUBLIC_MAP_API_KEY)"
--build-arg "NEXT_PUBLIC_VAPID_PUBLIC_KEY=$(env_or_empty NEXT_PUBLIC_VAPID_PUBLIC_KEY)"
--build-arg "NEXT_PUBLIC_BASE_PATH=$(env_or_empty NEXT_PUBLIC_BASE_PATH)"
--build-arg "NEXT_PUBLIC_SITE_URL=$(env_or_empty NEXT_PUBLIC_SITE_URL)"
--build-arg "NEXT_PUBLIC_OBSERVABILITY_ENDPOINT=$(env_or_empty NEXT_PUBLIC_OBSERVABILITY_ENDPOINT)"
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$(env_or_empty NEXT_PUBLIC_SENTRY_DSN)"
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=$(env_or_empty NEXT_PUBLIC_SENTRY_ENVIRONMENT)"
--build-arg "NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=$(env_or_empty NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)"
--build-arg "NEXT_PUBLIC_ARCAPTCHA_SITE_KEY=$(env_or_empty NEXT_PUBLIC_ARCAPTCHA_SITE_KEY)"
--build-arg "SENTRY_AUTH_TOKEN=$(env_or_empty SENTRY_AUTH_TOKEN)"
--build-arg "SENTRY_ORG=$(env_or_empty SENTRY_ORG)"
--build-arg "SENTRY_PROJECT=$(env_or_empty SENTRY_PROJECT)"
--build-arg "API_PROXY_TARGET=$(sanitize_api_proxy_target)"
)
echo "Building ${IMAGE_TAG} on VPS (build arguments redacted)"
docker build \
--platform linux/amd64 \
-t "$IMAGE_TAG" \
"${BUILD_ARGS[@]}" \
"$SRC_DIR"
ADMIN_IMAGE="$IMAGE_TAG" docker compose -f "$APP_DIR/docker-compose.yml" up -d --no-deps ghabilee-admin
for attempt in $(seq 1 36); do
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)"
echo "Admin health ${attempt}/36: ${health}"
if [ "$health" = "healthy" ]; then
docker image prune -af >/dev/null 2>&1 || true
echo "Deploy OK"
exit 0
fi
case "$health" in
unhealthy|exited|dead|missing) exit 1 ;;
esac
sleep 5
done
echo "Timed out waiting for healthy admin container" >&2
exit 1

View File

@ -1,50 +0,0 @@
#!/usr/bin/env python3
"""Print TELEGRAM_* assignments from known VPS env files (stdout only)."""
from __future__ import annotations
from pathlib import Path
WANTED = (
'TELEGRAM_BOT_TOKEN',
'TELEGRAM_GROUP_CHAT_ID',
'TELEGRAM_GROUP_THREAD_ID',
'TELEGRAM_CHAT_ID',
)
PATHS = (
Path('/opt/ghabilee-backend/.env'),
Path('/opt/ghabilee/backend/.env'),
Path('/opt/ghabilee-admin/.env'),
)
def main() -> None:
found: dict[str, str] = {}
for path in PATHS:
if not path.is_file() or path.stat().st_size == 0:
continue
for raw in path.read_text(encoding='utf-8', errors='replace').splitlines():
line = raw.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
if key not in WANTED or key in found:
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
# Inline comments in .env (e.g. THREAD_ID=8 # ops)
if ' #' in f' {value}':
value = value.split('#', 1)[0].rstrip()
value = value.replace('\n', '').replace('\r', '')
found[key] = value
if 'TELEGRAM_BOT_TOKEN' in found:
break
for key in WANTED:
if key in found and found[key]:
print(f'{key}={found[key]}')
if __name__ == '__main__':
main()

View File

@ -1,16 +0,0 @@
#!/usr/bin/env bash
# Notify ops Telegram from the Finland Actions runner (not from Iran).
# Credentials come from Gitea Actions secrets — same model as telegrambot.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
: "${TELEGRAM_BOT_TOKEN:?TELEGRAM_BOT_TOKEN secret required}"
if [[ -z "${TELEGRAM_GROUP_CHAT_ID:-}${TELEGRAM_CHAT_ID:-}" ]]; then
echo "[ghabilee-admin-notify] failed: set TELEGRAM_GROUP_CHAT_ID or TELEGRAM_CHAT_ID" >&2
exit 1
fi
chmod +x "${SCRIPT_DIR}/notify-ops-telegram.sh" "${SCRIPT_DIR}/notify-deploy.sh"
bash "${SCRIPT_DIR}/notify-deploy.sh"

View File

@ -1,11 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Load Telegram credentials from the Iran VPS .env, then send from this host. # Send deploy notification from VPS (Telegram creds from /opt/ghabilee-backend/.env).
# # GitHub runners may not reach api.telegram.org; VPS can.
# Why not notify on the VPS?
# - Iran egress often cannot reach api.telegram.org
# - Sourcing the full backend .env breaks on cron globs (BOOKING_EXPIRY_CRON=*)
#
# Gitea act_runner (foreign) can reach Telegram; we only SSH to fetch TELEGRAM_* keys.
set -euo pipefail set -euo pipefail
: "${SSH_KEY:?SSH_KEY required}" : "${SSH_KEY:?SSH_KEY required}"
@ -13,7 +8,8 @@ set -euo pipefail
: "${VPS_USER:?VPS_USER required}" : "${VPS_USER:?VPS_USER required}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXTRACTOR="${SCRIPT_DIR}/extract-telegram-env.py" REMOTE_DIR="/tmp/ghabilee-notify-$$"
COMMIT_SUBJECT_B64="$(printf '%s' "${DEPLOY_COMMIT_SUBJECT:-}" | base64 | tr -d '\n')"
install -m 700 -d "$HOME/.ssh" install -m 700 -d "$HOME/.ssh"
printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key" printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key"
@ -21,26 +17,32 @@ chmod 600 "$HOME/.ssh/vps_key"
trap 'rm -f "$HOME/.ssh/vps_key"' EXIT trap 'rm -f "$HOME/.ssh/vps_key"' EXIT
SSH_OPTS=(-i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new) SSH_OPTS=(-i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new)
REMOTE_EXTRACT="/tmp/ghabilee-extract-telegram-env-$$.py" SCP_OPTS=("${SSH_OPTS[@]}")
scp "${SSH_OPTS[@]}" "$EXTRACTOR" "${VPS_USER}@${VPS_HOST}:${REMOTE_EXTRACT}" ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" "mkdir -p '${REMOTE_DIR}'"
scp "${SCP_OPTS[@]}" \
"${SCRIPT_DIR}/notify-ops-telegram.sh" \
"${SCRIPT_DIR}/notify-deploy.sh" \
"${VPS_USER}@${VPS_HOST}:${REMOTE_DIR}/"
while IFS= read -r line; do ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" \
[[ -z "$line" || "$line" != TELEGRAM_*=* ]] && continue "DEPLOY_SHA='${DEPLOY_SHA:-}' DEPLOY_STATUS='${DEPLOY_STATUS:-success}' DEPLOY_VERSION='${DEPLOY_VERSION:-}' COMMIT_SUBJECT_B64='${COMMIT_SUBJECT_B64}' REMOTE_DIR='${REMOTE_DIR}' sh -s" <<'REMOTE'
key="${line%%=*}" set -eu
value="${line#*=}" if [ -n "${COMMIT_SUBJECT_B64:-}" ]; then
export "${key}=${value}" DEPLOY_COMMIT_SUBJECT="$(printf '%s' "$COMMIT_SUBJECT_B64" | base64 -d 2>/dev/null || true)"
done < <(ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" "python3 '${REMOTE_EXTRACT}'; rm -f '${REMOTE_EXTRACT}'") export DEPLOY_COMMIT_SUBJECT
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" ]]; then
echo "[ghabilee-admin-notify] failed: TELEGRAM_BOT_TOKEN missing on VPS" >&2
exit 1
fi fi
if [[ -z "${TELEGRAM_GROUP_CHAT_ID:-}${TELEGRAM_CHAT_ID:-}" ]]; then set -a
echo "[ghabilee-admin-notify] failed: no Telegram chat id on VPS" >&2 if [ -s /opt/ghabilee-backend/.env ]; then
exit 1 # shellcheck disable=SC1091
. /opt/ghabilee-backend/.env
elif [ -s /opt/ghabilee/backend/.env ]; then
# shellcheck disable=SC1091
. /opt/ghabilee/backend/.env
fi fi
set +a
chmod +x "${SCRIPT_DIR}/notify-ops-telegram.sh" "${SCRIPT_DIR}/notify-deploy.sh" export DEPLOY_SHA DEPLOY_STATUS DEPLOY_COMMIT_SUBJECT DEPLOY_VERSION
# Run on the Actions runner (foreign) — not on the Iran VPS. chmod +x "${REMOTE_DIR}/notify-ops-telegram.sh" "${REMOTE_DIR}/notify-deploy.sh"
bash "${SCRIPT_DIR}/notify-deploy.sh" bash "${REMOTE_DIR}/notify-deploy.sh"
rm -rf "${REMOTE_DIR}"
REMOTE

View File

@ -9,9 +9,8 @@ export type EventFaq = EventFaqResponseDto
const eventExtrasApi = getEventExtras() const eventExtrasApi = getEventExtras()
/** /**
* Shared public list endpoints after backend admin bypass on list reads, * Shared public list endpoints backend allows admin reads for event detail/edit.
* authenticated admins can load media/FAQs for any non-deleted event * No dedicated admin media/FAQ list routes exist in OpenAPI.
* (detail + edit pages for another host's event).
*/ */
export async function fetchEventMedia(eventId: string): Promise<EventMedia[]> { export async function fetchEventMedia(eventId: string): Promise<EventMedia[]> {
const response = await eventExtrasApi.eventMediaControllerList(eventId) const response = await eventExtrasApi.eventMediaControllerList(eventId)

View File

@ -19,7 +19,6 @@ export interface SupportTicket {
status: string status: string
lastMessageAt: string lastMessageAt: string
closedAt: string | null closedAt: string | null
closedBy?: 'user' | 'admin' | null
createdAt: string createdAt: string
user: { mobile: string; displayName: string | null } user: { mobile: string; displayName: string | null }
messages: SupportTicketMessage[] messages: SupportTicketMessage[]

View File

@ -126,12 +126,6 @@ export const events = {
revisionReject: 'رد ویرایش', revisionReject: 'رد ویرایش',
revisionRejectReasonLabel: 'دلیل رد', revisionRejectReasonLabel: 'دلیل رد',
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود', revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
adminLifecycleHintDraftUnapproved: 'تأیید برای انتشار: فقط اجازه می‌دهد میزبان خودش منتشر کند · انتشار فوری: همین الان عمومی می‌شود',
adminLifecycleHintDraftApproved: 'ادمین تأیید کرده؛ منتظر Publish میزبان بمانید یا با «انتشار فوری» همین الان منتشر کنید',
adminLifecycleHintPendingReview: 'میزبان درخواست انتشار داده؛ «تأیید و انتشار» رویداد را عمومی می‌کند',
adminLifecycleHintPublished: 'پایان: پس از برگزاری · لغو: همهٔ رزروها لغو می‌شوند',
adminLifecycleHintDeleteOnly: 'حذف فقط برای رویدادهای بدون رزرو فعال',
adminLifecycleTitleForcePublish: 'انتشار فوری بدون انتظار برای میزبان',
genderOpen: 'آزاد برای عموم', genderOpen: 'آزاد برای عموم',
genderFemaleOnly: 'خانم‌ها', genderFemaleOnly: 'خانم‌ها',
genderMaleOnly: 'آقایان', genderMaleOnly: 'آقایان',

View File

@ -12,11 +12,6 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
include: ['**/*.test.{ts,tsx}'], include: ['**/*.test.{ts,tsx}'],
setupFiles: ['./vitest.setup.ts'], setupFiles: ['./vitest.setup.ts'],
// Default 5s flakes under pre-push load (jsdom + HeroUI transform); aborted
// tests then leak async work into the next case in the same file.
testTimeout: 20_000,
hookTimeout: 20_000,
maxWorkers: '50%',
coverage: { coverage: {
provider: 'v8', provider: 'v8',
include: ['lib/authRouting.ts', 'helpers/listResponse.ts', 'validation/auth.ts'], include: ['lib/authRouting.ts', 'helpers/listResponse.ts', 'validation/auth.ts'],