diff --git a/.gitignore b/.gitignore index 1214d9d..f9e0f3e 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,7 @@ next-env.d.ts *.db-shm *.db-wal + +# store screenshot artifacts +/scripts/shots/ +/store-assets/ diff --git a/package-lock.json b/package-lock.json index b1a0513..a9f3eba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.7", + "playwright": "^1.60.0", "tailwindcss": "^4", "typescript": "^5" } @@ -4223,6 +4224,21 @@ "node": ">= 10.0.0" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6254,6 +6270,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", diff --git a/package.json b/package.json index 2b9087e..ec1e193 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.7", + "playwright": "^1.60.0", "tailwindcss": "^4", "typescript": "^5" } diff --git a/scripts/game.js b/scripts/game.js new file mode 100644 index 0000000..06caf86 --- /dev/null +++ b/scripts/game.js @@ -0,0 +1,41 @@ +// Capture several frames of an actual vs-computer game so we can pick the best +// "gameplay" shot (hand fanned at the bottom, trump chosen, a trick in play). +const { chromium } = require("playwright"); +const path = require("path"); +const OUT = path.join(__dirname, "shots"); +const URL = process.env.SHOT_URL || "http://localhost:3025/"; + +(async () => { + const browser = await chromium.launch({ channel: "chrome" }); + const ctx = await browser.newContext({ + viewport: { width: 430, height: 932 }, + deviceScaleFactor: 2, + locale: "fa-IR", + isMobile: true, + hasTouch: true, + }); + const page = await ctx.newPage(); + await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {}); + await page.waitForTimeout(2500); + + await page.getByText("بازی با کامپیوتر", { exact: false }).first().click(); + // Capture a frame every few seconds through deal → hakem → trump → play. + const stamps = [6, 10, 14, 18, 24, 30]; + let prev = 0; + for (const s of stamps) { + await page.waitForTimeout((s - prev) * 1000); + prev = s; + await page.screenshot({ path: path.join(OUT, `game-${String(s).padStart(2, "0")}s.png`) }); + // If it's our turn to choose trump, pick the first suit so play proceeds. + try { + const trump = page.getByText("حکم را انتخاب", { exact: false }); + if (await trump.count()) { + const suit = page.locator("button").filter({ hasText: /♠|♥|♦|♣/ }).first(); + if (await suit.count()) await suit.click({ timeout: 1500 }).catch(() => {}); + } + } catch {} + console.log("frame", s + "s"); + } + await browser.close(); + console.log("DONE"); +})().catch((e) => { console.error("FATAL", e); process.exit(1); }); diff --git a/scripts/icon.js b/scripts/icon.js new file mode 100644 index 0000000..b611f24 --- /dev/null +++ b/scripts/icon.js @@ -0,0 +1,24 @@ +// Render public/icon.svg to a 512x512 store PNG (full square — stores apply +// their own corner mask). Uses Chrome for correct Persian text shaping. +const { chromium } = require("playwright"); +const fs = require("fs"); +const path = require("path"); + +const OUT = path.join(__dirname, "shots"); +fs.mkdirSync(OUT, { recursive: true }); + +let svg = fs.readFileSync(path.join(__dirname, "..", "public", "icon.svg"), "utf8"); +// Full-bleed square (remove the rounded corners so there's no transparency). +const square = svg.replace('rx="112"', 'rx="0"').replace("${square}`; + +(async () => { + const browser = await chromium.launch({ channel: "chrome" }); + const ctx = await browser.newContext({ viewport: { width: 512, height: 512 }, deviceScaleFactor: 1 }); + const page = await ctx.newPage(); + await page.setContent(html, { waitUntil: "networkidle" }); + await page.waitForTimeout(600); + await page.screenshot({ path: path.join(OUT, "icon-512.png"), clip: { x: 0, y: 0, width: 512, height: 512 } }); + await browser.close(); + console.log("icon-512 done"); +})().catch((e) => { console.error("FATAL", e); process.exit(1); }); diff --git a/scripts/shots.js b/scripts/shots.js new file mode 100644 index 0000000..0ab1092 --- /dev/null +++ b/scripts/shots.js @@ -0,0 +1,69 @@ +// Capture portrait store screenshots from the running dev server (localhost:3025) +// using the system Chrome. Output -> scripts/shots/*.png +const { chromium } = require("playwright"); +const fs = require("fs"); +const path = require("path"); + +const OUT = path.join(__dirname, "shots"); +fs.mkdirSync(OUT, { recursive: true }); +const URL = process.env.SHOT_URL || "http://localhost:3025/"; + +const shot = async (page, name) => { + await page.screenshot({ path: path.join(OUT, name + ".png") }); + console.log("saved", name); +}; +const tap = async (page, text) => { + const el = page.getByText(text, { exact: false }).first(); + await el.click({ timeout: 6000 }); +}; + +(async () => { + const browser = await chromium.launch({ channel: "chrome" }); + const ctx = await browser.newContext({ + viewport: { width: 430, height: 932 }, + deviceScaleFactor: 2, + locale: "fa-IR", + isMobile: true, + hasTouch: true, + }); + const page = await ctx.newPage(); + await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {}); + await page.waitForTimeout(3000); + await shot(page, "01-home"); + + // nav-rail screens reachable from home + for (const [label, name] of [ + ["جدول امتیازات", "02-leaderboard"], + ["دستاوردها", "03-achievements"], + ["فروشگاه", "04-shop"], + ["پروفایل", "05-profile"], + ]) { + try { + await tap(page, label); + await page.waitForTimeout(2200); + await shot(page, name); + // back to home for the next nav tap + await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {}); + await page.waitForTimeout(1500); + } catch (e) { + console.log("skip", name, String(e).split("\n")[0]); + await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {}); + await page.waitForTimeout(1500); + } + } + + // vs-computer game (cards on the table) + try { + await tap(page, "بازی با کامپیوتر"); + await page.waitForTimeout(5000); + await shot(page, "06-game"); + } catch (e) { + console.log("skip game", String(e).split("\n")[0]); + } + + await browser.close(); + console.log("DONE"); +})().catch((e) => { + console.error("FATAL", e); + process.exit(1); +}); diff --git a/src/app/page.tsx b/src/app/page.tsx index b6cc709..1b8ee24 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,7 +22,6 @@ import { ResumeGameBar } from "@/components/online/ResumeGameBar"; import { CelebrationOverlay } from "@/components/online/CelebrationOverlay"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { PublicProfileModal } from "@/components/online/PublicProfileModal"; -import { RotatePrompt } from "@/components/online/RotatePrompt"; import { CapacitorBack } from "@/components/CapacitorBack"; import { useSessionStore } from "@/lib/session-store"; import { useGameStore } from "@/lib/game-store"; @@ -192,12 +191,11 @@ export default function Page() { }; }, [init]); - // Landscape-first app (UNO-style): best-effort lock the whole app to landscape - // on Android / installed PWA. iOS & desktop reject it harmlessly; the - // RotatePrompt covers the portrait case there. + // Portrait-first app: best-effort lock the whole app to portrait on Android / + // installed PWA. iOS & desktop reject it harmlessly. useEffect(() => { const o = (screen as unknown as { orientation?: { lock?: (m: string) => Promise } }).orientation; - o?.lock?.("landscape").catch(() => {}); + o?.lock?.("portrait").catch(() => {}); }, []); return ( @@ -211,7 +209,6 @@ export default function Page() { - {loading && null} diff --git a/src/components/online/RotatePrompt.tsx b/src/components/online/RotatePrompt.tsx deleted file mode 100644 index d890a33..0000000 --- a/src/components/online/RotatePrompt.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { RotateCcw } from "lucide-react"; -import { useI18n } from "@/lib/i18n"; - -/** - * Landscape-first nudge for the game table. The Hokm table plays best wide - * (UNO-style), so when a phone is held in portrait we cover the table with a - * "rotate your device" prompt. iOS/Safari can't be force-rotated, so this is the - * reliable cross-platform path. A "play anyway" escape hatch avoids trapping - * users whose OS rotation-lock is on. - */ -export function RotatePrompt() { - const { t } = useI18n(); - const [portrait, setPortrait] = useState(false); - const [dismissed, setDismissed] = useState(false); - - useEffect(() => { - const check = () => { - const isPortrait = window.innerHeight > window.innerWidth; - // Only nudge on phone-sized screens (smaller side ≤ ~820px); desktops/wide - // tablets are already roomy enough. - const isPhone = Math.min(window.innerWidth, window.innerHeight) <= 820; - setPortrait(isPortrait && isPhone); - }; - check(); - window.addEventListener("resize", check); - window.addEventListener("orientationchange", check); - return () => { - window.removeEventListener("resize", check); - window.removeEventListener("orientationchange", check); - }; - }, []); - - if (!portrait || dismissed) return null; - - return ( -
- -

{t("rotate.title")}

-

{t("rotate.desc")}

- - - -
- ); -}