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
|
out
|
||||||
android
|
android
|
||||||
server
|
server
|
||||||
|
site
|
||||||
.git
|
.git
|
||||||
.gitea
|
.gitea
|
||||||
*.md
|
*.md
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
if [ -n "$CURRENT" ]; then docker tag "$CURRENT" hokm-server:rollback && echo "rollback tag = $CURRENT"; fi
|
if [ -n "$CURRENT" ]; then docker tag "$CURRENT" hokm-server:rollback && echo "rollback tag = $CURRENT"; fi
|
||||||
|
|
||||||
- name: Build images
|
- name: Build images
|
||||||
run: docker compose build --parallel server web
|
run: docker compose build --parallel server web site
|
||||||
env:
|
env:
|
||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
@@ -182,6 +182,22 @@ jobs:
|
|||||||
sleep 5
|
sleep 5
|
||||||
done
|
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
|
- name: Prune dangling images
|
||||||
if: success()
|
if: success()
|
||||||
run: docker image prune -f
|
run: docker image prune -f
|
||||||
|
|||||||
@@ -56,6 +56,24 @@ IAB_MYKET_ACCESS_TOKEN=
|
|||||||
# store creds). NEVER true in production.
|
# store creds). NEVER true in production.
|
||||||
IAB_ALLOW_UNVERIFIED=false
|
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,
|
# PRODUCTION (bargevasat.ir) — use these values instead of the local ones above,
|
||||||
# and deploy with the Caddy overlay (see PRODUCTION.md). DNS: bargevasat.ir,
|
# 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__BazaarRefreshToken: ${IAB_BAZAAR_REFRESH_TOKEN:-}
|
||||||
Iab__MyketAccessToken: ${IAB_MYKET_ACCESS_TOKEN:-}
|
Iab__MyketAccessToken: ${IAB_MYKET_ACCESS_TOKEN:-}
|
||||||
Iab__AllowUnverified: ${IAB_ALLOW_UNVERIFIED:-false}
|
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:
|
ports:
|
||||||
- "${API_PORT:-1505}:5005"
|
- "${API_PORT:-1505}:5005"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -98,5 +104,29 @@ services:
|
|||||||
retries: 6
|
retries: 6
|
||||||
start_period: 10s
|
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:
|
volumes:
|
||||||
hokm_db_data:
|
hokm_db_data:
|
||||||
|
hokm_data:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Hokm.Server.Game;
|
|||||||
using Hokm.Server.Hubs;
|
using Hokm.Server.Hubs;
|
||||||
using Hokm.Server.Payments;
|
using Hokm.Server.Payments;
|
||||||
using Hokm.Server.Profiles;
|
using Hokm.Server.Profiles;
|
||||||
|
using Hokm.Server.Site;
|
||||||
using Hokm.Server.Social;
|
using Hokm.Server.Social;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -51,6 +52,11 @@ var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOp
|
|||||||
builder.Services.AddSingleton(iab);
|
builder.Services.AddSingleton(iab);
|
||||||
builder.Services.AddSingleton<IabService>();
|
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) ---
|
// --- SignalR (camelCase to match the TS client) ---
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddSignalR()
|
.AddSignalR()
|
||||||
@@ -126,6 +132,16 @@ app.UseAuthorization();
|
|||||||
app.MapGet("/", () => Results.Json(new { service = "Barg-e Vasat SignalR server", status = "ok" }));
|
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 }));
|
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. ---
|
// --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. ---
|
||||||
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
|
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
|
||||||
Results.Json(new { devCode = "1234", phone = req.Phone }));
|
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",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "site"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user