From 5d38312ef0b3d15a38a7ff7e9dd5bc8dea51d218 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 8 Jun 2026 07:19:43 +0330 Subject: [PATCH] Marketing site (bargevasat.ir) + admin-editable store links + subdomain split - New standalone Next.js marketing site under site/ (static export, SEO): landing, download/install guide (Bazaar/Myket/iOS-PWA/web), FAQ (JSON-LD), privacy, terms, support, /admin link editor. fa RTL, sitemap/robots/manifest. - Backend: SiteLinksService (JSON-file persisted) + GET /api/site/links (public) + POST /api/admin/site/links (X-Admin-Token). ADMIN_TOKEN + Site__DataDir via env. - compose: hokm-site service (:1520) + hokm_data volume for links JSON. - CI deploy job builds + deploys the site container. - deploy/SUBDOMAIN_SPLIT.md: nginx blocks, cert reissue, DNS, ENV split. - Exclude site/ from root tsc + web docker context. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 1 + .gitea/workflows/ci-cd.yml | 18 +- deploy/ENV_FILE.example | 18 + deploy/SUBDOMAIN_SPLIT.md | 118 + docker-compose.yml | 30 + server/src/Hokm.Server/Program.cs | 16 + .../src/Hokm.Server/Site/SiteLinksService.cs | 120 + site/.dockerignore | 9 + site/.gitignore | 7 + site/Dockerfile | 23 + site/app/admin/page.tsx | 133 + site/app/download/page.tsx | 76 + site/app/faq/page.tsx | 45 + site/app/globals.css | 80 + site/app/layout.tsx | 81 + site/app/manifest.ts | 20 + site/app/page.tsx | 112 + site/app/privacy/page.tsx | 51 + site/app/robots.ts | 12 + site/app/sitemap.ts | 13 + site/app/support/page.tsx | 24 + site/app/terms/page.tsx | 41 + site/components/DownloadButtons.tsx | 85 + site/components/Footer.tsx | 48 + site/components/Logo.tsx | 24 + site/components/Nav.tsx | 72 + site/components/PageShell.tsx | 21 + site/components/SupportContact.tsx | 41 + site/eslint.config.mjs | 3 + site/lib/links.ts | 46 + site/lib/site.ts | 13 + site/next.config.ts | 12 + site/nginx.conf | 22 + site/package-lock.json | 6693 +++++++++++++++++ site/package.json | 29 + site/postcss.config.mjs | 5 + site/public/icon.svg | 4 + site/tsconfig.json | 41 + tsconfig.json | 2 +- 39 files changed, 8207 insertions(+), 2 deletions(-) create mode 100644 deploy/SUBDOMAIN_SPLIT.md create mode 100644 server/src/Hokm.Server/Site/SiteLinksService.cs create mode 100644 site/.dockerignore create mode 100644 site/.gitignore create mode 100644 site/Dockerfile create mode 100644 site/app/admin/page.tsx create mode 100644 site/app/download/page.tsx create mode 100644 site/app/faq/page.tsx create mode 100644 site/app/globals.css create mode 100644 site/app/layout.tsx create mode 100644 site/app/manifest.ts create mode 100644 site/app/page.tsx create mode 100644 site/app/privacy/page.tsx create mode 100644 site/app/robots.ts create mode 100644 site/app/sitemap.ts create mode 100644 site/app/support/page.tsx create mode 100644 site/app/terms/page.tsx create mode 100644 site/components/DownloadButtons.tsx create mode 100644 site/components/Footer.tsx create mode 100644 site/components/Logo.tsx create mode 100644 site/components/Nav.tsx create mode 100644 site/components/PageShell.tsx create mode 100644 site/components/SupportContact.tsx create mode 100644 site/eslint.config.mjs create mode 100644 site/lib/links.ts create mode 100644 site/lib/site.ts create mode 100644 site/next.config.ts create mode 100644 site/nginx.conf create mode 100644 site/package-lock.json create mode 100644 site/package.json create mode 100644 site/postcss.config.mjs create mode 100644 site/public/icon.svg create mode 100644 site/tsconfig.json diff --git a/.dockerignore b/.dockerignore index a011f4b..609277b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ node_modules out android server +site .git .gitea *.md diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 7680c0c..35141df 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -132,7 +132,7 @@ jobs: if [ -n "$CURRENT" ]; then docker tag "$CURRENT" hokm-server:rollback && echo "rollback tag = $CURRENT"; fi - name: Build images - run: docker compose build --parallel server web + run: docker compose build --parallel server web site env: DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 @@ -182,6 +182,22 @@ jobs: sleep 5 done + - name: Deploy marketing site (stop + rm + up, no force-recreate) + run: | + docker stop hokm-site 2>/dev/null || true + docker rm hokm-site 2>/dev/null || true + docker compose up -d --no-deps site + + - name: Wait for site healthy + run: | + for i in $(seq 1 18); do + S=$(docker inspect --format='{{.State.Health.Status}}' hokm-site 2>/dev/null || echo missing) + echo " [$i/18] site: $S" + [ "$S" = "healthy" ] && { echo "OK hokm-site healthy"; break; } + [ "$i" = "18" ] && { echo "TIMEOUT hokm-site"; docker compose logs --tail=40 site; exit 1; } + sleep 5 + done + - name: Prune dangling images if: success() run: docker image prune -f diff --git a/deploy/ENV_FILE.example b/deploy/ENV_FILE.example index 9c98ad1..668c49f 100644 --- a/deploy/ENV_FILE.example +++ b/deploy/ENV_FILE.example @@ -56,6 +56,24 @@ IAB_MYKET_ACCESS_TOKEN= # store creds). NEVER true in production. IAB_ALLOW_UNVERIFIED=false +# ────────────────────────────────────────────────────────────────────────── +# Marketing site (bargevasat.ir) + subdomain split +# Game → app.bargevasat.ir ; marketing site → bargevasat.ir +# ────────────────────────────────────────────────────────────────────────── +SITE_PORT=1520 +# Browser-facing URLs baked into the marketing site at BUILD time: +NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir # the game (CTA buttons) +NEXT_PUBLIC_SITE_URL=https://bargevasat.ir # canonical/SEO base +# (NEXT_PUBLIC_SERVER_URL above is reused by the site to read store links.) + +# Admin panel (edit Bazaar/Myket/iOS links at /admin). Shared-token auth. +# Generate with: openssl rand -hex 24 +ADMIN_TOKEN=7ec8b2b242695de7d2692185acb4f1d345a589866ddd2de6 + +# With the split, set these too (game bundle + CORS for all 3 hosts): +# NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir +# CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir,https://app.bargevasat.ir + # ────────────────────────────────────────────────────────────────────────── # PRODUCTION (bargevasat.ir) — use these values instead of the local ones above, # and deploy with the Caddy overlay (see PRODUCTION.md). DNS: bargevasat.ir, diff --git a/deploy/SUBDOMAIN_SPLIT.md b/deploy/SUBDOMAIN_SPLIT.md new file mode 100644 index 0000000..853c067 --- /dev/null +++ b/deploy/SUBDOMAIN_SPLIT.md @@ -0,0 +1,118 @@ +# Subdomain split: marketing site + game + API + +After this change there are **three** public hosts (all → edge nginx `185.239.1.100`): + +| Host | Serves | Upstream (on 171.22.25.73) | +|---|---|---| +| `bargevasat.ir`, `www.bargevasat.ir` | Marketing site (`hokm-site`) | `:1520` | +| `app.bargevasat.ir` | The game (`hokm-web`) | `:1500` | +| `api.bargevasat.ir` | API + SignalR (`hokm-server`) | `:1505` (CDN **bypass**) | + +## 1. DNS +Add/confirm A‑records (all → `185.239.1.100`): +``` +bargevasat.ir A 185.239.1.100 (CDN ok) +www.bargevasat.ir A 185.239.1.100 (CDN ok) +app.bargevasat.ir A 185.239.1.100 (CDN ok) +api.bargevasat.ir A 185.239.1.100 (CDN BYPASS / DNS-only) +``` + +## 2. TLS cert — reissue to include `app` +The current cert covers `bargevasat.ir, www, api` — add `app`: +```bash +sudo certbot certonly --webroot -w /var/www/certbot \ + -d bargevasat.ir -d www.bargevasat.ir -d app.bargevasat.ir -d api.bargevasat.ir \ + --agree-tos --no-eff-email --email you@example.com +# then copy/symlink fullchain.pem + privkey.pem into /etc/ssl/bargevasat/ +``` +(Or DNS‑01 if behind the CDN — see SSL notes.) + +## 3. nginx (edit /root/mirror-server/nginx/nginx.conf) +Replace the single Barg‑e Vasat web block with these three: + +```nginx +# Redirect http → https for all three +server { + listen 80; + server_name bargevasat.ir www.bargevasat.ir app.bargevasat.ir api.bargevasat.ir; + location /.well-known/acme-challenge/ { root /var/www/certbot; } + location / { return 301 https://$host$request_uri; } +} + +# Marketing site → hokm-site :1520 +server { + listen 443 ssl; + http2 on; + server_name bargevasat.ir www.bargevasat.ir; + ssl_certificate /etc/ssl/bargevasat/fullchain.pem; + ssl_certificate_key /etc/ssl/bargevasat/privateKey.pem; + location / { + proxy_pass http://171.22.25.73:1520; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# Game (Next static SPA) → hokm-web :1500 +server { + listen 443 ssl; + http2 on; + server_name app.bargevasat.ir; + client_max_body_size 25m; + ssl_certificate /etc/ssl/bargevasat/fullchain.pem; + ssl_certificate_key /etc/ssl/bargevasat/privateKey.pem; + location / { + proxy_pass http://171.22.25.73:1500; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# API + SignalR → hokm-server :1505 (WebSocket; keep CDN bypassed for this host) +server { + listen 443 ssl; + http2 on; + server_name api.bargevasat.ir; + client_max_body_size 50m; + ssl_certificate /etc/ssl/bargevasat/fullchain.pem; + ssl_certificate_key /etc/ssl/bargevasat/privateKey.pem; + location / { + proxy_pass http://171.22.25.73:1505; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + } +} +``` +Reload: `docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reload` + +## 4. ENV_FILE secret (Gitea) — add/confirm +``` +SITE_PORT=1520 +NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir +NEXT_PUBLIC_SITE_URL=https://bargevasat.ir +NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir +CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir,https://app.bargevasat.ir +ADMIN_TOKEN= +``` + +## 5. Deploy +`docker compose build site web server && docker compose up -d` +(Add the `site` service to the CI deploy job's build/up + health‑wait, same pattern as web.) + +## 6. Verify +```bash +curl -I https://bargevasat.ir # marketing (200) +curl -I https://app.bargevasat.ir # game (200) +curl -I https://api.bargevasat.ir # API (405 to HEAD is fine) +``` +Admin: open `https://bargevasat.ir/admin`, enter `ADMIN_TOKEN`, set Bazaar/Myket links → Save. diff --git a/docker-compose.yml b/docker-compose.yml index 433fe89..957dd10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,12 @@ services: Iab__BazaarRefreshToken: ${IAB_BAZAAR_REFRESH_TOKEN:-} Iab__MyketAccessToken: ${IAB_MYKET_ACCESS_TOKEN:-} Iab__AllowUnverified: ${IAB_ALLOW_UNVERIFIED:-false} + # Admin panel (marketing-site links editor) — shared-token auth. + Admin__Token: ${ADMIN_TOKEN:-} + # Where the admin-editable site-links JSON is persisted (mounted volume). + Site__DataDir: /data + volumes: + - hokm_data:/data ports: - "${API_PORT:-1505}:5005" healthcheck: @@ -98,5 +104,29 @@ services: retries: 6 start_period: 10s + # Marketing website (bargevasat.ir) — separate static Next.js project in ./site. + site: + build: + context: ./site + dockerfile: Dockerfile + args: + # Browser-facing API (for reading admin-editable store links) + game URL. + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_SERVER_URL:-http://localhost:1505} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:1500} + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:1520} + NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/} + image: hokm-site:latest + container_name: hokm-site + restart: unless-stopped + ports: + - "${SITE_PORT:-1520}:80" + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1/"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + volumes: hokm_db_data: + hokm_data: diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs index 5d97393..dd3b12d 100644 --- a/server/src/Hokm.Server/Program.cs +++ b/server/src/Hokm.Server/Program.cs @@ -7,6 +7,7 @@ using Hokm.Server.Game; using Hokm.Server.Hubs; using Hokm.Server.Payments; using Hokm.Server.Profiles; +using Hokm.Server.Site; using Hokm.Server.Social; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; @@ -51,6 +52,11 @@ var iab = builder.Configuration.GetSection("Iab").Get() ?? new IabOp builder.Services.AddSingleton(iab); builder.Services.AddSingleton(); +// --- Marketing site links (admin-editable) + shared-token admin auth --- +var admin = builder.Configuration.GetSection("Admin").Get() ?? new AdminOptions(); +builder.Services.AddSingleton(admin); +builder.Services.AddSingleton(); + // --- SignalR (camelCase to match the TS client) --- builder.Services .AddSignalR() @@ -126,6 +132,16 @@ app.UseAuthorization(); app.MapGet("/", () => Results.Json(new { service = "Barg-e Vasat SignalR server", status = "ok" })); app.MapGet("/api/stats/online", (GameManager m) => Results.Json(new { online = m.OnlineCount })); +// --- Marketing site links: public read, admin-token write --- +app.MapGet("/api/site/links", (SiteLinksService s) => Results.Json(s.Get(), JsonOpts.Default)); +app.MapPost("/api/admin/site/links", (HttpRequest req, AdminOptions admin, SiteLinksService s, SiteLinks body) => +{ + var token = req.Headers["X-Admin-Token"].ToString(); + if (string.IsNullOrWhiteSpace(admin.Token) || token != admin.Token) + return Results.Json(new { error = "UNAUTHORIZED" }, statusCode: 401); + return Results.Json(s.Update(body), JsonOpts.Default); +}); + // --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. --- app.MapPost("/api/auth/otp/request", (OtpRequest req) => Results.Json(new { devCode = "1234", phone = req.Phone })); diff --git a/server/src/Hokm.Server/Site/SiteLinksService.cs b/server/src/Hokm.Server/Site/SiteLinksService.cs new file mode 100644 index 0000000..1ff6b06 --- /dev/null +++ b/server/src/Hokm.Server/Site/SiteLinksService.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Hokm.Server.Site; + +/// Admin-editable links + flags shown on the marketing site (bargevasat.ir). +public class SiteLinks +{ + // Android stores + public string BazaarUrl { get; set; } = ""; + public bool BazaarEnabled { get; set; } = false; + public string MyketUrl { get; set; } = ""; + public bool MyketEnabled { get; set; } = false; + + // Direct APK (optional, for sideloading) + public string DirectApkUrl { get; set; } = ""; + public bool DirectApkEnabled { get; set; } = false; + + // Play on web / PWA + public string WebPlayUrl { get; set; } = "https://app.bargevasat.ir"; + public bool IosPwaEnabled { get; set; } = true; // iOS = Add to Home Screen + + // Socials / support + public string Instagram { get; set; } = ""; + public string Telegram { get; set; } = ""; + public string SupportEmail { get; set; } = ""; + public string SupportPhone { get; set; } = ""; + + public string AppVersion { get; set; } = ""; +} + +/// Shared-token admin auth (set ADMIN_TOKEN in ENV_FILE). +public class AdminOptions +{ + public string Token { get; set; } = ""; +} + +/// +/// Loads/persists as a JSON file under a writable data dir +/// (mount a volume at it in prod). No DB migration required. +/// +public class SiteLinksService +{ + private readonly string _path; + private readonly object _gate = new(); + private SiteLinks _current; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + WriteIndented = true, + }; + + public SiteLinksService(IConfiguration config) + { + var dataDir = config["Site:DataDir"]; + if (string.IsNullOrWhiteSpace(dataDir)) dataDir = "/data"; + try { Directory.CreateDirectory(dataDir); } catch { /* fall back below */ } + if (!CanWrite(dataDir)) dataDir = AppContext.BaseDirectory; // dev fallback + _path = Path.Combine(dataDir, "site-links.json"); + + _current = Load() ?? Seed(config); + // Persist the seed so the file exists for the admin to edit. + if (!File.Exists(_path)) TrySave(_current); + } + + public SiteLinks Get() + { + lock (_gate) return Clone(_current); + } + + public SiteLinks Update(SiteLinks next) + { + lock (_gate) + { + _current = next; + TrySave(_current); + return Clone(_current); + } + } + + private SiteLinks? Load() + { + try + { + if (!File.Exists(_path)) return null; + return JsonSerializer.Deserialize(File.ReadAllText(_path), JsonOpts); + } + catch { return null; } + } + + private void TrySave(SiteLinks v) + { + try { File.WriteAllText(_path, JsonSerializer.Serialize(v, JsonOpts)); } + catch { /* read-only fs in dev — keep in-memory only */ } + } + + // Seed defaults from config (Site section) when no file exists yet. + private static SiteLinks Seed(IConfiguration config) + { + var seeded = config.GetSection("Site:Links").Get(); + return seeded ?? new SiteLinks(); + } + + private static SiteLinks Clone(SiteLinks v) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(v, JsonOpts), JsonOpts)!; + + private static bool CanWrite(string dir) + { + try + { + var probe = Path.Combine(dir, ".write-test"); + File.WriteAllText(probe, "ok"); + File.Delete(probe); + return true; + } + catch { return false; } + } +} diff --git a/site/.dockerignore b/site/.dockerignore new file mode 100644 index 0000000..94676cb --- /dev/null +++ b/site/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +out +.git +*.md +Dockerfile +.dockerignore +npm-debug.log* +tsconfig.tsbuildinfo diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..fdee3c6 --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,7 @@ +/node_modules +/.next/ +/out/ +/.turbo +*.tsbuildinfo +next-env.d.ts +.DS_Store diff --git a/site/Dockerfile b/site/Dockerfile new file mode 100644 index 0000000..9b6dc04 --- /dev/null +++ b/site/Dockerfile @@ -0,0 +1,23 @@ +# Barg-e Vasat marketing site (Next.js static export → nginx). +FROM mirror.soroushasadi.com/node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +ARG NPM_REGISTRY=http://171.22.25.73:8081/repository/npm-group/ +RUN npm ci --legacy-peer-deps --strict-ssl=false --no-audit --no-fund \ + --registry "${NPM_REGISTRY}" +COPY . . +# Public URLs baked at build time (browser-facing). +ARG NEXT_PUBLIC_API_URL=https://api.bargevasat.ir +ARG NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir +ARG NEXT_PUBLIC_SITE_URL=https://bargevasat.ir +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL +RUN npm run build + +FROM mirror.soroushasadi.com/nginx:alpine +COPY --from=build /app/out /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +HEALTHCHECK --interval=10s --timeout=5s --retries=6 --start-period=10s \ + CMD wget -q -O- http://127.0.0.1/ || exit 1 diff --git a/site/app/admin/page.tsx b/site/app/admin/page.tsx new file mode 100644 index 0000000..694e2f1 --- /dev/null +++ b/site/app/admin/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; +import { fetchLinks, FALLBACK_LINKS, type SiteLinks } from "@/lib/links"; +import { API_URL } from "@/lib/site"; + +type Field = { key: keyof SiteLinks; label: string; type: "text" | "bool" }; + +const FIELDS: Field[] = [ + { key: "bazaarUrl", label: "لینک کافه‌بازار", type: "text" }, + { key: "bazaarEnabled", label: "نمایش دکمهٔ کافه‌بازار", type: "bool" }, + { key: "myketUrl", label: "لینک مایکت", type: "text" }, + { key: "myketEnabled", label: "نمایش دکمهٔ مایکت", type: "bool" }, + { key: "directApkUrl", label: "لینک دانلود مستقیم APK", type: "text" }, + { key: "directApkEnabled", label: "نمایش دانلود مستقیم", type: "bool" }, + { key: "webPlayUrl", label: "آدرس بازی (وب)", type: "text" }, + { key: "iosPwaEnabled", label: "نمایش نصب iOS/PWA", type: "bool" }, + { key: "instagram", label: "اینستاگرام", type: "text" }, + { key: "telegram", label: "تلگرام", type: "text" }, + { key: "supportEmail", label: "ایمیل پشتیبانی", type: "text" }, + { key: "supportPhone", label: "تلفن پشتیبانی", type: "text" }, + { key: "appVersion", label: "نسخهٔ اپ", type: "text" }, +]; + +export default function AdminPage() { + const [token, setToken] = useState(""); + const [authed, setAuthed] = useState(false); + const [links, setLinks] = useState(FALLBACK_LINKS); + const [msg, setMsg] = useState(null); + const [busy, setBusy] = useState(false); + + async function login() { + setBusy(true); + setMsg(null); + const l = await fetchLinks(); + setLinks(l); + setAuthed(true); + setBusy(false); + } + + async function save() { + setBusy(true); + setMsg(null); + try { + const res = await fetch(`${API_URL}/api/admin/site/links`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Admin-Token": token }, + body: JSON.stringify(links), + }); + if (res.status === 401) { + setMsg("توکن نامعتبر است."); + } else if (!res.ok) { + setMsg("خطا در ذخیره."); + } else { + setLinks(await res.json()); + setMsg("ذخیره شد ✓"); + } + } catch { + setMsg("سرور در دسترس نیست."); + } + setBusy(false); + } + + function set(k: K, v: SiteLinks[K]) { + setLinks((p) => ({ ...p, [k]: v })); + } + + if (!authed) { + return ( +
+

