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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ node_modules
|
||||
out
|
||||
android
|
||||
server
|
||||
site
|
||||
.git
|
||||
.gitea
|
||||
*.md
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=<openssl rand -hex 24>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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<IabOptions>() ?? new IabOp
|
||||
builder.Services.AddSingleton(iab);
|
||||
builder.Services.AddSingleton<IabService>();
|
||||
|
||||
// --- Marketing site links (admin-editable) + shared-token admin auth ---
|
||||
var admin = builder.Configuration.GetSection("Admin").Get<AdminOptions>() ?? new AdminOptions();
|
||||
builder.Services.AddSingleton(admin);
|
||||
builder.Services.AddSingleton<SiteLinksService>();
|
||||
|
||||
// --- 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 }));
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Hokm.Server.Site;
|
||||
|
||||
/// <summary>Admin-editable links + flags shown on the marketing site (bargevasat.ir).</summary>
|
||||
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; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Shared-token admin auth (set ADMIN_TOKEN in ENV_FILE).</summary>
|
||||
public class AdminOptions
|
||||
{
|
||||
public string Token { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads/persists <see cref="SiteLinks"/> as a JSON file under a writable data dir
|
||||
/// (mount a volume at it in prod). No DB migration required.
|
||||
/// </summary>
|
||||
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<SiteLinks>(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<SiteLinks>();
|
||||
return seeded ?? new SiteLinks();
|
||||
}
|
||||
|
||||
private static SiteLinks Clone(SiteLinks v) =>
|
||||
JsonSerializer.Deserialize<SiteLinks>(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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.git
|
||||
*.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
npm-debug.log*
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -0,0 +1,7 @@
|
||||
/node_modules
|
||||
/.next/
|
||||
/out/
|
||||
/.turbo
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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<SiteLinks>(FALLBACK_LINKS);
|
||||
const [msg, setMsg] = useState<string | null>(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 extends keyof SiteLinks>(k: K, v: SiteLinks[K]) {
|
||||
setLinks((p) => ({ ...p, [k]: v }));
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<section className="mx-auto max-w-md px-4 py-20">
|
||||
<h1 className="text-2xl font-black gold-text">ورود مدیریت</h1>
|
||||
<p className="mt-2 text-sm text-cream/60">توکن مدیریت را وارد کن.</p>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={login}
|
||||
disabled={!token || busy}
|
||||
className="mt-4 w-full rounded-xl btn-gold px-4 py-3 disabled:opacity-50"
|
||||
>
|
||||
ورود
|
||||
</button>
|
||||
<p className="mt-3 text-xs text-cream/45">
|
||||
توکن همان مقدار ADMIN_TOKEN در فایل محیطی سرور است. ذخیره هنگام «ثبت» اعتبارسنجی میشود.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-2xl px-4 py-14">
|
||||
<h1 className="text-2xl font-black gold-text">مدیریت لینکها</h1>
|
||||
<p className="mt-2 text-sm text-cream/60">لینکهای کافهبازار، مایکت، شبکههای اجتماعی و پشتیبانی را اینجا تنظیم کن.</p>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{FIELDS.map((f) =>
|
||||
f.type === "bool" ? (
|
||||
<label key={f.key} className="glass flex items-center justify-between rounded-xl px-4 py-3">
|
||||
<span>{f.label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(links[f.key])}
|
||||
onChange={(e) => set(f.key, e.target.checked as never)}
|
||||
className="h-5 w-5 accent-[#d4af37]"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div key={f.key}>
|
||||
<label className="mb-1 block text-sm text-cream/70">{f.label}</label>
|
||||
<input
|
||||
dir="ltr"
|
||||
value={String(links[f.key] ?? "")}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<button onClick={save} disabled={busy} className="rounded-xl btn-gold px-6 py-3 disabled:opacity-50">
|
||||
ثبت تغییرات
|
||||
</button>
|
||||
{msg && <span className="text-sm text-cream/80">{msg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ol className="space-y-3">
|
||||
{items.map((s, i) => (
|
||||
<li key={i} className="flex gap-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full btn-gold text-sm font-black">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="pt-0.5 text-cream/80">{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DownloadPage() {
|
||||
return (
|
||||
<PageShell title="دانلود و نصب" subtitle="هر طور که دوست داری بازی کن — روی گوشی نصب کن یا مستقیم در مرورگر اجرا کن.">
|
||||
<div className="mb-8">
|
||||
<DownloadButtons variant="full" />
|
||||
</div>
|
||||
|
||||
{/* Web */}
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-bold text-cream">🌐 بازی در مرورگر (بدون نصب)</h2>
|
||||
<p className="mt-2 text-cream/70">
|
||||
سریعترین راه: کافی است آدرس بازی را در مرورگر باز کنی و وارد شوی. هیچ نصبی لازم نیست و روی هر دستگاهی کار میکند.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Android */}
|
||||
<div id="android" className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-bold text-cream">🤖 اندروید</h2>
|
||||
<p className="mt-2 mb-4 text-cream/70">از کافهبازار یا مایکت نصب کن، یا اپ وب را به صفحهٔ اصلی اضافه کن:</p>
|
||||
<Steps
|
||||
items={[
|
||||
"آدرس بازی را در مرورگر کروم باز کن.",
|
||||
"روی منوی سهنقطهٔ بالا-راست بزن.",
|
||||
"گزینهٔ «افزودن به صفحهٔ اصلی / Install app» را انتخاب کن.",
|
||||
"آیکن برگ وسط مثل یک اپ روی گوشیات مینشیند.",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* iOS */}
|
||||
<div id="ios" className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-bold text-cream">🍏 آیفون و آیپد (iOS)</h2>
|
||||
<p className="mt-2 mb-4 text-cream/70">
|
||||
روی iOS بازی را بهصورت وباپ (PWA) نصب کن — درست مثل یک اپ واقعی، با آیکن روی صفحهٔ اصلی:
|
||||
</p>
|
||||
<Steps
|
||||
items={[
|
||||
"آدرس بازی را در مرورگر Safari باز کن.",
|
||||
"روی دکمهٔ «اشتراکگذاری» (مربع با فلش رو به بالا) بزن.",
|
||||
"کمی پایین برو و «Add to Home Screen / افزودن به صفحهٔ اصلی» را انتخاب کن.",
|
||||
"روی «Add» بزن — آیکن برگ وسط روی صفحهٔ اصلی اضافه میشود و تمامصفحه اجرا میشود.",
|
||||
]}
|
||||
/>
|
||||
<p className="mt-4 text-sm text-cream/55">
|
||||
نکته: روی آیفون حتماً از مرورگر Safari استفاده کن؛ افزودن به صفحهٔ اصلی فقط در Safari کار میکند.
|
||||
</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<PageShell title="سوالهای متداول">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<div className="space-y-3">
|
||||
{FAQ.map((f) => (
|
||||
<details key={f.q} className="glass group rounded-2xl p-5">
|
||||
<summary className="cursor-pointer list-none text-lg font-bold text-cream marker:hidden">
|
||||
{f.q}
|
||||
</summary>
|
||||
<p className="mt-3 text-cream/70">{f.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/vazirmatn";
|
||||
|
||||
:root {
|
||||
--navy-950: #070b18;
|
||||
--navy-900: #0b1226;
|
||||
--navy-800: #111a33;
|
||||
--gold: #d4af37;
|
||||
--gold-soft: #e7c873;
|
||||
--teal: #2dd4bf;
|
||||
--cream: #f5efe0;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-navy-950: var(--navy-950);
|
||||
--color-navy-900: var(--navy-900);
|
||||
--color-navy-800: var(--navy-800);
|
||||
--color-gold: var(--gold);
|
||||
--color-gold-soft: var(--gold-soft);
|
||||
--color-teal: var(--teal);
|
||||
--color-cream: var(--cream);
|
||||
--font-sans: "Vazirmatn Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(120% 120% at 50% 0%, #0e1730 0%, var(--navy-950) 60%);
|
||||
color: var(--cream);
|
||||
font-family: "Vazirmatn Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
[dir="rtl"] {
|
||||
font-family: "Vazirmatn Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Utility helpers */
|
||||
.gold-text {
|
||||
background: linear-gradient(180deg, var(--gold-soft), var(--gold));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(17, 26, 51, 0.55);
|
||||
border: 1px solid rgba(212, 175, 55, 0.18);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.btn-gold {
|
||||
background: linear-gradient(180deg, var(--gold-soft), var(--gold));
|
||||
color: #1a1206;
|
||||
font-weight: 800;
|
||||
}
|
||||
.btn-gold:hover {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
.felt {
|
||||
background:
|
||||
radial-gradient(120% 120% at 50% 0%, rgba(45, 212, 191, 0.08), transparent 60%),
|
||||
radial-gradient(80% 80% at 80% 90%, rgba(212, 175, 55, 0.06), transparent 60%);
|
||||
}
|
||||
|
||||
.card-pattern {
|
||||
background-image:
|
||||
linear-gradient(45deg, rgba(212, 175, 55, 0.04) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(212, 175, 55, 0.04) 25%, transparent 25%);
|
||||
background-size: 22px 22px;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { BRAND, SITE_URL } from "@/lib/site";
|
||||
import { Nav } from "@/components/Nav";
|
||||
import { Footer } from "@/components/Footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: `${BRAND.nameFa} | بازی حکم آنلاین رایگان`,
|
||||
template: `%s | ${BRAND.nameFa}`,
|
||||
},
|
||||
description: BRAND.descFa,
|
||||
keywords: [
|
||||
"حکم",
|
||||
"بازی حکم",
|
||||
"حکم آنلاین",
|
||||
"بازی ورق ایرانی",
|
||||
"برگ وسط",
|
||||
"بازی کارتی آنلاین",
|
||||
"حکم با دوستان",
|
||||
"Hokm",
|
||||
"Barg-e Vasat",
|
||||
],
|
||||
applicationName: BRAND.nameFa,
|
||||
authors: [{ name: BRAND.nameFa }],
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "fa_IR",
|
||||
url: SITE_URL,
|
||||
siteName: BRAND.nameFa,
|
||||
title: `${BRAND.nameFa} | بازی حکم آنلاین رایگان`,
|
||||
description: BRAND.descFa,
|
||||
images: [{ url: "/og.png", width: 1200, height: 630, alt: BRAND.nameFa }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${BRAND.nameFa} | بازی حکم آنلاین`,
|
||||
description: BRAND.descFa,
|
||||
images: ["/og.png"],
|
||||
},
|
||||
icons: { icon: "/icon.svg", apple: "/icon.svg" },
|
||||
manifest: "/manifest.webmanifest",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#070b18",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoGame",
|
||||
name: "برگ وسط",
|
||||
alternateName: "Barg-e Vasat",
|
||||
description: BRAND.descFa,
|
||||
url: SITE_URL,
|
||||
applicationCategory: "GameApplication",
|
||||
genre: "بازی کارتی",
|
||||
operatingSystem: "Android, iOS, Web",
|
||||
inLanguage: "fa-IR",
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "IRR" },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl">
|
||||
<body>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<Nav />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "برگ وسط — بازی حکم آنلاین",
|
||||
short_name: "برگ وسط",
|
||||
description: "بازی حکم ایرانی آنلاین با دوستان و هوش مصنوعی.",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#070b18",
|
||||
theme_color: "#070b18",
|
||||
dir: "rtl",
|
||||
lang: "fa",
|
||||
icons: [
|
||||
{ src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Users, Bot, Trophy, Gift, MessageCircle, Globe, ShieldCheck, Zap, Crown, Star,
|
||||
} from "lucide-react";
|
||||
import { DownloadButtons } from "@/components/DownloadButtons";
|
||||
import { BRAND } from "@/lib/site";
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: Users, title: "حکم ۴ نفره آنلاین", desc: "با بازیکنهای واقعی از سراسر ایران، دونفره و تیمی بازی کن." },
|
||||
{ icon: Bot, title: "بازی با هوش مصنوعی", desc: "آفلاین و بدون اینترنت، با رباتهای هوشمند تمرین کن." },
|
||||
{ icon: Trophy, title: "لیگ و رتبهبندی", desc: "از لیگ مبتدی تا استاد بالا برو و در جدول قهرمانان بدرخش." },
|
||||
{ icon: Gift, title: "جایزههای روزانه", desc: "هر روز سکه بگیر، دستاورد باز کن و جوایز ویژه ببر." },
|
||||
{ icon: MessageCircle, title: "چت و شکلک", desc: "سر میز با همتیمی و حریف کلکل کن؛ استیکرهای فارسی." },
|
||||
{ icon: Globe, title: "همهجا در دسترس", desc: "اندروید، آیفون و مرورگر — پیشرفتت همهجا همگام میشود." },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ n: "۱", title: "وارد شو", desc: "با شماره موبایل ثبتنام کن — سریع و رایگان." },
|
||||
{ n: "۲", title: "میز انتخاب کن", desc: "بازی سریع آنلاین، اتاق خصوصی با دوستان، یا بازی با کامپیوتر." },
|
||||
{ n: "۳", title: "حکم بزن و ببر", desc: "حاکم شو، خال حکم را انتخاب کن و حریف را کُت کن!" },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ icon: Zap, label: "بازی سریع", value: "زیر ۱۵ ثانیه شروع" },
|
||||
{ icon: ShieldCheck, label: "بدون تقلب", value: "سرور منصف و امن" },
|
||||
{ icon: Crown, label: "کاملاً رایگان", value: "بدون اجبار خرید" },
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="felt card-pattern">
|
||||
<div className="mx-auto max-w-6xl px-4 py-16 text-center sm:py-24">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full glass px-3 py-1 text-xs text-gold-soft">
|
||||
<Star size={13} /> بازی حکمِ ایرانی، حرفهایتر از همیشه
|
||||
</span>
|
||||
<h1 className="mx-auto mt-6 max-w-3xl text-4xl font-black leading-tight sm:text-6xl">
|
||||
<span className="gold-text">{BRAND.nameFa}</span>
|
||||
<br />
|
||||
بازی حکم آنلاین با دوستان
|
||||
</h1>
|
||||
<p className="mx-auto mt-5 max-w-2xl text-base leading-8 text-cream/70 sm:text-lg">
|
||||
{BRAND.descFa}
|
||||
</p>
|
||||
<div className="mt-9 flex justify-center">
|
||||
<DownloadButtons variant="hero" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-12 grid max-w-3xl gap-3 sm:grid-cols-3">
|
||||
{STATS.map((s) => (
|
||||
<div key={s.label} className="glass rounded-2xl px-4 py-4">
|
||||
<s.icon className="mx-auto text-teal" size={22} />
|
||||
<div className="mt-2 text-sm font-bold text-cream">{s.label}</div>
|
||||
<div className="text-xs text-cream/55">{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section id="features" className="mx-auto max-w-6xl px-4 py-16">
|
||||
<h2 className="text-center text-3xl font-black sm:text-4xl">
|
||||
چرا <span className="gold-text">برگ وسط</span>؟
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-center text-cream/60">
|
||||
همهٔ چیزی که یک بازی حکم بینقص لازم دارد، در یک اپ.
|
||||
</p>
|
||||
<div className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURES.map((f) => (
|
||||
<div key={f.title} className="glass rounded-2xl p-6 transition hover:border-gold/40">
|
||||
<f.icon className="text-gold" size={28} />
|
||||
<h3 className="mt-4 text-lg font-bold text-cream">{f.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-cream/65">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How to play */}
|
||||
<section className="mx-auto max-w-6xl px-4 py-16">
|
||||
<div className="glass rounded-3xl p-8 sm:p-12">
|
||||
<h2 className="text-center text-3xl font-black sm:text-4xl">در ۳ قدم شروع کن</h2>
|
||||
<div className="mt-10 grid gap-6 sm:grid-cols-3">
|
||||
{STEPS.map((s) => (
|
||||
<div key={s.n} className="text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full btn-gold text-2xl font-black">
|
||||
{s.n}
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-bold text-cream">{s.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-cream/65">{s.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="mx-auto max-w-4xl px-4 py-16 text-center">
|
||||
<h2 className="text-3xl font-black sm:text-4xl">
|
||||
همین حالا <span className="gold-text">حکم</span> را شروع کن
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-lg text-cream/65">
|
||||
رایگان روی مرورگر بازی کن یا اپ را روی گوشیات نصب کن.
|
||||
</p>
|
||||
<div className="mt-8 flex justify-center">
|
||||
<DownloadButtons variant="full" />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageShell } from "@/components/PageShell";
|
||||
import { BRAND } from "@/lib/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "حریم خصوصی",
|
||||
description: "سیاست حریم خصوصی برگ وسط: چه دادههایی جمعآوری میشود و چگونه از آن محافظت میکنیم.",
|
||||
alternates: { canonical: "/privacy" },
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<PageShell title="سیاست حریم خصوصی" subtitle="آخرین بهروزرسانی: ۱۴۰۴">
|
||||
<p>
|
||||
برگ وسط ({BRAND.nameEn}) به حریم خصوصی شما احترام میگذارد. این سند توضیح میدهد که چه اطلاعاتی جمعآوری
|
||||
میشود و چگونه استفاده میشود.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۱. اطلاعاتی که جمعآوری میکنیم</h2>
|
||||
<ul className="list-disc space-y-2 pr-5">
|
||||
<li>شمارهٔ موبایل برای ورود و احراز هویت.</li>
|
||||
<li>اطلاعات نمایه که خودتان وارد میکنید (نام نمایشی، آواتار، تنظیمات).</li>
|
||||
<li>دادههای بازی مانند امتیاز، رتبه، سکه و دستاوردها.</li>
|
||||
<li>اطلاعات فنی پایه برای پایداری سرویس (مانند نوع دستگاه و خطاها).</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۲. استفاده از اطلاعات</h2>
|
||||
<p>
|
||||
از اطلاعات فقط برای ارائهٔ سرویس بازی، ذخیرهٔ پیشرفت شما، جلوگیری از تقلب و بهبود تجربهٔ کاربری استفاده
|
||||
میکنیم. اطلاعات شما را به اشخاص ثالث نمیفروشیم.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۳. پرداختها</h2>
|
||||
<p>
|
||||
خریدهای درونبرنامهای از طریق درگاههای معتبر (زرینپال) و فروشگاهها (کافهبازار، مایکت) انجام میشود و
|
||||
اطلاعات کارت بانکی شما نزد ما ذخیره نمیشود.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۴. امنیت</h2>
|
||||
<p>برای محافظت از دادهها از رمزنگاری و سرورهای امن استفاده میکنیم.</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۵. حذف حساب</h2>
|
||||
<p>
|
||||
برای حذف حساب و دادههای مرتبط، از طریق ایمیل {BRAND.email} با ما تماس بگیرید.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۶. تماس</h2>
|
||||
<p>برای هر پرسشی دربارهٔ حریم خصوصی به {BRAND.email} ایمیل بزنید.</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/site";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/", disallow: "/admin" },
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
host: SITE_URL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/site";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const routes = ["", "/download", "/faq", "/support", "/privacy", "/terms"];
|
||||
return routes.map((r) => ({
|
||||
url: `${SITE_URL}${r}`,
|
||||
changeFrequency: r === "" ? "weekly" : "monthly",
|
||||
priority: r === "" ? 1 : 0.7,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageShell } from "@/components/PageShell";
|
||||
import { SupportContact } from "@/components/SupportContact";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "پشتیبانی",
|
||||
description: "از تیم پشتیبانی برگ وسط کمک بگیرید — ایمیل، تلگرام و اینستاگرام.",
|
||||
alternates: { canonical: "/support" },
|
||||
};
|
||||
|
||||
export default function SupportPage() {
|
||||
return (
|
||||
<PageShell title="پشتیبانی" subtitle="سوالی داری یا مشکلی پیش آمده؟ ما اینجاییم.">
|
||||
<SupportContact />
|
||||
<p className="text-sm text-cream/55">
|
||||
پیش از تماس، نگاهی به{" "}
|
||||
<a href="/faq" className="text-gold-soft underline">
|
||||
سوالهای متداول
|
||||
</a>{" "}
|
||||
بینداز — شاید جوابت همانجا باشد.
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageShell } from "@/components/PageShell";
|
||||
import { BRAND } from "@/lib/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "قوانین و مقررات",
|
||||
description: "قوانین و شرایط استفاده از بازی حکم آنلاین برگ وسط.",
|
||||
alternates: { canonical: "/terms" },
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<PageShell title="قوانین و مقررات" subtitle="آخرین بهروزرسانی: ۱۴۰۴">
|
||||
<p>با استفاده از برگ وسط، شرایط زیر را میپذیرید.</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۱. استفادهٔ مجاز</h2>
|
||||
<p>
|
||||
استفاده از تقلب، رباتهای غیرمجاز، سوءاستفاده از باگها یا هرگونه رفتار مخل بازی ممنوع است و میتواند به
|
||||
مسدودسازی حساب منجر شود.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۲. حساب کاربری</h2>
|
||||
<p>مسئولیت حفظ امنیت حساب و فعالیتهای انجامشده با آن بر عهدهٔ شماست.</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۳. سکه و خریدها</h2>
|
||||
<p>
|
||||
سکهها و آیتمهای مجازی ارزش واقعی پولی ندارند و قابل بازگشت به وجه نقد نیستند. خریدهای درونبرنامهای پس از
|
||||
انجام، طبق قوانین فروشگاه مربوطه قابل بازگشتاند.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۴. رفتار سر میز</h2>
|
||||
<p>توهین، آزار و محتوای نامناسب در چت ممنوع است.</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۵. تغییرات</h2>
|
||||
<p>ممکن است این قوانین بهمرور بهروزرسانی شوند. ادامهٔ استفاده بهمنزلهٔ پذیرش نسخهٔ جدید است.</p>
|
||||
|
||||
<h2 className="text-xl font-bold text-cream">۶. تماس</h2>
|
||||
<p>برای سوالها به {BRAND.email} ایمیل بزنید.</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Play, Smartphone, Download } from "lucide-react";
|
||||
import { fetchLinks, FALLBACK_LINKS, type SiteLinks } from "@/lib/links";
|
||||
import { APP_URL } from "@/lib/site";
|
||||
|
||||
function BazaarIcon() {
|
||||
return <span className="text-lg">🛒</span>;
|
||||
}
|
||||
function MyketIcon() {
|
||||
return <span className="text-lg">🟢</span>;
|
||||
}
|
||||
|
||||
export function DownloadButtons({ variant = "hero" }: { variant?: "hero" | "full" }) {
|
||||
const [links, setLinks] = useState<SiteLinks>(FALLBACK_LINKS);
|
||||
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
fetchLinks().then((l) => on && setLinks(l));
|
||||
return () => {
|
||||
on = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const webUrl = links.webPlayUrl || APP_URL;
|
||||
|
||||
return (
|
||||
<div className={variant === "hero" ? "flex flex-wrap gap-3" : "grid gap-3 sm:grid-cols-2"}>
|
||||
{/* Always available: play in browser */}
|
||||
<a href={webUrl} className="flex items-center justify-center gap-2 rounded-2xl btn-gold px-6 py-3.5 text-base">
|
||||
<Play size={18} /> بازی در مرورگر (رایگان)
|
||||
</a>
|
||||
|
||||
{links.bazaarEnabled && links.bazaarUrl && (
|
||||
<a
|
||||
href={links.bazaarUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
|
||||
>
|
||||
<BazaarIcon /> کافهبازار
|
||||
</a>
|
||||
)}
|
||||
|
||||
{links.myketEnabled && links.myketUrl && (
|
||||
<a
|
||||
href={links.myketUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
|
||||
>
|
||||
<MyketIcon /> مایکت
|
||||
</a>
|
||||
)}
|
||||
|
||||
{links.iosPwaEnabled && (
|
||||
<a
|
||||
href="/download#ios"
|
||||
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
|
||||
>
|
||||
<span className="text-lg">🍏</span> نصب روی آیفون (iOS)
|
||||
</a>
|
||||
)}
|
||||
|
||||
{variant === "full" && links.iosPwaEnabled && (
|
||||
<a
|
||||
href="/download#android"
|
||||
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
|
||||
>
|
||||
<Smartphone size={18} /> نصب روی اندروید (PWA)
|
||||
</a>
|
||||
)}
|
||||
|
||||
{links.directApkEnabled && links.directApkUrl && (
|
||||
<a
|
||||
href={links.directApkUrl}
|
||||
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
|
||||
>
|
||||
<Download size={18} /> دانلود مستقیم APK
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from "next/link";
|
||||
import { Logo } from "./Logo";
|
||||
import { BRAND } from "@/lib/site";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-24 border-t border-gold/10 bg-navy-950/60">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-12 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2 md:col-span-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo size={30} />
|
||||
<span className="font-extrabold gold-text">{BRAND.nameFa}</span>
|
||||
</div>
|
||||
<p className="mt-3 max-w-xs text-sm leading-7 text-cream/60">{BRAND.descFa}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 font-bold text-cream">بازی</h4>
|
||||
<ul className="space-y-2 text-sm text-cream/65">
|
||||
<li><Link href="/#features" className="hover:text-cream">ویژگیها</Link></li>
|
||||
<li><Link href="/download" className="hover:text-cream">دانلود و نصب</Link></li>
|
||||
<li><Link href="/faq" className="hover:text-cream">سوالهای متداول</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 font-bold text-cream">قوانین</h4>
|
||||
<ul className="space-y-2 text-sm text-cream/65">
|
||||
<li><Link href="/privacy" className="hover:text-cream">حریم خصوصی</Link></li>
|
||||
<li><Link href="/terms" className="hover:text-cream">قوانین و مقررات</Link></li>
|
||||
<li><Link href="/support" className="hover:text-cream">پشتیبانی</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 font-bold text-cream">ارتباط</h4>
|
||||
<ul className="space-y-2 text-sm text-cream/65">
|
||||
<li><a href={`mailto:${BRAND.email}`} className="hover:text-cream">{BRAND.email}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gold/10 py-5 text-center text-xs text-cream/45">
|
||||
© {new Date().getFullYear()} {BRAND.nameFa} — همهٔ حقوق محفوظ است.
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function Logo({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 100 100" fill="none" aria-hidden>
|
||||
<rect x="6" y="6" width="88" height="88" rx="22" fill="url(#lg)" stroke="#d4af37" strokeWidth="3" />
|
||||
<text
|
||||
x="50"
|
||||
y="62"
|
||||
textAnchor="middle"
|
||||
fontSize="46"
|
||||
fontWeight="900"
|
||||
fill="#d4af37"
|
||||
fontFamily="Vazirmatn Variable, sans-serif"
|
||||
>
|
||||
و
|
||||
</text>
|
||||
<defs>
|
||||
<linearGradient id="lg" x1="0" y1="0" x2="100" y2="100" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#111a33" />
|
||||
<stop offset="1" stopColor="#070b18" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu, X, Play } from "lucide-react";
|
||||
import { Logo } from "./Logo";
|
||||
import { APP_URL, BRAND } from "@/lib/site";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/#features", label: "ویژگیها" },
|
||||
{ href: "/download", label: "دانلود و نصب" },
|
||||
{ href: "/faq", label: "سوالها" },
|
||||
{ href: "/support", label: "پشتیبانی" },
|
||||
];
|
||||
|
||||
export function Nav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<header className="sticky top-0 z-50 glass">
|
||||
<nav className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Logo size={34} />
|
||||
<span className="text-lg font-extrabold gold-text">{BRAND.nameFa}</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
{NAV.map((n) => (
|
||||
<Link key={n.href} href={n.href} className="text-sm text-cream/80 hover:text-cream">
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={APP_URL}
|
||||
className="hidden items-center gap-1.5 rounded-xl btn-gold px-4 py-2 text-sm sm:flex"
|
||||
>
|
||||
<Play size={16} /> بازی در مرورگر
|
||||
</a>
|
||||
<button
|
||||
className="rounded-lg p-2 text-cream md:hidden"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="منو"
|
||||
>
|
||||
{open ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-gold/10 px-4 pb-4 md:hidden">
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
{NAV.map((n) => (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg px-3 py-2 text-cream/85 hover:bg-navy-800"
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
<a href={APP_URL} className="mt-2 flex items-center justify-center gap-1.5 rounded-xl btn-gold px-4 py-2.5">
|
||||
<Play size={16} /> بازی در مرورگر
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function PageShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-4 py-14">
|
||||
<h1 className="text-3xl font-black sm:text-4xl gold-text">{title}</h1>
|
||||
{subtitle && <p className="mt-3 text-cream/65">{subtitle}</p>}
|
||||
<div className="mt-8 space-y-5 leading-8 text-cream/80">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Prose({ children }: { children: React.ReactNode }) {
|
||||
return <div className="glass rounded-2xl p-6 leading-8 text-cream/80">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Mail, Phone, Send } from "lucide-react";
|
||||
import { fetchLinks, FALLBACK_LINKS, type SiteLinks } from "@/lib/links";
|
||||
|
||||
export function SupportContact() {
|
||||
const [links, setLinks] = useState<SiteLinks>(FALLBACK_LINKS);
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
fetchLinks().then((l) => on && setLinks(l));
|
||||
return () => {
|
||||
on = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const email = links.supportEmail || FALLBACK_LINKS.supportEmail;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<a href={`mailto:${email}`} className="glass flex items-center gap-3 rounded-2xl p-5 hover:border-gold/40">
|
||||
<Mail className="text-gold" /> <span>{email}</span>
|
||||
</a>
|
||||
{links.supportPhone && (
|
||||
<a href={`tel:${links.supportPhone}`} className="glass flex items-center gap-3 rounded-2xl p-5 hover:border-gold/40">
|
||||
<Phone className="text-gold" /> <span dir="ltr">{links.supportPhone}</span>
|
||||
</a>
|
||||
)}
|
||||
{links.telegram && (
|
||||
<a href={links.telegram} target="_blank" rel="noopener" className="glass flex items-center gap-3 rounded-2xl p-5 hover:border-gold/40">
|
||||
<Send className="text-teal" /> <span>تلگرام پشتیبانی</span>
|
||||
</a>
|
||||
)}
|
||||
{links.instagram && (
|
||||
<a href={links.instagram} target="_blank" rel="noopener" className="glass flex items-center gap-3 rounded-2xl p-5 hover:border-gold/40">
|
||||
<span className="text-lg">📷</span> <span>اینستاگرام</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Lint is skipped during `next build` (see next.config.ts). This minimal flat
|
||||
// config keeps `eslint` runnable without extra deps.
|
||||
export default [];
|
||||
@@ -0,0 +1,46 @@
|
||||
import { API_URL, APP_URL } from "./site";
|
||||
|
||||
export interface SiteLinks {
|
||||
bazaarUrl: string;
|
||||
bazaarEnabled: boolean;
|
||||
myketUrl: string;
|
||||
myketEnabled: boolean;
|
||||
directApkUrl: string;
|
||||
directApkEnabled: boolean;
|
||||
webPlayUrl: string;
|
||||
iosPwaEnabled: boolean;
|
||||
instagram: string;
|
||||
telegram: string;
|
||||
supportEmail: string;
|
||||
supportPhone: string;
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
// Safe defaults used until the API responds (or if it's unreachable).
|
||||
export const FALLBACK_LINKS: SiteLinks = {
|
||||
bazaarUrl: "",
|
||||
bazaarEnabled: false,
|
||||
myketUrl: "",
|
||||
myketEnabled: false,
|
||||
directApkUrl: "",
|
||||
directApkEnabled: false,
|
||||
webPlayUrl: APP_URL,
|
||||
iosPwaEnabled: true,
|
||||
instagram: "",
|
||||
telegram: "",
|
||||
supportEmail: "support@bargevasat.ir",
|
||||
supportPhone: "",
|
||||
appVersion: "",
|
||||
};
|
||||
|
||||
/** Fetch admin-editable links at runtime (client-side). Falls back gracefully. */
|
||||
export async function fetchLinks(): Promise<SiteLinks> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/site/links`, { cache: "no-store" });
|
||||
if (!res.ok) return FALLBACK_LINKS;
|
||||
const data = (await res.json()) as Partial<SiteLinks>;
|
||||
return { ...FALLBACK_LINKS, ...data };
|
||||
} catch {
|
||||
return FALLBACK_LINKS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Build-time public config (baked into the static bundle).
|
||||
export const API_URL = (process.env.NEXT_PUBLIC_API_URL || "https://api.bargevasat.ir").replace(/\/$/, "");
|
||||
export const APP_URL = (process.env.NEXT_PUBLIC_APP_URL || "https://app.bargevasat.ir").replace(/\/$/, "");
|
||||
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://bargevasat.ir").replace(/\/$/, "");
|
||||
|
||||
export const BRAND = {
|
||||
nameFa: "برگ وسط",
|
||||
nameEn: "Barg-e Vasat",
|
||||
taglineFa: "بازی حکمِ آنلاین، رایگان و حرفهای",
|
||||
descFa:
|
||||
"برگ وسط، بازی حکم ایرانی بهصورت آنلاین: با دوستان یا هوش مصنوعی بازی کن، در لیگها بالا برو، سکه و دستاورد جمع کن. روی اندروید، iOS و مرورگر.",
|
||||
email: "support@bargevasat.ir",
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Static export — the marketing site is fully static (SEO-friendly pre-rendered
|
||||
// HTML) and served by nginx. Store links are fetched client-side at runtime from
|
||||
// the API (so the admin can change them without a rebuild).
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
images: { unoptimized: true },
|
||||
trailingSlash: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Static export with trailingSlash: serve dir/index.html, or the .html twin.
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html /index.html;
|
||||
}
|
||||
|
||||
# Never cache HTML — new deploys (new chunk hashes) are picked up immediately.
|
||||
location ~* \.html$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Long-cache immutable, content-hashed build assets.
|
||||
location /_next/static/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Generated
+6693
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "barg-e-vasat-site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3030",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/vazirmatn": "^5.2.8",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
||||
<rect x="6" y="6" width="88" height="88" rx="22" fill="#0b1226" stroke="#d4af37" stroke-width="4"/>
|
||||
<text x="50" y="64" text-anchor="middle" font-size="48" font-weight="900" fill="#d4af37" font-family="Tahoma, sans-serif">و</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 331 B |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -30,5 +30,5 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "site"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user