commit 785ed98ffc72f044add2d61bc511b932906c6f3e Author: Amirhossein Khalili Date: Mon Jun 29 22:35:34 2026 +0330 docs(showcase): add public qlockify showcase + screenshots diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0015178 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.mp4 binary +*.webm binary + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47a3be9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +Thumbs.db +*.log +*.tmp + +# Keep this repository documentation-only. +.env +.env.* +node_modules/ +dist/ +build/ +__pycache__/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1245241 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# Qlockify Showcase + +Public product showcase for **Qlockify**, a time tracking and workspace reporting platform. + +This repository is intentionally documentation-only. The production backend, frontend, and deployment source code are private. + +## Live Demo + +- Website: [https://qlockify.ir](https://qlockify.ir) +- Demo: use the demo entry point on the landing page to create an isolated temporary workspace. + +## What Qlockify Does + +Qlockify helps teams track billable work, manage workspace/project access, and export operational reports for clients, projects, tags, and team members. + +Core capabilities: + +- Mobile-first authentication with password, OTP, and Google OAuth account linking +- Workspace, member, client, project, and tag management +- Project-level access control for workspace members +- Workspace and project-based hourly rates +- Live timer and manual time entry management +- Admin/owner reporting dashboards +- Excel and PDF report exports +- Isolated demo environments for public product trials +- Audit logs, notifications, and operational visibility + +## Architecture Overview + +![Qlockify runtime request flow](assets/diagrams/qlockify-runtime-request-flow.png) + +The production app is served behind Nginx with a React frontend, Django REST backend, PostgreSQL, Redis, Celery workers, and scheduled background jobs. + +More details: [docs/architecture.md](docs/architecture.md) + +## Product Docs + +- [Features](docs/features.md) +- [Demo Guide](docs/demo-guide.md) +- [Architecture](docs/architecture.md) +- [Security and Privacy](docs/security-and-privacy.md) + +## Screenshots + +The screenshots below were captured from an isolated local demo workspace with seeded test data. + +| Area | Preview | +| --- | --- | +| Landing | ![Landing](assets/screenshots/01-landing.png) | +| About | ![About](assets/screenshots/02-about.png) | +| Authentication | ![Authentication](assets/screenshots/03-auth-mobile.png) | +| Timesheet | ![Timesheet](assets/screenshots/10-timesheet.png) | +| My rates panel | ![My rates panel](assets/screenshots/11-my-rates-panel.png) | +| Reports table | ![Reports table](assets/screenshots/20-reports-table.png) | +| Reports chart | ![Reports chart](assets/screenshots/21-reports-chart.png) | +| Clients | ![Clients](assets/screenshots/30-clients.png) | +| Client create modal | ![Client create modal](assets/screenshots/31-client-create-modal.png) | +| Client edit modal | ![Client edit modal](assets/screenshots/32-client-edit-modal.png) | +| Projects | ![Projects](assets/screenshots/40-projects.png) | +| Project create modal | ![Project create modal](assets/screenshots/41-project-create-modal.png) | +| Project access and rates | ![Project access and rates](assets/screenshots/42-project-access-modal.png) | +| Tags | ![Tags](assets/screenshots/50-tags.png) | +| Tag create modal | ![Tag create modal](assets/screenshots/51-tags-create-modal.png) | +| Workspaces | ![Workspaces](assets/screenshots/60-workspaces.png) | +| Workspace edit page | ![Workspace edit page](assets/screenshots/61-workspace-edit-page.png) | +| Profile | ![Profile](assets/screenshots/70-profile.png) | +| Profile edit state | ![Profile edit state](assets/screenshots/71-profile-edit-page.png) | +| Admin dashboard | ![Admin dashboard](assets/screenshots/80-admin-dashboard.png) | +| Admin users list | ![Admin users list](assets/screenshots/81-admin-users-list.png) | +| Admin user detail with social account inline | ![Admin user detail with social account inline](assets/screenshots/82-admin-user-detail-social-inline.png) | +| Admin workspaces list | ![Admin workspaces list](assets/screenshots/83-admin-workspaces-list.png) | +| Admin workspace detail with member inline | ![Admin workspace detail with member inline](assets/screenshots/84-admin-workspace-detail-members-inline.png) | +| Admin projects list | ![Admin projects list](assets/screenshots/85-admin-projects-list.png) | +| Admin time entries list | ![Admin time entries list](assets/screenshots/86-admin-time-entries-list.png) | +| Admin social accounts list | ![Admin social accounts list](assets/screenshots/87-admin-social-accounts-list.png) | +| Admin contact submissions list | ![Admin contact submissions list](assets/screenshots/88-admin-contact-submissions-list.png) | + +More screenshots are available in [`assets/screenshots`](assets/screenshots/). + +## Regenerating Screenshots + +Screenshots are generated locally from a running Qlockify frontend/backend pair: + +```powershell +cd scripts +npm install +cd .. +node scripts/capture-screenshots.js +``` + +The reusable script and its environment variables are documented in [`scripts/README.md`](scripts/README.md). + +## Tech Stack + +- Backend: Django, Django REST Framework, Celery +- Frontend: React, TypeScript, Vite +- Data: PostgreSQL, Redis +- Runtime: Docker, Nginx +- Operations: Gitea Actions, encrypted object-storage backups + +## Source Code + +The source code and deployment configuration are private/proprietary. This repository exists only to demonstrate the software, user experience, and technical architecture at a high level. diff --git a/assets/diagrams/qlockify-multi-repo-layout.png b/assets/diagrams/qlockify-multi-repo-layout.png new file mode 100644 index 0000000..44dc6a3 Binary files /dev/null and b/assets/diagrams/qlockify-multi-repo-layout.png differ diff --git a/assets/diagrams/qlockify-runtime-request-flow.png b/assets/diagrams/qlockify-runtime-request-flow.png new file mode 100644 index 0000000..881698d Binary files /dev/null and b/assets/diagrams/qlockify-runtime-request-flow.png differ diff --git a/assets/qlockify-logo.png b/assets/qlockify-logo.png new file mode 100644 index 0000000..f70bf6f Binary files /dev/null and b/assets/qlockify-logo.png differ diff --git a/assets/screenshots/.gitkeep b/assets/screenshots/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/screenshots/.gitkeep @@ -0,0 +1 @@ + diff --git a/assets/screenshots/01-landing.png b/assets/screenshots/01-landing.png new file mode 100644 index 0000000..6ccd1e8 Binary files /dev/null and b/assets/screenshots/01-landing.png differ diff --git a/assets/screenshots/02-about.png b/assets/screenshots/02-about.png new file mode 100644 index 0000000..88ee287 Binary files /dev/null and b/assets/screenshots/02-about.png differ diff --git a/assets/screenshots/03-auth-mobile.png b/assets/screenshots/03-auth-mobile.png new file mode 100644 index 0000000..d9ab74b Binary files /dev/null and b/assets/screenshots/03-auth-mobile.png differ diff --git a/assets/screenshots/10-timesheet.png b/assets/screenshots/10-timesheet.png new file mode 100644 index 0000000..08312dc Binary files /dev/null and b/assets/screenshots/10-timesheet.png differ diff --git a/assets/screenshots/11-my-rates-panel.png b/assets/screenshots/11-my-rates-panel.png new file mode 100644 index 0000000..7edc234 Binary files /dev/null and b/assets/screenshots/11-my-rates-panel.png differ diff --git a/assets/screenshots/20-reports-table.png b/assets/screenshots/20-reports-table.png new file mode 100644 index 0000000..af47b8d Binary files /dev/null and b/assets/screenshots/20-reports-table.png differ diff --git a/assets/screenshots/21-reports-chart.png b/assets/screenshots/21-reports-chart.png new file mode 100644 index 0000000..049d405 Binary files /dev/null and b/assets/screenshots/21-reports-chart.png differ diff --git a/assets/screenshots/30-clients.png b/assets/screenshots/30-clients.png new file mode 100644 index 0000000..2bad2a2 Binary files /dev/null and b/assets/screenshots/30-clients.png differ diff --git a/assets/screenshots/31-client-create-modal.png b/assets/screenshots/31-client-create-modal.png new file mode 100644 index 0000000..017ac90 Binary files /dev/null and b/assets/screenshots/31-client-create-modal.png differ diff --git a/assets/screenshots/32-client-edit-modal.png b/assets/screenshots/32-client-edit-modal.png new file mode 100644 index 0000000..6ff1e27 Binary files /dev/null and b/assets/screenshots/32-client-edit-modal.png differ diff --git a/assets/screenshots/40-projects.png b/assets/screenshots/40-projects.png new file mode 100644 index 0000000..7546a47 Binary files /dev/null and b/assets/screenshots/40-projects.png differ diff --git a/assets/screenshots/41-project-create-modal.png b/assets/screenshots/41-project-create-modal.png new file mode 100644 index 0000000..670c9ef Binary files /dev/null and b/assets/screenshots/41-project-create-modal.png differ diff --git a/assets/screenshots/42-project-access-modal.png b/assets/screenshots/42-project-access-modal.png new file mode 100644 index 0000000..dc24502 Binary files /dev/null and b/assets/screenshots/42-project-access-modal.png differ diff --git a/assets/screenshots/50-tags.png b/assets/screenshots/50-tags.png new file mode 100644 index 0000000..15a2e94 Binary files /dev/null and b/assets/screenshots/50-tags.png differ diff --git a/assets/screenshots/51-tags-create-modal.png b/assets/screenshots/51-tags-create-modal.png new file mode 100644 index 0000000..2034407 Binary files /dev/null and b/assets/screenshots/51-tags-create-modal.png differ diff --git a/assets/screenshots/60-workspaces.png b/assets/screenshots/60-workspaces.png new file mode 100644 index 0000000..4d40652 Binary files /dev/null and b/assets/screenshots/60-workspaces.png differ diff --git a/assets/screenshots/61-workspace-edit-page.png b/assets/screenshots/61-workspace-edit-page.png new file mode 100644 index 0000000..46af5eb Binary files /dev/null and b/assets/screenshots/61-workspace-edit-page.png differ diff --git a/assets/screenshots/70-profile.png b/assets/screenshots/70-profile.png new file mode 100644 index 0000000..6938c00 Binary files /dev/null and b/assets/screenshots/70-profile.png differ diff --git a/assets/screenshots/71-profile-edit-page.png b/assets/screenshots/71-profile-edit-page.png new file mode 100644 index 0000000..9bebc21 Binary files /dev/null and b/assets/screenshots/71-profile-edit-page.png differ diff --git a/assets/screenshots/80-admin-dashboard.png b/assets/screenshots/80-admin-dashboard.png new file mode 100644 index 0000000..6ed968f Binary files /dev/null and b/assets/screenshots/80-admin-dashboard.png differ diff --git a/assets/screenshots/81-admin-users-list.png b/assets/screenshots/81-admin-users-list.png new file mode 100644 index 0000000..ae9f6e4 Binary files /dev/null and b/assets/screenshots/81-admin-users-list.png differ diff --git a/assets/screenshots/82-admin-user-detail-social-inline.png b/assets/screenshots/82-admin-user-detail-social-inline.png new file mode 100644 index 0000000..0a81574 Binary files /dev/null and b/assets/screenshots/82-admin-user-detail-social-inline.png differ diff --git a/assets/screenshots/83-admin-workspaces-list.png b/assets/screenshots/83-admin-workspaces-list.png new file mode 100644 index 0000000..c971904 Binary files /dev/null and b/assets/screenshots/83-admin-workspaces-list.png differ diff --git a/assets/screenshots/84-admin-workspace-detail-members-inline.png b/assets/screenshots/84-admin-workspace-detail-members-inline.png new file mode 100644 index 0000000..71ffd7a Binary files /dev/null and b/assets/screenshots/84-admin-workspace-detail-members-inline.png differ diff --git a/assets/screenshots/85-admin-projects-list.png b/assets/screenshots/85-admin-projects-list.png new file mode 100644 index 0000000..df6560e Binary files /dev/null and b/assets/screenshots/85-admin-projects-list.png differ diff --git a/assets/screenshots/86-admin-time-entries-list.png b/assets/screenshots/86-admin-time-entries-list.png new file mode 100644 index 0000000..d2c3751 Binary files /dev/null and b/assets/screenshots/86-admin-time-entries-list.png differ diff --git a/assets/screenshots/87-admin-social-accounts-list.png b/assets/screenshots/87-admin-social-accounts-list.png new file mode 100644 index 0000000..9e4f98b Binary files /dev/null and b/assets/screenshots/87-admin-social-accounts-list.png differ diff --git a/assets/screenshots/88-admin-contact-submissions-list.png b/assets/screenshots/88-admin-contact-submissions-list.png new file mode 100644 index 0000000..de2958c Binary files /dev/null and b/assets/screenshots/88-admin-contact-submissions-list.png differ diff --git a/assets/videos/.gitkeep b/assets/videos/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/videos/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1493627 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,43 @@ +# Architecture + +Qlockify is deployed as a containerized web application behind Nginx. + +## Runtime Flow + +![Qlockify runtime request flow](../assets/diagrams/qlockify-runtime-request-flow.png) + +High-level request flow: + +- Browser requests are served through Nginx. +- Static frontend assets are served by the frontend container. +- API and admin requests are proxied to the Django backend. +- Django stores relational data in PostgreSQL. +- Redis is used for cache, async job brokering, and background coordination. +- Celery workers process async jobs. +- Celery Beat schedules recurring jobs such as demo cleanup. + +## Multi-Repo Setup + +![Qlockify runtime request flow](../assets/diagrams/qlockify-multi-repo-layout.png) + +The actual source uses separate private repositories for: + +- backend application code +- frontend application code +- deployment/orchestration configuration + +This keeps application changes, frontend changes, and production operations changes isolated from each other. The public showcase repository does not include those private repositories or their deployment files. + +## Public Safety Boundary + +This repository intentionally excludes: + +- source code +- database migrations and schemas +- Docker Compose and Nginx configuration +- CI/CD workflows +- backup scripts +- environment variables +- private repository URLs with credentials +- real production logs or user data + diff --git a/docs/demo-guide.md b/docs/demo-guide.md new file mode 100644 index 0000000..52f1e89 --- /dev/null +++ b/docs/demo-guide.md @@ -0,0 +1,32 @@ +# Demo Guide + +Qlockify includes a public demo flow for exploring the product without accessing real customer data. + +## How to Try It + +1. Open [https://qlockify.ir](https://qlockify.ir). +2. Use the demo call-to-action on the landing page. +3. The app creates a temporary isolated workspace and logs you in as the demo owner. +4. Explore timesheets, clients, projects, tags, access management, and reports. + +## Demo Data + +The demo workspace includes realistic sample data such as: + +- workspace members +- clients and projects +- tags +- time entries +- billable work +- hourly rates +- project access rules +- reportable activity + +## Isolation + +Each demo visitor gets a separate demo environment. Actions in one demo do not affect other demo users or production users. + +## Expiration + +Demo environments are temporary and are cleaned automatically. Do not enter sensitive or real customer data in the demo. + diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..88d2004 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,40 @@ +# Features + +## Time Tracking + +- Live timer for active work sessions +- Manual time entry for missed or corrected work +- Billable and non-billable tracking +- Project, client, and tag attribution +- Mobile-friendly timesheet interactions + +## Workspace Management + +- Workspace roles for owners, admins, members, and guests +- Member management and bulk member import +- Project access control for normal workspace users +- Workspace and project-level hourly rates + +## Reporting + +- Table and chart report views +- All-users summary reports for admins and owners +- Per-user daily summaries +- Client, project, and tag breakdowns +- Excel and PDF exports with localized Persian/English output + +## Authentication + +- Mobile-first auth flow +- Password login +- OTP login and password recovery +- Google OAuth account linking with mobile confirmation +- Isolated demo entry for public product trials + +## Operations + +- Dockerized production deployment +- Background workers and scheduled cleanup jobs +- Encrypted backup flow to S3-compatible object storage +- Audit logs and notifications + diff --git a/docs/security-and-privacy.md b/docs/security-and-privacy.md new file mode 100644 index 0000000..519cf5d --- /dev/null +++ b/docs/security-and-privacy.md @@ -0,0 +1,26 @@ +# Security and Privacy + +## Source Code Privacy + +Qlockify source code is private. This showcase repository is documentation-only and does not contain backend, frontend, deployment, or infrastructure source. + +## Demo Safety + +The public demo uses isolated temporary data. Demo users should not enter real customer data, private notes, credentials, or sensitive business information. + +## Public Repository Rules + +Do not commit: + +- `.env` files +- credentials or API keys +- production logs +- database dumps +- customer screenshots +- private deployment scripts +- internal-only URLs or access tokens + +## Contact + +For product or technical questions, use the contact channels listed on the public Qlockify website. + diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..52e3475 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,89 @@ +# Showcase Automation Scripts + +This directory contains local-only automation for regenerating public showcase assets. + +Currently implemented: + +- `capture-screenshots.js`: captures the reusable screenshot set into `assets/screenshots/`. + +Planned later: + +- a video/demo recorder script can be added here with the same environment conventions. + +## Screenshot Prerequisites + +The script does not start any Qlockify services. Start them before running it: + +- frontend: `http://localhost:5173` +- backend: `http://localhost:8000` +- database/Redis/Celery as required by your normal local app setup + +Install Playwright for the script: + +```powershell +cd scripts +npm install +``` + +If Playwright cannot find a browser, either install Chromium: + +```powershell +npx playwright install chromium +``` + +or point the script to your installed Chrome: + +```powershell +$env:QLOCKIFY_CHROME_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe" +``` + +## Capture Public And App Screenshots + +From the showcase repo root: + +```powershell +node scripts/capture-screenshots.js +``` + +Or from the `scripts` directory: + +```powershell +npm run screenshots +``` + +By default, the script deletes old `.png` files from `assets/screenshots/` first. Disable that with: + +```powershell +$env:QLOCKIFY_CLEAN_SCREENSHOTS="0" +node scripts/capture-screenshots.js +``` + +## Capture Admin Screenshots + +Admin screenshots are optional. When enabled, the script creates/updates a local superuser fixture and logs into Django admin. + +```powershell +$env:QLOCKIFY_CAPTURE_ADMIN="1" +$env:QLOCKIFY_ADMIN_USERNAME="09900000000" +$env:QLOCKIFY_ADMIN_PASSWORD="QlockifyLocalAdmin!2026" +node scripts/capture-screenshots.js +``` + +If demo creation is rate-limited, the script falls back to the latest active local demo environment through Django shell. For that fallback and admin fixture setup, it needs the backend repo path: + +```powershell +$env:QLOCKIFY_BACKEND_REPO="D:\Programing\Scripts\Qlockify\qlockify-backend" +``` + +## Environment Variables + +- `QLOCKIFY_FRONTEND_URL`: frontend URL, default `http://localhost:5173`. +- `QLOCKIFY_BACKEND_URL`: backend URL, default `http://localhost:8000`. +- `QLOCKIFY_BACKEND_REPO`: local backend repo path, default `../qlockify-backend`. +- `QLOCKIFY_DEMO_TOKEN_FILE`: optional JSON file with `access`, `refresh`, `workspace_id`, and `expires_at`. +- `QLOCKIFY_CAPTURE_ADMIN`: set `1` to capture Django admin screenshots. +- `QLOCKIFY_ADMIN_USERNAME`: local admin login mobile. +- `QLOCKIFY_ADMIN_PASSWORD`: local admin password. +- `QLOCKIFY_ADMIN_USER_ID`: optional user id for the admin user-detail screenshot; defaults to the demo owner when available. +- `QLOCKIFY_CHROME_PATH` or `CHROME_EXECUTABLE`: optional installed Chrome path. +- `QLOCKIFY_CLEAN_SCREENSHOTS`: set `0` to keep existing PNG files before capture. diff --git a/scripts/capture-screenshots.js b/scripts/capture-screenshots.js new file mode 100644 index 0000000..97ba981 --- /dev/null +++ b/scripts/capture-screenshots.js @@ -0,0 +1,477 @@ +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const ROOT_DIR = path.resolve(__dirname, ".."); +const OUTPUT_DIR = path.join(ROOT_DIR, "assets", "screenshots"); +const FRONTEND_URL = stripTrailingSlash(process.env.QLOCKIFY_FRONTEND_URL || "http://localhost:5173"); +const BACKEND_URL = stripTrailingSlash(process.env.QLOCKIFY_BACKEND_URL || "http://localhost:8000"); +const BACKEND_REPO = path.resolve( + ROOT_DIR, + process.env.QLOCKIFY_BACKEND_REPO || path.join("..", "qlockify-backend"), +); +const ADMIN_USERNAME = process.env.QLOCKIFY_ADMIN_USERNAME || "09900000000"; +const ADMIN_PASSWORD = process.env.QLOCKIFY_ADMIN_PASSWORD || "QlockifyLocalAdmin!2026"; +const CHROME_EXECUTABLE = process.env.CHROME_EXECUTABLE || process.env.QLOCKIFY_CHROME_PATH || ""; +const CAPTURE_ADMIN = process.env.QLOCKIFY_CAPTURE_ADMIN === "1"; +const CLEAN_OUTPUT = process.env.QLOCKIFY_CLEAN_SCREENSHOTS !== "0"; + +function printHelp() { + console.log(` +Usage: + node scripts/capture-screenshots.js + +Required: + - Qlockify frontend running at ${FRONTEND_URL} + - Qlockify backend running at ${BACKEND_URL} + - npm install run inside scripts/ so Playwright is available + +Useful environment variables: + QLOCKIFY_FRONTEND_URL Frontend URL. Default: http://localhost:5173 + QLOCKIFY_BACKEND_URL Backend URL. Default: http://localhost:8000 + QLOCKIFY_BACKEND_REPO Local backend repo path. Default: ../qlockify-backend + QLOCKIFY_DEMO_TOKEN_FILE JSON token file with access, refresh, workspace_id, expires_at + QLOCKIFY_CAPTURE_ADMIN=1 Also capture Django admin screenshots + QLOCKIFY_ADMIN_USERNAME Local admin mobile for admin screenshots + QLOCKIFY_ADMIN_PASSWORD Local admin password for admin screenshots + QLOCKIFY_CHROME_PATH Optional installed Chrome executable + QLOCKIFY_CLEAN_SCREENSHOTS=0 Do not delete old PNG screenshots before capture + +The script does not start frontend, backend, PostgreSQL, Redis, or Celery. +`); +} + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + printHelp(); + process.exit(0); +} + +function stripTrailingSlash(value) { + return value.replace(/\/+$/, ""); +} + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function cleanScreenshots() { + if (!CLEAN_OUTPUT || !fs.existsSync(OUTPUT_DIR)) return; + for (const file of fs.readdirSync(OUTPUT_DIR)) { + if (file.endsWith(".png")) { + fs.rmSync(path.join(OUTPUT_DIR, file), { force: true }); + } + } +} + +async function assertReachable(url, label) { + try { + const response = await fetch(url, { method: "GET" }); + if (!response.ok && response.status >= 500) { + throw new Error(`${label} returned ${response.status}`); + } + } catch (error) { + throw new Error(`${label} is not reachable at ${url}. Start the service first. ${error.message}`); + } +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, options); + const body = await response.text(); + if (!response.ok) { + throw new Error(`${url} failed: ${response.status} ${body}`); + } + return body ? JSON.parse(body) : {}; +} + +function pythonExecutable() { + const windowsVenv = path.join(BACKEND_REPO, ".venv", "Scripts", "python.exe"); + const unixVenv = path.join(BACKEND_REPO, ".venv", "bin", "python"); + if (fs.existsSync(windowsVenv)) return windowsVenv; + if (fs.existsSync(unixVenv)) return unixVenv; + return "python"; +} + +function runDjangoShell(code) { + if (!fs.existsSync(path.join(BACKEND_REPO, "manage.py"))) { + throw new Error(`Backend repo was not found at ${BACKEND_REPO}`); + } + + const output = execFileSync(pythonExecutable(), ["manage.py", "shell", "-c", code], { + cwd: BACKEND_REPO, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return output.trim().split(/\r?\n/).filter(Boolean).at(-1); +} + +function getDemoFromDjango() { + const code = ` +import json +from apps.demos.models import DemoEnvironment +from apps.users.services.auth import get_tokens_for_user +env = DemoEnvironment.objects.select_related("owner_user", "workspace").filter(status="active").order_by("-created_at").first() +if env is None: + raise SystemExit("No active demo environment exists. Start one from the app or clear demo rate limit.") +print(json.dumps({**get_tokens_for_user(env.owner_user), "workspace_id": str(env.workspace_id), "expires_at": env.expires_at.isoformat(), "owner_user_id": str(env.owner_user_id)})) +`; + return JSON.parse(runDjangoShell(code)); +} + +function prepareAdminFixtures(demo) { + if (!CAPTURE_ADMIN) return; + const adminUsername = JSON.stringify(ADMIN_USERNAME); + const adminPassword = JSON.stringify(ADMIN_PASSWORD); + const ownerId = JSON.stringify(demo.owner_user_id || ""); + const code = ` +from django.contrib.auth import get_user_model +from apps.users.models import UserSocialAccount +User = get_user_model() +admin, _ = User.objects.update_or_create( + mobile=${adminUsername}, + defaults={ + "first_name": "Showcase", + "last_name": "Admin", + "email": "showcase-admin@qlockify.local", + "is_staff": True, + "is_superuser": True, + "is_active": True, + }, +) +admin.set_password(${adminPassword}) +admin.save(update_fields=["password", "first_name", "last_name", "email", "is_staff", "is_superuser", "is_active"]) +owner_id = ${ownerId} +if owner_id: + owner = User.objects.filter(id=owner_id).first() + if owner: + UserSocialAccount.objects.update_or_create( + provider="google", + provider_user_id="showcase-demo-google", + defaults={ + "user": owner, + "email": owner.email or "demo-owner@demo.qlockify.local", + "avatar_url": "https://qlockify.ir/favicon.svg", + }, + ) +print("ok") +`; + runDjangoShell(code); +} + +async function readDemoTokenFile() { + const tokenFile = process.env.QLOCKIFY_DEMO_TOKEN_FILE; + if (!tokenFile || !fs.existsSync(tokenFile)) return null; + const token = JSON.parse(fs.readFileSync(tokenFile, "utf8").replace(/^\uFEFF/, "")); + if (token?.access && token?.refresh && token?.workspace_id) return token; + return null; +} + +async function resolveDemoSession() { + const fileToken = await readDemoTokenFile(); + if (fileToken) return fileToken; + + try { + return await fetchJson(`${BACKEND_URL}/api/demo/start/`, { method: "POST" }); + } catch (error) { + console.warn(`Demo start failed, trying latest active demo from Django shell: ${error.message}`); + return getDemoFromDjango(); + } +} + +async function settle(page, delay = 2000) { + await page.waitForLoadState("domcontentloaded"); + await page.waitForLoadState("networkidle", { timeout: 12000 }).catch(() => {}); + await page + .waitForFunction( + () => { + const text = document.body.innerText || ""; + const skeletons = document.querySelectorAll( + '[class*="animate-pulse"], [class*="skeleton"], [aria-busy="true"]', + ).length; + return skeletons === 0 && !/Loading|در حال|بارگذاری|لطفا صبر/i.test(text); + }, + { timeout: 12000 }, + ) + .catch(() => {}); + await wait(delay); +} + +async function saveScreenshot(page, name, { fullPage = false, delay = 1000 } = {}) { + await settle(page, delay); + await page.screenshot({ path: path.join(OUTPUT_DIR, name), fullPage }); + console.log(`saved ${name}`); +} + +async function openAppRoute(page, route, delay = 2500) { + await page.goto(`${FRONTEND_URL}${route}`, { waitUntil: "domcontentloaded" }); + await settle(page, delay); +} + +async function clickRoleButton(page, names, timeout = 5000) { + for (const name of names) { + const button = page.getByRole("button", { name }).first(); + if (await button.click({ timeout }).then(() => true).catch(() => false)) { + await settle(page, 1200); + return true; + } + } + return false; +} + +async function clickSelector(page, selectors, timeout = 5000) { + for (const selector of selectors) { + const locator = page.locator(selector).first(); + if (await locator.click({ timeout }).then(() => true).catch(() => false)) { + await settle(page, 1200); + return true; + } + } + return false; +} + +async function createBrowser() { + const { chromium } = require("playwright"); + const options = { headless: true }; + if (CHROME_EXECUTABLE) { + options.executablePath = CHROME_EXECUTABLE; + } + return chromium.launch(options); +} + +async function createPublicContext(browser) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + colorScheme: "dark", + locale: "fa-IR", + timezoneId: "Asia/Tehran", + }); + await context.addInitScript(() => { + localStorage.setItem("language", "fa"); + localStorage.setItem("theme", "dark"); + }); + return context; +} + +async function createAppContext(browser, demo, viewport = { width: 1440, height: 900 }) { + const context = await browser.newContext({ + viewport, + colorScheme: "dark", + locale: "fa-IR", + timezoneId: "Asia/Tehran", + }); + await context.addInitScript(({ access, refresh, expiresAt }) => { + localStorage.setItem("language", "fa"); + localStorage.setItem("theme", "dark"); + localStorage.setItem("accessToken", access); + localStorage.setItem("refreshToken", refresh); + localStorage.setItem("demoExpiresAt", expiresAt || new Date(Date.now() + 86400000).toISOString()); + }, { access: demo.access, refresh: demo.refresh, expiresAt: demo.expires_at }); + return context; +} + +async function getChartUserId(demo) { + const chart = await fetchJson( + `${BACKEND_URL}/api/reports/chart/?workspace=${demo.workspace_id}&period=this_month&language=fa`, + { headers: { Authorization: `Bearer ${demo.access}` } }, + ); + const ownerSeries = + chart.series?.find((series) => series.user?.name === "Demo Owner") || + chart.series?.find((series) => series.user); + return ownerSeries?.user?.id || null; +} + +async function capturePublicScreenshots(browser) { + const context = await createPublicContext(browser); + const page = await context.newPage(); + + await openAppRoute(page, "/", 2400); + await saveScreenshot(page, "01-landing.png"); + + await openAppRoute(page, "/about", 2400); + await saveScreenshot(page, "02-about.png", { fullPage: true }); + + await openAppRoute(page, "/auth", 2400); + await saveScreenshot(page, "03-auth-mobile.png"); + + await context.close(); +} + +async function captureAppScreenshots(browser, demo) { + const context = await createAppContext(browser, demo); + const page = await context.newPage(); + + await openAppRoute(page, "/timesheet", 4500); + await saveScreenshot(page, "10-timesheet.png"); + if (await clickRoleButton(page, [/تعرفه.*من/i, /My rates/i])) { + await saveScreenshot(page, "11-my-rates-panel.png"); + await page.keyboard.press("Escape"); + await wait(800); + } + + await openAppRoute(page, "/reports?tab=table", 4500); + await clickRoleButton(page, [/جدول/i, /Table/i]); + await saveScreenshot(page, "20-reports-table.png"); + + const chartUserId = await getChartUserId(demo); + const chartRoute = chartUserId + ? `/reports?tab=chart&period=this_month&user=${encodeURIComponent(chartUserId)}` + : "/reports?tab=chart&period=this_month"; + await openAppRoute(page, chartRoute, 7000); + await clickRoleButton(page, [/نمودار/i, /Chart/i]); + await page.evaluate(() => { + for (const el of document.querySelectorAll(".overflow-x-auto")) { + el.scrollLeft = -el.scrollWidth; + } + }); + await wait(1200); + await saveScreenshot(page, "21-reports-chart.png"); + + await openAppRoute(page, "/clients", 3500); + await saveScreenshot(page, "30-clients.png"); + if (await clickRoleButton(page, [/افزودن مشتری/i, /افزودن/i, /Create/i, /Add/i])) { + await saveScreenshot(page, "31-client-create-modal.png"); + await page.keyboard.press("Escape"); + await wait(800); + } + if (await clickSelector(page, ['button[title="ویرایش"]', 'button[title="Edit"]', "button:has(svg.lucide-edit-2)"])) { + await saveScreenshot(page, "32-client-edit-modal.png"); + await page.keyboard.press("Escape"); + await wait(800); + } + + await openAppRoute(page, "/projects", 3500); + await saveScreenshot(page, "40-projects.png"); + if (await clickSelector(page, ['button[title="ایجاد پروژه جدید"]', 'button[title="Create new project"]'])) { + await saveScreenshot(page, "41-project-create-modal.png"); + await page.keyboard.press("Escape"); + await wait(800); + } + if (await clickRoleButton(page, [/پروژه.*نرخ/i, /Projects and rates/i])) { + await saveScreenshot(page, "42-project-access-modal.png", { delay: 2200 }); + await page.keyboard.press("Escape"); + await wait(800); + } + + await openAppRoute(page, "/tags", 3500); + await saveScreenshot(page, "50-tags.png"); + if (await clickSelector(page, ['button[title="ایجاد تگ"]', 'button[title="Create Tag"]', "button:has(svg.lucide-plus)"])) { + await saveScreenshot(page, "51-tags-create-modal.png"); + await page.keyboard.press("Escape"); + await wait(800); + } + + await openAppRoute(page, "/workspaces", 3500); + await saveScreenshot(page, "60-workspaces.png"); + if (demo.workspace_id) { + await openAppRoute(page, `/workspaces/${demo.workspace_id}/edit`, 4500); + await saveScreenshot(page, "61-workspace-edit-page.png"); + } + + await openAppRoute(page, "/profile", 3500); + await saveScreenshot(page, "70-profile.png"); + if (await clickSelector(page, ["button:has(svg.lucide-edit-2)", 'button[title="ویرایش"]', 'button[title="Edit"]'])) { + await saveScreenshot(page, "71-profile-edit-page.png"); + } + + await context.close(); +} + +async function loginAdmin(page) { + await page.goto(`${BACKEND_URL}/admin/login/?next=/admin/`, { waitUntil: "domcontentloaded" }); + await settle(page, 800); + await page.locator('input[name="username"]').fill(ADMIN_USERNAME); + await page.locator('input[name="password"]').fill(ADMIN_PASSWORD); + await page.locator('button[type="submit"], input[type="submit"]').first().click(); + await settle(page, 2200); +} + +async function hideDebugToolbar(page) { + await page + .addStyleTag({ + content: + '#djDebug, .djdt-hidden, [id^="djDebug"] { display: none !important; visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; }', + }) + .catch(() => {}); + await page + .evaluate(() => { + for (const node of document.querySelectorAll('#djDebug, .djdt-hidden, [id^="djDebug"]')) { + node.remove(); + } + }) + .catch(() => {}); +} + +async function saveAdminScreenshot(page, name, fullPage = false) { + await hideDebugToolbar(page); + await saveScreenshot(page, name, { fullPage }); +} + +async function captureAdminScreenshots(browser, demo) { + if (!CAPTURE_ADMIN) { + console.log("skipping admin screenshots; set QLOCKIFY_CAPTURE_ADMIN=1 to enable"); + return; + } + + const context = await browser.newContext({ + viewport: { width: 1440, height: 1100 }, + colorScheme: "dark", + locale: "en-US", + }); + const page = await context.newPage(); + await loginAdmin(page); + + await saveAdminScreenshot(page, "80-admin-dashboard.png"); + await page.goto(`${BACKEND_URL}/admin/users/user/`, { waitUntil: "domcontentloaded" }); + await saveAdminScreenshot(page, "81-admin-users-list.png"); + + const userId = process.env.QLOCKIFY_ADMIN_USER_ID || demo.owner_user_id; + if (userId) { + await page.goto(`${BACKEND_URL}/admin/users/user/${userId}/change/`, { waitUntil: "domcontentloaded" }); + await saveAdminScreenshot(page, "82-admin-user-detail-social-inline.png", true); + } + + await page.goto(`${BACKEND_URL}/admin/workspaces/workspace/`, { waitUntil: "domcontentloaded" }); + await saveAdminScreenshot(page, "83-admin-workspaces-list.png"); + if (demo.workspace_id) { + await page.goto(`${BACKEND_URL}/admin/workspaces/workspace/${demo.workspace_id}/change/`, { waitUntil: "domcontentloaded" }); + await saveAdminScreenshot(page, "84-admin-workspace-detail-members-inline.png", true); + } + await page.goto(`${BACKEND_URL}/admin/projects/project/`, { waitUntil: "domcontentloaded" }); + await saveAdminScreenshot(page, "85-admin-projects-list.png"); + await page.goto(`${BACKEND_URL}/admin/time_entries/timeentry/`, { waitUntil: "domcontentloaded" }); + await saveAdminScreenshot(page, "86-admin-time-entries-list.png"); + await page.goto(`${BACKEND_URL}/admin/users/usersocialaccount/`, { waitUntil: "domcontentloaded" }); + await saveAdminScreenshot(page, "87-admin-social-accounts-list.png"); + await page.goto(`${BACKEND_URL}/admin/contacts/contactsubmission/`, { waitUntil: "domcontentloaded" }); + await saveAdminScreenshot(page, "88-admin-contact-submissions-list.png"); + + await context.close(); +} + +async function main() { + ensureDir(OUTPUT_DIR); + cleanScreenshots(); + + await assertReachable(`${FRONTEND_URL}/`, "Frontend"); + await assertReachable(`${BACKEND_URL}/api/`, "Backend"); + + const demo = await resolveDemoSession(); + prepareAdminFixtures(demo); + + const browser = await createBrowser(); + try { + await capturePublicScreenshots(browser); + await captureAppScreenshots(browser, demo); + await captureAdminScreenshots(browser, demo); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..e0bddb2 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "type": "commonjs", + "scripts": { + "screenshots": "node capture-screenshots.js" + }, + "dependencies": { + "playwright": "^1.57.0" + } +}