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:
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user