portrait-only: drop landscape rotate prompt + lock to portrait
- Remove RotatePrompt (the "rotate to landscape" overlay) — the app is portrait now, so it only blocked the UI. - page.tsx: best-effort orientation lock switched landscape → portrait. - Add Playwright-based store-screenshot + icon scripts (scripts/shots.js, game.js, icon.js); generated images are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -60,3 +60,7 @@ next-env.d.ts
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|
||||||
|
|
||||||
|
# store screenshot artifacts
|
||||||
|
/scripts/shots/
|
||||||
|
/store-assets/
|
||||||
|
|||||||
Generated
+48
@@ -31,6 +31,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.7",
|
"eslint-config-next": "16.2.7",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
@@ -4223,6 +4224,21 @@
|
|||||||
"node": ">= 10.0.0"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -6254,6 +6270,38 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.7",
|
"eslint-config-next": "16.2.7",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); });
|
||||||
@@ -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("<svg ", '<svg width="512" height="512" ');
|
||||||
|
const html = `<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0;padding:0">${square}</body></html>`;
|
||||||
|
|
||||||
|
(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); });
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
+3
-6
@@ -22,7 +22,6 @@ import { ResumeGameBar } from "@/components/online/ResumeGameBar";
|
|||||||
import { CelebrationOverlay } from "@/components/online/CelebrationOverlay";
|
import { CelebrationOverlay } from "@/components/online/CelebrationOverlay";
|
||||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
import { PublicProfileModal } from "@/components/online/PublicProfileModal";
|
import { PublicProfileModal } from "@/components/online/PublicProfileModal";
|
||||||
import { RotatePrompt } from "@/components/online/RotatePrompt";
|
|
||||||
import { CapacitorBack } from "@/components/CapacitorBack";
|
import { CapacitorBack } from "@/components/CapacitorBack";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore } from "@/lib/game-store";
|
||||||
@@ -192,12 +191,11 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
}, [init]);
|
}, [init]);
|
||||||
|
|
||||||
// Landscape-first app (UNO-style): best-effort lock the whole app to landscape
|
// Portrait-first app: best-effort lock the whole app to portrait on Android /
|
||||||
// on Android / installed PWA. iOS & desktop reject it harmlessly; the
|
// installed PWA. iOS & desktop reject it harmlessly.
|
||||||
// RotatePrompt covers the portrait case there.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const o = (screen as unknown as { orientation?: { lock?: (m: string) => Promise<void> } }).orientation;
|
const o = (screen as unknown as { orientation?: { lock?: (m: string) => Promise<void> } }).orientation;
|
||||||
o?.lock?.("landscape").catch(() => {});
|
o?.lock?.("portrait").catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -211,7 +209,6 @@ export default function Page() {
|
|||||||
<ResumeGameBar />
|
<ResumeGameBar />
|
||||||
<CelebrationOverlay />
|
<CelebrationOverlay />
|
||||||
<PublicProfileModal />
|
<PublicProfileModal />
|
||||||
<RotatePrompt />
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<CapacitorBack />
|
<CapacitorBack />
|
||||||
{loading && null}
|
{loading && 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 (
|
|
||||||
<div className="fixed inset-0 z-[80] flex flex-col items-center justify-center gap-6 bg-navy-950/96 backdrop-blur-md p-8 text-center safe-top safe-x">
|
|
||||||
<RotateCcw className="size-20 text-gold-400 motion-safe:animate-[rotateHint_2.4s_ease-in-out_infinite]" />
|
|
||||||
<h2 className="text-2xl font-black gold-text">{t("rotate.title")}</h2>
|
|
||||||
<p className="max-w-xs leading-8 text-cream/70">{t("rotate.desc")}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setDismissed(true)}
|
|
||||||
className="mt-2 text-sm text-cream/45 underline underline-offset-4 hover:text-cream/70"
|
|
||||||
>
|
|
||||||
{t("rotate.anyway")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes rotateHint {
|
|
||||||
0%, 40% { transform: rotate(0deg); }
|
|
||||||
60%, 100% { transform: rotate(-90deg); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user