ورود مدیریت

+

توکن مدیریت را وارد کن.

+ setToken(e.target.value)} + placeholder="ADMIN_TOKEN" + className="mt-5 w-full rounded-xl bg-navy-800 px-4 py-3 text-cream outline-none ring-1 ring-gold/20 focus:ring-gold/50" + /> + +

+ توکن همان مقدار ADMIN_TOKEN در فایل محیطی سرور است. ذخیره هنگام «ثبت» اعتبارسنجی می‌شود. +

+
+ ); + } + + return ( +
+

مدیریت لینک‌ها

+

لینک‌های کافه‌بازار، مایکت، شبکه‌های اجتماعی و پشتیبانی را اینجا تنظیم کن.

+ +
+ {FIELDS.map((f) => + f.type === "bool" ? ( + + ) : ( +
+ + set(f.key, e.target.value as never)} + className="w-full rounded-xl bg-navy-800 px-4 py-2.5 text-cream outline-none ring-1 ring-gold/15 focus:ring-gold/50" + /> +
+ ) + )} +
+ +
+ + {msg && {msg}} +
+
+ ); +} diff --git a/site/app/download/page.tsx b/site/app/download/page.tsx new file mode 100644 index 0000000..a8e8ae2 --- /dev/null +++ b/site/app/download/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import { PageShell } from "@/components/PageShell"; +import { DownloadButtons } from "@/components/DownloadButtons"; + +export const metadata: Metadata = { + title: "دانلود و نصب", + description: + "برگ وسط را روی اندروید (کافه‌بازار، مایکت)، آیفون (نصب وب/PWA) یا مستقیماً در مرورگر اجرا کن. راهنمای گام‌به‌گام نصب.", + alternates: { canonical: "/download" }, +}; + +function Steps({ items }: { items: string[] }) { + return ( +
    + {items.map((s, i) => ( +
  1. + + {i + 1} + + {s} +
  2. + ))} +
+ ); +} + +export default function DownloadPage() { + return ( + +
+ +
+ + {/* Web */} +
+

🌐 بازی در مرورگر (بدون نصب)

+

+ سریع‌ترین راه: کافی است آدرس بازی را در مرورگر باز کنی و وارد شوی. هیچ نصبی لازم نیست و روی هر دستگاهی کار می‌کند. +

+
+ + {/* Android */} +
+

🤖 اندروید

+

از کافه‌بازار یا مایکت نصب کن، یا اپ وب را به صفحهٔ اصلی اضافه کن:

+ +
+ + {/* iOS */} +
+

🍏 آیفون و آیپد (iOS)

+

+ روی iOS بازی را به‌صورت وب‌اپ (PWA) نصب کن — درست مثل یک اپ واقعی، با آیکن روی صفحهٔ اصلی: +

+ +

+ نکته: روی آیفون حتماً از مرورگر Safari استفاده کن؛ افزودن به صفحهٔ اصلی فقط در Safari کار می‌کند. +

+
+
+ ); +} diff --git a/site/app/faq/page.tsx b/site/app/faq/page.tsx new file mode 100644 index 0000000..cb0008f --- /dev/null +++ b/site/app/faq/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { PageShell } from "@/components/PageShell"; + +export const metadata: Metadata = { + title: "سوال‌های متداول", + description: "پاسخ پرسش‌های رایج دربارهٔ بازی حکم آنلاین برگ وسط — رایگان بودن، نصب، بازی با دوستان و سکه‌ها.", + alternates: { canonical: "/faq" }, +}; + +const FAQ = [ + { q: "بازی رایگان است؟", a: "بله، برگ وسط کاملاً رایگان است. می‌توانی همهٔ بخش‌ها را بدون پرداخت بازی کنی. خرید سکه فقط اختیاری است." }, + { q: "چطور با دوستانم بازی کنم؟", a: "یک اتاق خصوصی بساز، کد اتاق را برای دوستانت بفرست و هم‌تیمی و حریف‌هایت را انتخاب کن." }, + { q: "اینترنت لازم دارم؟", a: "برای بازی آنلاین بله، اما بخش «بازی با کامپیوتر» کاملاً آفلاین کار می‌کند." }, + { q: "روی آیفون نصب می‌شود؟", a: "بله، روی iOS از طریق Safari بازی را به صفحهٔ اصلی اضافه کن (PWA). راهنمای کامل در صفحهٔ دانلود هست." }, + { q: "سکه‌ها به چه درد می‌خورند؟", a: "با سکه در لیگ‌های بالاتر بازی می‌کنی و آیتم‌های ظاهری مثل آواتار، طرح کارت و عنوان می‌خری." }, + { q: "اگر وسط بازی قطع شوم چه می‌شود؟", a: "بازی‌ات زنده می‌ماند و می‌توانی برگردی و ادامه دهی." }, + { q: "کُت (کوت) یعنی چه؟", a: "اگر تیم حاکم همهٔ ۷ دست را ببرد، حریف «کُت» می‌شود و امتیاز و جایزهٔ بیشتری می‌گیری." }, +]; + +export default function FaqPage() { + const jsonLd = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: FAQ.map((f) => ({ + "@type": "Question", + name: f.q, + acceptedAnswer: { "@type": "Answer", text: f.a }, + })), + }; + return ( + +