feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support
Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
This commit is contained in:
92
src/modules/settings/admin-settings-data-context.tsx
Normal file
92
src/modules/settings/admin-settings-data-context.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAdminSettings } from "@/api/admin-settings";
|
||||
|
||||
/** 系统设置页一次拉取的分组(避免各卡片重复 GET) */
|
||||
export const SYSTEM_SETTINGS_GROUPS = ["draw", "settlement", "frontend", "wallet"] as const;
|
||||
|
||||
function mergeItemsToKv(
|
||||
items: { key: string; value: unknown }[],
|
||||
into: Record<string, unknown>,
|
||||
): void {
|
||||
for (const item of items) {
|
||||
into[item.key] = item.value;
|
||||
}
|
||||
}
|
||||
|
||||
type AdminSettingsDataContextValue = {
|
||||
kv: Record<string, unknown> | null;
|
||||
loading: boolean;
|
||||
reload: () => Promise<void>;
|
||||
patchKv: (updates: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
const AdminSettingsDataContext = createContext<AdminSettingsDataContextValue | null>(null);
|
||||
|
||||
export function AdminSettingsDataProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation(["config"]);
|
||||
const [kv, setKv] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
SYSTEM_SETTINGS_GROUPS.map((group) => getAdminSettings(group)),
|
||||
);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const res of responses) {
|
||||
mergeItemsToKv(res.items, merged);
|
||||
}
|
||||
setKv(merged);
|
||||
} catch {
|
||||
toast.error(tRef.current("system.loadFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const patchKv = useCallback((updates: Record<string, unknown>) => {
|
||||
setKv((prev) => (prev === null ? { ...updates } : { ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ kv, loading, reload, patchKv }),
|
||||
[kv, loading, reload, patchKv],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminSettingsDataContext.Provider value={value}>{children}</AdminSettingsDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAdminSettingsData(): AdminSettingsDataContextValue {
|
||||
const ctx = useContext(AdminSettingsDataContext);
|
||||
if (ctx === null) {
|
||||
throw new Error("useAdminSettingsData must be used within AdminSettingsDataProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalAdminSettingsData(): AdminSettingsDataContextValue | null {
|
||||
return useContext(AdminSettingsDataContext);
|
||||
}
|
||||
36
src/modules/settings/components/settings-section-actions.tsx
Normal file
36
src/modules/settings/components/settings-section-actions.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SettingsSectionActions({
|
||||
dirty,
|
||||
loading,
|
||||
saving,
|
||||
onSave,
|
||||
onDiscard,
|
||||
saveLabel,
|
||||
savingLabel,
|
||||
discardLabel,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
saveLabel: string;
|
||||
savingLabel: string;
|
||||
discardLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
|
||||
{saving ? savingLabel : saveLabel}
|
||||
</Button>
|
||||
{dirty ? (
|
||||
<Button type="button" variant="outline" onClick={onDiscard} disabled={saving}>
|
||||
{discardLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -68,6 +70,7 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
|
||||
|
||||
export function CurrencySettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers"]);
|
||||
const tRef = useTranslationRef(["config", "adminUsers"]);
|
||||
const exportLabels = useExportLabels("currencies");
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
|
||||
@@ -96,18 +99,16 @@ export function CurrencySettingsPanel() {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError
|
||||
? error.message
|
||||
: t("currencies.loadFailed", { ns: "config" }),
|
||||
: tRef.current("currencies.loadFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canManage, t]);
|
||||
}, [canManage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [canManage]);
|
||||
|
||||
function openCreate(): void {
|
||||
setMode("create");
|
||||
|
||||
99
src/modules/settings/hooks/use-settings-section.ts
Normal file
99
src/modules/settings/hooks/use-settings-section.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { updateAdminSettingsBatch, type AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { setCachedApplyRebateToPayoutSetting } from "@/lib/admin-settlement-settings-cache";
|
||||
import { useAdminSettingsData } from "@/modules/settings/admin-settings-data-context";
|
||||
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function useSettingsSection<TDraft>(options: {
|
||||
initialDraft: TDraft;
|
||||
fromKv: (kv: Record<string, unknown>) => TDraft;
|
||||
buildDirtyItems: (draft: TDraft, saved: TDraft) => AdminSettingBatchItem[];
|
||||
saveSuccessKey: string;
|
||||
saveFailedKey: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["config"]);
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const { kv, loading, patchKv } = useAdminSettingsData();
|
||||
const [draft, setDraft] = useState(options.initialDraft);
|
||||
const [saved, setSaved] = useState(options.initialDraft);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const hydratedRef = useRef(false);
|
||||
|
||||
const { fromKv, buildDirtyItems, saveSuccessKey, saveFailedKey } = options;
|
||||
|
||||
const dirty = useMemo(
|
||||
() => buildDirtyItems(draft, saved).length > 0,
|
||||
[draft, saved, buildDirtyItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (kv === null) {
|
||||
return;
|
||||
}
|
||||
const next = fromKv(kv);
|
||||
setDraft(next);
|
||||
setSaved(next);
|
||||
hydratedRef.current = true;
|
||||
}, [kv, fromKv]);
|
||||
|
||||
const updateField = <K extends keyof TDraft>(field: K, value: TDraft[K]) => {
|
||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setDraft(saved);
|
||||
};
|
||||
|
||||
const save = async (): Promise<boolean> => {
|
||||
const items = buildDirtyItems(draft, saved);
|
||||
if (items.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAdminSettingsBatch(items);
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const item of items) {
|
||||
updates[item.key] = item.value;
|
||||
if (item.key === SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT) {
|
||||
setCachedApplyRebateToPayoutSetting(Boolean(item.value));
|
||||
}
|
||||
}
|
||||
patchKv(updates);
|
||||
setSaved(draft);
|
||||
toast.success(tRef.current(saveSuccessKey, { ns: "config" }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError
|
||||
? error.message
|
||||
: tRef.current(saveFailedKey, { ns: "config" }),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sectionLoading = loading || (kv !== null && !hydratedRef.current);
|
||||
|
||||
return {
|
||||
draft,
|
||||
saved,
|
||||
loading: sectionLoading,
|
||||
saving,
|
||||
dirty,
|
||||
updateField,
|
||||
discard,
|
||||
save,
|
||||
};
|
||||
}
|
||||
148
src/modules/settings/panels/currency-format-settings-panel.tsx
Normal file
148
src/modules/settings/panels/currency-format-settings-panel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface CurrencyFormatDraft {
|
||||
currencyDisplayDecimals: string;
|
||||
currencyDecimalSeparator: string;
|
||||
currencyThousandsSeparator: string;
|
||||
}
|
||||
|
||||
const INITIAL: CurrencyFormatDraft = {
|
||||
currencyDisplayDecimals: "2",
|
||||
currencyDecimalSeparator: ".",
|
||||
currencyThousandsSeparator: ",",
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): CurrencyFormatDraft {
|
||||
return {
|
||||
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
|
||||
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
|
||||
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirtyItems(draft: CurrencyFormatDraft, saved: CurrencyFormatDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
if (draft.currencyDisplayDecimals !== saved.currencyDisplayDecimals) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
|
||||
value: Math.max(
|
||||
0,
|
||||
Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (draft.currencyDecimalSeparator !== saved.currencyDecimalSeparator) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
|
||||
value: (draft.currencyDecimalSeparator || ".").slice(0, 1),
|
||||
});
|
||||
}
|
||||
if (draft.currencyThousandsSeparator !== saved.currencyThousandsSeparator) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
|
||||
value: (draft.currencyThousandsSeparator || ",").slice(0, 1),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function CurrencyFormatSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
initialDraft: INITIAL,
|
||||
fromKv,
|
||||
buildDirtyItems: buildItems,
|
||||
saveSuccessKey: "system.saveCurrencyFormatSuccess",
|
||||
saveFailedKey: "system.saveFailed",
|
||||
});
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("system.sections.currencyFormat", { ns: "config" })}
|
||||
description={t("system.sections.currencyFormatDescription", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-display-decimals"
|
||||
type="number"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={draft.currencyDisplayDecimals}
|
||||
onChange={(e) => updateField("currencyDisplayDecimals", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-decimal-separator"
|
||||
value={draft.currencyDecimalSeparator}
|
||||
onChange={(e) => updateField("currencyDecimalSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-thousands-separator"
|
||||
value={draft.currencyThousandsSeparator}
|
||||
onChange={(e) => updateField("currencyThousandsSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSectionActions
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveCurrencyFormatTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveCurrencyFormatDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
||||
discardLabel={t("system.discard", { ns: "config" })}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
234
src/modules/settings/panels/draw-settings-panel.tsx
Normal file
234
src/modules/settings/panels/draw-settings-panel.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface DrawDraft {
|
||||
defaultCurrency: string;
|
||||
drawIntervalMinutes: string;
|
||||
drawBettingWindowSeconds: string;
|
||||
drawCloseBeforeDrawSeconds: string;
|
||||
drawBufferDrawsAhead: string;
|
||||
requireManualReview: boolean;
|
||||
cooldownMinutes: string;
|
||||
}
|
||||
|
||||
const INITIAL: DrawDraft = {
|
||||
defaultCurrency: "NPR",
|
||||
drawIntervalMinutes: "5",
|
||||
drawBettingWindowSeconds: "270",
|
||||
drawCloseBeforeDrawSeconds: "30",
|
||||
drawBufferDrawsAhead: "8",
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): DrawDraft {
|
||||
return {
|
||||
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
|
||||
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
|
||||
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
|
||||
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
|
||||
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
|
||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
const push = (key: string, value: unknown, changed: boolean) => {
|
||||
if (changed) {
|
||||
items.push({ key, value });
|
||||
}
|
||||
};
|
||||
|
||||
push(
|
||||
DRAW_KEYS.DEFAULT_CURRENCY,
|
||||
draft.defaultCurrency.trim().toUpperCase() || "NPR",
|
||||
draft.defaultCurrency !== saved.defaultCurrency,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
|
||||
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
|
||||
draft.drawIntervalMinutes !== saved.drawIntervalMinutes,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
|
||||
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
|
||||
draft.drawBettingWindowSeconds !== saved.drawBettingWindowSeconds,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
|
||||
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
|
||||
draft.drawCloseBeforeDrawSeconds !== saved.drawCloseBeforeDrawSeconds,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
|
||||
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
|
||||
draft.drawBufferDrawsAhead !== saved.drawBufferDrawsAhead,
|
||||
);
|
||||
push(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview, draft.requireManualReview !== saved.requireManualReview);
|
||||
push(
|
||||
DRAW_KEYS.COOLDOWN_MINUTES,
|
||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||
draft.cooldownMinutes !== saved.cooldownMinutes,
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function DrawSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
initialDraft: INITIAL,
|
||||
fromKv,
|
||||
buildDirtyItems: buildItems,
|
||||
saveSuccessKey: "system.saveDrawSuccess",
|
||||
saveFailedKey: "system.saveFailed",
|
||||
});
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("system.sections.draw", { ns: "config" })}
|
||||
description={t("system.sections.drawDescription", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.requireManualReview}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("requireManualReview", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="default-currency" className="text-sm font-medium">
|
||||
{t("system.fields.defaultCurrency", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="default-currency"
|
||||
value={draft.defaultCurrency}
|
||||
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
|
||||
disabled={loading || saving}
|
||||
maxLength={16}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-interval-minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
step="1"
|
||||
value={draft.drawIntervalMinutes}
|
||||
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-betting-window-seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
step="1"
|
||||
value={draft.drawBettingWindowSeconds}
|
||||
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-close-before-seconds"
|
||||
type="number"
|
||||
min="5"
|
||||
step="1"
|
||||
value={draft.drawCloseBeforeDrawSeconds}
|
||||
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
|
||||
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-buffer-ahead"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={draft.drawBufferDrawsAhead}
|
||||
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="cooldown-minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSectionActions
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveDrawTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveDrawDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
||||
discardLabel={t("system.discard", { ns: "config" })}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
src/modules/settings/panels/frontend-settings-panel.tsx
Normal file
138
src/modules/settings/panels/frontend-settings-panel.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { FRONTEND_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface FrontendDraft {
|
||||
playRulesHtmlZh: string;
|
||||
playRulesHtmlEn: string;
|
||||
playRulesHtmlNe: string;
|
||||
}
|
||||
|
||||
const INITIAL: FrontendDraft = {
|
||||
playRulesHtmlZh: "",
|
||||
playRulesHtmlEn: "",
|
||||
playRulesHtmlNe: "",
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): FrontendDraft {
|
||||
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
|
||||
return {
|
||||
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
|
||||
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
|
||||
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirtyItems(draft: FrontendDraft, saved: FrontendDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
if (draft.playRulesHtmlZh !== saved.playRulesHtmlZh) {
|
||||
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_ZH, value: draft.playRulesHtmlZh });
|
||||
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML, value: draft.playRulesHtmlZh });
|
||||
}
|
||||
if (draft.playRulesHtmlEn !== saved.playRulesHtmlEn) {
|
||||
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_EN, value: draft.playRulesHtmlEn });
|
||||
}
|
||||
if (draft.playRulesHtmlNe !== saved.playRulesHtmlNe) {
|
||||
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_NE, value: draft.playRulesHtmlNe });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function FrontendSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
initialDraft: INITIAL,
|
||||
fromKv,
|
||||
buildDirtyItems: buildItems,
|
||||
saveSuccessKey: "system.saveFrontendSuccess",
|
||||
saveFailedKey: "system.saveFailed",
|
||||
});
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("system.fields.playRulesHtml", { ns: "config" })}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
|
||||
</p>
|
||||
<Tabs defaultValue="zh" className="w-full">
|
||||
<TabsList className="w-full max-w-md">
|
||||
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="zh" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-zh"
|
||||
value={draft.playRulesHtmlZh}
|
||||
onChange={(e) => updateField("playRulesHtmlZh", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="en" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-en"
|
||||
value={draft.playRulesHtmlEn}
|
||||
onChange={(e) => updateField("playRulesHtmlEn", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="ne" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-ne"
|
||||
value={draft.playRulesHtmlNe}
|
||||
onChange={(e) => updateField("playRulesHtmlNe", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SettingsSectionActions
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
||||
discardLabel={t("system.discard", { ns: "config" })}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
149
src/modules/settings/panels/settlement-settings-panel.tsx
Normal file
149
src/modules/settings/panels/settlement-settings-panel.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface SettlementDraft {
|
||||
autoSettlement: boolean;
|
||||
autoApprove: boolean;
|
||||
autoPayout: boolean;
|
||||
applyRebateToPayout: boolean;
|
||||
}
|
||||
|
||||
const INITIAL: SettlementDraft = {
|
||||
autoSettlement: true,
|
||||
autoApprove: true,
|
||||
autoPayout: true,
|
||||
applyRebateToPayout: false,
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): SettlementDraft {
|
||||
return {
|
||||
autoSettlement: Boolean(kv[SETTLEMENT_KEYS.AUTO_SETTLEMENT] ?? true),
|
||||
autoApprove: Boolean(kv[SETTLEMENT_KEYS.AUTO_APPROVE] ?? true),
|
||||
autoPayout: Boolean(kv[SETTLEMENT_KEYS.AUTO_PAYOUT] ?? true),
|
||||
applyRebateToPayout: Boolean(kv[SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirtyItems(draft: SettlementDraft, saved: SettlementDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
if (draft.autoSettlement !== saved.autoSettlement) {
|
||||
items.push({ key: SETTLEMENT_KEYS.AUTO_SETTLEMENT, value: draft.autoSettlement });
|
||||
}
|
||||
if (draft.autoApprove !== saved.autoApprove) {
|
||||
items.push({ key: SETTLEMENT_KEYS.AUTO_APPROVE, value: draft.autoApprove });
|
||||
}
|
||||
if (draft.autoPayout !== saved.autoPayout) {
|
||||
items.push({ key: SETTLEMENT_KEYS.AUTO_PAYOUT, value: draft.autoPayout });
|
||||
}
|
||||
if (draft.applyRebateToPayout !== saved.applyRebateToPayout) {
|
||||
items.push({ key: SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT, value: draft.applyRebateToPayout });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function SettlementSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
initialDraft: INITIAL,
|
||||
fromKv,
|
||||
buildDirtyItems: buildItems,
|
||||
saveSuccessKey: "system.saveSettlementSuccess",
|
||||
saveFailedKey: "system.saveFailed",
|
||||
});
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("system.sections.settlement", { ns: "config" })}
|
||||
description={t("system.sections.settlementDescription", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoSettlement}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("autoSettlement", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoApprove}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("autoApprove", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("autoPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1 pr-4">
|
||||
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.applyRebateToPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSectionActions
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveSettlementTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveSettlementDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
||||
discardLabel={t("system.discard", { ns: "config" })}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/modules/settings/settings-keys.ts
Normal file
38
src/modules/settings/settings-keys.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const DRAW_GROUP = "draw";
|
||||
export const SETTLEMENT_GROUP = "settlement";
|
||||
export const FRONTEND_GROUP = "frontend";
|
||||
export const WALLET_GROUP = "wallet";
|
||||
|
||||
export const DRAW_KEYS = {
|
||||
DEFAULT_CURRENCY: "currency.default_code",
|
||||
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
|
||||
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
|
||||
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
|
||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
|
||||
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
|
||||
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
|
||||
} as const;
|
||||
|
||||
export const SETTLEMENT_KEYS = {
|
||||
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
||||
AUTO_APPROVE: "settlement.auto_approve_on_tick",
|
||||
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
|
||||
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
|
||||
} as const;
|
||||
|
||||
export const FRONTEND_KEYS = {
|
||||
PLAY_RULES_HTML: "frontend.play_rules_html",
|
||||
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
|
||||
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
|
||||
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
|
||||
} as const;
|
||||
|
||||
export const WALLET_KEYS = {
|
||||
IN_MIN: "wallet.transfer_in_min_minor",
|
||||
IN_MAX: "wallet.transfer_in_max_minor",
|
||||
OUT_MIN: "wallet.transfer_out_min_minor",
|
||||
OUT_MAX: "wallet.transfer_out_max_minor",
|
||||
} as const;
|
||||
@@ -1,568 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSetting,
|
||||
} from "@/api/admin-settings";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context";
|
||||
import { CurrencyFormatSettingsPanel } from "@/modules/settings/panels/currency-format-settings-panel";
|
||||
import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
|
||||
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
|
||||
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const DRAW_GROUP = "draw";
|
||||
const SETTLEMENT_GROUP = "settlement";
|
||||
|
||||
const DRAW_KEYS = {
|
||||
DEFAULT_CURRENCY: "currency.default_code",
|
||||
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
|
||||
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
|
||||
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
|
||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
|
||||
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
|
||||
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
|
||||
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
||||
AUTO_APPROVE: "settlement.auto_approve_on_tick",
|
||||
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
|
||||
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
|
||||
} as const;
|
||||
|
||||
const FRONTEND_GROUP = "frontend";
|
||||
const FRONTEND_KEYS = {
|
||||
PLAY_RULES_HTML: "frontend.play_rules_html",
|
||||
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
|
||||
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
|
||||
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
|
||||
} as const;
|
||||
|
||||
interface RuntimeDraft {
|
||||
defaultCurrency: string;
|
||||
drawIntervalMinutes: string;
|
||||
drawBettingWindowSeconds: string;
|
||||
drawCloseBeforeDrawSeconds: string;
|
||||
drawBufferDrawsAhead: string;
|
||||
requireManualReview: boolean;
|
||||
cooldownMinutes: string;
|
||||
currencyDisplayDecimals: string;
|
||||
currencyDecimalSeparator: string;
|
||||
currencyThousandsSeparator: string;
|
||||
autoSettlement: boolean;
|
||||
autoApprove: boolean;
|
||||
autoPayout: boolean;
|
||||
applyRebateToPayout: boolean;
|
||||
playRulesHtmlZh: string;
|
||||
playRulesHtmlEn: string;
|
||||
playRulesHtmlNe: string;
|
||||
}
|
||||
|
||||
const RUNTIME_DRAFT_KEYS = [
|
||||
"defaultCurrency",
|
||||
"drawIntervalMinutes",
|
||||
"drawBettingWindowSeconds",
|
||||
"drawCloseBeforeDrawSeconds",
|
||||
"drawBufferDrawsAhead",
|
||||
"requireManualReview",
|
||||
"cooldownMinutes",
|
||||
"currencyDisplayDecimals",
|
||||
"currencyDecimalSeparator",
|
||||
"currencyThousandsSeparator",
|
||||
"autoSettlement",
|
||||
"autoApprove",
|
||||
"autoPayout",
|
||||
"applyRebateToPayout",
|
||||
] as const satisfies readonly (keyof RuntimeDraft)[];
|
||||
|
||||
const FRONTEND_DRAFT_KEYS = [
|
||||
"playRulesHtmlZh",
|
||||
"playRulesHtmlEn",
|
||||
"playRulesHtmlNe",
|
||||
] as const satisfies readonly (keyof RuntimeDraft)[];
|
||||
|
||||
function isSectionDirty<const K extends keyof RuntimeDraft>(
|
||||
draft: RuntimeDraft,
|
||||
saved: RuntimeDraft,
|
||||
keys: readonly K[],
|
||||
): boolean {
|
||||
return keys.some((key) => draft[key] !== saved[key]);
|
||||
}
|
||||
|
||||
function applyDraftFields<const K extends keyof RuntimeDraft>(
|
||||
base: RuntimeDraft,
|
||||
source: RuntimeDraft,
|
||||
keys: readonly K[],
|
||||
): RuntimeDraft {
|
||||
const next = { ...base };
|
||||
for (const key of keys) {
|
||||
next[key] = source[key];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function SaveActions({
|
||||
dirty,
|
||||
loading,
|
||||
saving,
|
||||
onSave,
|
||||
onDiscard,
|
||||
saveLabel,
|
||||
savingLabel,
|
||||
discardLabel,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
saveLabel: string;
|
||||
savingLabel: string;
|
||||
discardLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
|
||||
{saving ? savingLabel : saveLabel}
|
||||
</Button>
|
||||
{dirty ? (
|
||||
<Button type="button" variant="outline" onClick={onDiscard}>
|
||||
{discardLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemSettingsScreen() {
|
||||
const { t } = useTranslation(["common", "config", "adminUsers"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const [draft, setDraft] = useState<RuntimeDraft>({
|
||||
defaultCurrency: "NPR",
|
||||
drawIntervalMinutes: "5",
|
||||
drawBettingWindowSeconds: "270",
|
||||
drawCloseBeforeDrawSeconds: "30",
|
||||
drawBufferDrawsAhead: "8",
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
currencyDisplayDecimals: "2",
|
||||
currencyDecimalSeparator: ".",
|
||||
currencyThousandsSeparator: ",",
|
||||
autoSettlement: true,
|
||||
autoApprove: true,
|
||||
autoPayout: true,
|
||||
applyRebateToPayout: false,
|
||||
playRulesHtmlZh: "",
|
||||
playRulesHtmlEn: "",
|
||||
playRulesHtmlNe: "",
|
||||
});
|
||||
const [saved, setSaved] = useState<RuntimeDraft>({
|
||||
defaultCurrency: "NPR",
|
||||
drawIntervalMinutes: "5",
|
||||
drawBettingWindowSeconds: "270",
|
||||
drawCloseBeforeDrawSeconds: "30",
|
||||
drawBufferDrawsAhead: "8",
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
currencyDisplayDecimals: "2",
|
||||
currencyDecimalSeparator: ".",
|
||||
currencyThousandsSeparator: ",",
|
||||
autoSettlement: true,
|
||||
autoApprove: true,
|
||||
autoPayout: true,
|
||||
applyRebateToPayout: false,
|
||||
playRulesHtmlZh: "",
|
||||
playRulesHtmlEn: "",
|
||||
playRulesHtmlNe: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingRuntime, setSavingRuntime] = useState(false);
|
||||
const [savingFrontend, setSavingFrontend] = useState(false);
|
||||
|
||||
const runtimeDirty = useMemo(
|
||||
() => isSectionDirty(draft, saved, RUNTIME_DRAFT_KEYS),
|
||||
[draft, saved],
|
||||
);
|
||||
const frontendDirty = useMemo(
|
||||
() => isSectionDirty(draft, saved, FRONTEND_DRAFT_KEYS),
|
||||
[draft, saved],
|
||||
);
|
||||
const anyDirty = runtimeDirty || frontendDirty;
|
||||
const saving = savingRuntime || savingFrontend;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [drawRes, settlementRes, frontendRes] = await Promise.all([
|
||||
getAdminSettings(DRAW_GROUP),
|
||||
getAdminSettings(SETTLEMENT_GROUP),
|
||||
getAdminSettings(FRONTEND_GROUP),
|
||||
]);
|
||||
|
||||
const kv: Record<string, unknown> = {};
|
||||
for (const item of [...drawRes.items, ...settlementRes.items, ...frontendRes.items]) {
|
||||
kv[item.key] = item.value;
|
||||
}
|
||||
|
||||
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
|
||||
const nextDraft: RuntimeDraft = {
|
||||
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
|
||||
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
|
||||
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
|
||||
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
|
||||
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
|
||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
||||
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
|
||||
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
|
||||
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
|
||||
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
|
||||
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
|
||||
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
|
||||
applyRebateToPayout: Boolean(kv[DRAW_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
|
||||
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
|
||||
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
|
||||
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSaved(nextDraft);
|
||||
} catch {
|
||||
toast.error(t("system.loadFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
|
||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const discardSection = <const K extends keyof RuntimeDraft>(keys: readonly K[]) => {
|
||||
setDraft((prev) => applyDraftFields(prev, saved, keys));
|
||||
};
|
||||
|
||||
const handleSaveRuntime = async () => {
|
||||
setSavingRuntime(true);
|
||||
try {
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DEFAULT_CURRENCY,
|
||||
draft.defaultCurrency.trim().toUpperCase() || "NPR",
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
|
||||
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
|
||||
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
|
||||
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
|
||||
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
|
||||
);
|
||||
await updateAdminSetting(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.COOLDOWN_MINUTES,
|
||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
|
||||
Math.max(0, Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2)),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
|
||||
(draft.currencyDecimalSeparator || ".").slice(0, 1),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
|
||||
(draft.currencyThousandsSeparator || ",").slice(0, 1),
|
||||
);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
|
||||
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
|
||||
toast.success(t("system.saveRuntimeSuccess", { ns: "config" }));
|
||||
setSaved((prev) => applyDraftFields(prev, draft, RUNTIME_DRAFT_KEYS));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setSavingRuntime(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFrontend = async () => {
|
||||
setSavingFrontend(true);
|
||||
try {
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
|
||||
toast.success(t("system.saveFrontendSuccess", { ns: "config" }));
|
||||
setSaved((prev) => applyDraftFields(prev, draft, FRONTEND_DRAFT_KEYS));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setSavingFrontend(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveLabel = t("actions.save", { ns: "adminUsers" });
|
||||
const savingLabel = t("saving", { ns: "adminUsers" });
|
||||
const discardLabel = t("system.discard", { ns: "config" });
|
||||
function SystemSettingsContent() {
|
||||
const { t } = useTranslation(["config"]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
{anyDirty ? (
|
||||
<div className="sticky top-0 z-20 -mx-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 shadow-sm backdrop-blur-sm">
|
||||
<p className="text-sm font-medium text-amber-950 dark:text-amber-100">
|
||||
{t("system.unsavedChanges", { ns: "config" })}
|
||||
{runtimeDirty && frontendDirty
|
||||
? ` · ${t("system.title", { ns: "config" })} / ${t("system.frontendConfig", { ns: "config" })}`
|
||||
: runtimeDirty
|
||||
? ` · ${t("system.title", { ns: "config" })}`
|
||||
: ` · ${t("system.frontendConfig", { ns: "config" })}`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<AdminPageCard
|
||||
title={t("system.title", { ns: "config" })}
|
||||
description={t("system.description", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.requireManualReview}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("requireManualReview", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="default-currency" className="text-sm font-medium">
|
||||
{t("system.fields.defaultCurrency", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="default-currency"
|
||||
value={draft.defaultCurrency}
|
||||
onChange={(e) => updateDraft("defaultCurrency", e.target.value.toUpperCase())}
|
||||
disabled={loading || saving}
|
||||
maxLength={16}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-interval-minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
step="1"
|
||||
value={draft.drawIntervalMinutes}
|
||||
onChange={(e) => updateDraft("drawIntervalMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-betting-window-seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
step="1"
|
||||
value={draft.drawBettingWindowSeconds}
|
||||
onChange={(e) => updateDraft("drawBettingWindowSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-close-before-seconds"
|
||||
type="number"
|
||||
min="5"
|
||||
step="1"
|
||||
value={draft.drawCloseBeforeDrawSeconds}
|
||||
onChange={(e) => updateDraft("drawCloseBeforeDrawSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
|
||||
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-buffer-ahead"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={draft.drawBufferDrawsAhead}
|
||||
onChange={(e) => updateDraft("drawBufferDrawsAhead", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-display-decimals"
|
||||
type="number"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={draft.currencyDisplayDecimals}
|
||||
onChange={(e) => updateDraft("currencyDisplayDecimals", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-decimal-separator"
|
||||
value={draft.currencyDecimalSeparator}
|
||||
onChange={(e) => updateDraft("currencyDecimalSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-thousands-separator"
|
||||
value={draft.currencyThousandsSeparator}
|
||||
onChange={(e) => updateDraft("currencyThousandsSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoSettlement}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("autoSettlement", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoApprove}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("autoApprove", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("autoPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1 pr-4">
|
||||
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.applyRebateToPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("applyRebateToPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="grid max-w-xs gap-2">
|
||||
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="cooldown-minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SaveActions
|
||||
dirty={runtimeDirty}
|
||||
loading={loading}
|
||||
saving={savingRuntime}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveRuntimeTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveRuntimeDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => handleSaveRuntime(),
|
||||
})
|
||||
}
|
||||
onDiscard={() => discardSection(RUNTIME_DRAFT_KEYS)}
|
||||
saveLabel={saveLabel}
|
||||
savingLabel={savingLabel}
|
||||
discardLabel={discardLabel}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<DrawSettingsPanel />
|
||||
<CurrencyFormatSettingsPanel />
|
||||
<SettlementSettingsPanel />
|
||||
|
||||
<AdminPageCard
|
||||
title={t("wallet.title", { ns: "config" })}
|
||||
@@ -571,73 +25,15 @@ export function SystemSettingsScreen() {
|
||||
<WalletConfigDocScreen embedded />
|
||||
</AdminPageCard>
|
||||
|
||||
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("system.fields.playRulesHtml", { ns: "config" })}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
|
||||
</p>
|
||||
<Tabs defaultValue="zh" className="w-full">
|
||||
<TabsList className="w-full max-w-md">
|
||||
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="zh" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-zh"
|
||||
value={draft.playRulesHtmlZh}
|
||||
onChange={(e) => updateDraft("playRulesHtmlZh", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="en" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-en"
|
||||
value={draft.playRulesHtmlEn}
|
||||
onChange={(e) => updateDraft("playRulesHtmlEn", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="ne" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-ne"
|
||||
value={draft.playRulesHtmlNe}
|
||||
onChange={(e) => updateDraft("playRulesHtmlNe", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SaveActions
|
||||
dirty={frontendDirty}
|
||||
loading={loading}
|
||||
saving={savingFrontend}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => handleSaveFrontend(),
|
||||
})
|
||||
}
|
||||
onDiscard={() => discardSection(FRONTEND_DRAFT_KEYS)}
|
||||
saveLabel={saveLabel}
|
||||
savingLabel={savingLabel}
|
||||
discardLabel={discardLabel}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
|
||||
<ConfirmDialog />
|
||||
<FrontendSettingsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemSettingsScreen() {
|
||||
return (
|
||||
<AdminSettingsDataProvider>
|
||||
<SystemSettingsContent />
|
||||
</AdminSettingsDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user