docs(showcase): add public qlockify showcase + screenshots

This commit is contained in:
2026-06-29 22:35:34 +03:30
commit 785ed98ffc
43 changed files with 843 additions and 0 deletions

8
.gitattributes vendored Normal file
View File

@@ -0,0 +1,8 @@
* text=auto
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.mp4 binary
*.webm binary

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
Thumbs.db
*.log
*.tmp
# Keep this repository documentation-only.
.env
.env.*
node_modules/
dist/
build/
__pycache__/

103
README.md Normal file
View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
assets/qlockify-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

1
assets/videos/.gitkeep Normal file
View File

@@ -0,0 +1 @@

43
docs/architecture.md Normal file
View File

@@ -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

32
docs/demo-guide.md Normal file
View File

@@ -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.

40
docs/features.md Normal file
View File

@@ -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

View File

@@ -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.

89
scripts/README.md Normal file
View File

@@ -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.

View File

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

10
scripts/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"private": true,
"type": "commonjs",
"scripts": {
"screenshots": "node capture-screenshots.js"
},
"dependencies": {
"playwright": "^1.57.0"
}
}