364 lines
12 KiB
Swift
364 lines
12 KiB
Swift
import SwiftUI
|
||
|
||
/// Montana Name Service — Domain Registration
|
||
/// Регистрация доменов через аукцион (N-й домен = N Ɉ)
|
||
struct DomainView: View {
|
||
@EnvironmentObject var engine: PresenceEngine
|
||
@State private var domainInput = ""
|
||
@State private var currentPrice = 1
|
||
@State private var totalSold = 0
|
||
@State private var isLoading = false
|
||
@State private var isCheckingAvailability = false
|
||
@State private var domainAvailable: Bool? = nil
|
||
@State private var statusMessage = ""
|
||
@State private var ownedDomains: [OwnedDomain] = []
|
||
@State private var showSidebar = false
|
||
|
||
private let gold = Color(red: 0.85, green: 0.68, blue: 0.25)
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .leading) {
|
||
VStack(spacing: 0) {
|
||
// ── BURGER MENU BUTTON ──
|
||
HStack {
|
||
Button(action: { withAnimation(.easeInOut(duration: 0.3)) { showSidebar = true } }) {
|
||
Image(systemName: "line.3.horizontal")
|
||
.font(.system(size: 18, weight: .medium))
|
||
.foregroundColor(gold)
|
||
.padding(8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 8)
|
||
|
||
// Header
|
||
header
|
||
|
||
Divider()
|
||
|
||
// Main content
|
||
ScrollView {
|
||
VStack(spacing: 24) {
|
||
// Registration card
|
||
registrationCard
|
||
|
||
// Owned domains
|
||
if !ownedDomains.isEmpty {
|
||
ownedDomainsSection
|
||
}
|
||
|
||
// Info section
|
||
infoSection
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
.background(Color(NSColor.windowBackgroundColor))
|
||
.onAppear {
|
||
loadCurrentPrice()
|
||
loadOwnedDomains()
|
||
}
|
||
|
||
// Sidebar overlay
|
||
if showSidebar {
|
||
Color.black.opacity(0.3)
|
||
.ignoresSafeArea()
|
||
.onTapGesture {
|
||
withAnimation(.easeInOut(duration: 0.3)) {
|
||
showSidebar = false
|
||
}
|
||
}
|
||
|
||
SharedSidebar(isVisible: $showSidebar)
|
||
.transition(.move(edge: .leading))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var header: some View {
|
||
HStack(spacing: 12) {
|
||
// Domain icon
|
||
Circle()
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [
|
||
Color(red: 0.0, green: 0.83, blue: 1.0), // #00d4ff cyan
|
||
Color(red: 0.48, green: 0.18, blue: 1.0) // #7b2fff purple
|
||
],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.frame(width: 40, height: 40)
|
||
.overlay(
|
||
Text("@")
|
||
.font(.system(size: 24, weight: .bold))
|
||
.foregroundColor(.white)
|
||
)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Домены Montana")
|
||
.font(.headline)
|
||
Text("Регистрация доменов @efir.org")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// Current price badge
|
||
HStack(spacing: 4) {
|
||
Text("\(currentPrice)")
|
||
.font(.title2)
|
||
.fontWeight(.bold)
|
||
Text("Ɉ")
|
||
.font(.title2)
|
||
}
|
||
.foregroundColor(Color(red: 0.0, green: 0.83, blue: 1.0))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(Color(red: 0.0, green: 0.83, blue: 1.0).opacity(0.1))
|
||
.cornerRadius(8)
|
||
}
|
||
.padding()
|
||
}
|
||
|
||
// MARK: - Registration Card
|
||
|
||
private var registrationCard: some View {
|
||
VStack(spacing: 16) {
|
||
Text("Регистрация домена")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
HStack(spacing: 8) {
|
||
TextField("имя", text: $domainInput)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 16))
|
||
.padding(10)
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
.disabled(isLoading)
|
||
.onChange(of: domainInput) { _ in
|
||
domainAvailable = nil
|
||
statusMessage = ""
|
||
}
|
||
.onSubmit { checkAvailability() }
|
||
|
||
Text("@efir.org")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
if isCheckingAvailability {
|
||
HStack(spacing: 4) {
|
||
ProgressView().controlSize(.small)
|
||
Text("Проверяю...")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
} else if let available = domainAvailable {
|
||
Text(available ? "✓ Домен свободен" : "✗ Домен занят")
|
||
.font(.caption)
|
||
.foregroundColor(available ? .green : .red)
|
||
}
|
||
|
||
if !statusMessage.isEmpty {
|
||
Text(statusMessage)
|
||
.font(.caption)
|
||
.foregroundColor(statusMessage.hasPrefix("✓") ? .green : .red)
|
||
}
|
||
|
||
Button(action: registerDomain) {
|
||
HStack {
|
||
if isLoading {
|
||
ProgressView()
|
||
.scaleEffect(0.8)
|
||
.padding(.trailing, 4)
|
||
}
|
||
Text("Зарегистрировать за \(currentPrice) Ɉ")
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 10)
|
||
.background(domainInput.isEmpty || isLoading ? Color.gray : Color(red: 0.0, green: 0.83, blue: 1.0))
|
||
.foregroundColor(.white)
|
||
.cornerRadius(8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(domainInput.isEmpty || isLoading)
|
||
}
|
||
.padding()
|
||
.background(Color.blue.opacity(0.05))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
// MARK: - Owned Domains Section
|
||
|
||
private var ownedDomainsSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Мои домены")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
ForEach(ownedDomains) { domain in
|
||
HStack {
|
||
Text(domain.name + "@efir.org")
|
||
.font(.system(.body, design: .monospaced))
|
||
|
||
Spacer()
|
||
|
||
Text("\(domain.pricePaid) Ɉ")
|
||
.foregroundColor(.secondary)
|
||
.font(.caption)
|
||
}
|
||
.padding()
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Info Section
|
||
|
||
private var infoSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("💡 Аукционная модель")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
InfoRow(icon: "1️⃣", text: "1-й домен: 1 Ɉ")
|
||
InfoRow(icon: "2️⃣", text: "2-й домен: 2 Ɉ")
|
||
InfoRow(icon: "🔢", text: "N-й домен: N Ɉ")
|
||
InfoRow(icon: "📧", text: "Формат: alice@efir.org")
|
||
InfoRow(icon: "🔐", text: "Постквантовая криптография ML-DSA-65")
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color.orange.opacity(0.05))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
private func loadCurrentPrice() {
|
||
Task { @MainActor in
|
||
do {
|
||
let (price, sold) = try await engine.api.fetchAuctionPrice(serviceType: "domain")
|
||
currentPrice = price
|
||
totalSold = sold
|
||
} catch {
|
||
currentPrice = 1
|
||
}
|
||
}
|
||
}
|
||
|
||
private func loadOwnedDomains() {
|
||
guard let addr = engine.address else { return }
|
||
Task { @MainActor in
|
||
do {
|
||
let events = try await engine.api.fetchMyEvents(address: addr)
|
||
ownedDomains = events.compactMap { event in
|
||
guard let type = event["event_type"] as? String,
|
||
type == "domain_register",
|
||
let metadata = event["metadata"] as? [String: Any],
|
||
let domain = metadata["domain"] as? String,
|
||
let amount = event["amount"] as? Int else { return nil }
|
||
let name = domain.replacingOccurrences(of: "@efir.org", with: "")
|
||
return OwnedDomain(name: name, pricePaid: amount)
|
||
}
|
||
} catch {
|
||
ownedDomains = []
|
||
}
|
||
}
|
||
}
|
||
|
||
private func checkAvailability() {
|
||
let sanitized = domainInput.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
guard !sanitized.isEmpty else { return }
|
||
isCheckingAvailability = true
|
||
Task { @MainActor in
|
||
do {
|
||
let (available, price) = try await engine.api.checkDomainAvailability(domain: sanitized)
|
||
domainAvailable = available
|
||
if available { currentPrice = price }
|
||
} catch {
|
||
domainAvailable = nil
|
||
}
|
||
isCheckingAvailability = false
|
||
}
|
||
}
|
||
|
||
private func registerDomain() {
|
||
guard !domainInput.isEmpty else { return }
|
||
guard let ownerAddress = engine.address else {
|
||
statusMessage = "❌ Нет адреса кошелька"
|
||
return
|
||
}
|
||
|
||
let sanitized = domainInput.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
guard sanitized.range(of: "^[a-z0-9_-]+$", options: .regularExpression) != nil else {
|
||
statusMessage = "❌ Только латиница, цифры, _ и -"
|
||
return
|
||
}
|
||
|
||
isLoading = true
|
||
statusMessage = ""
|
||
|
||
Task { @MainActor in
|
||
do {
|
||
let result = try await engine.api.registerDomain(
|
||
domain: sanitized,
|
||
ownerAddress: ownerAddress,
|
||
amount: currentPrice
|
||
)
|
||
let pricePaid = result["price_paid"] as? Int ?? currentPrice
|
||
statusMessage = "✓ Домен \(sanitized)@efir.org зарегистрирован!"
|
||
ownedDomains.append(OwnedDomain(name: sanitized, pricePaid: pricePaid))
|
||
domainInput = ""
|
||
domainAvailable = nil
|
||
await engine.syncBalance()
|
||
loadCurrentPrice()
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
isLoading = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Models
|
||
|
||
struct OwnedDomain: Identifiable {
|
||
let id = UUID()
|
||
let name: String
|
||
let pricePaid: Int
|
||
}
|
||
|
||
private struct InfoRow: View {
|
||
let icon: String
|
||
let text: String
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
Text(icon)
|
||
.font(.body)
|
||
Text(text)
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Preview
|
||
|
||
#Preview {
|
||
DomainView()
|
||
.environmentObject(PresenceEngine.shared)
|
||
.frame(width: 600, height: 500)
|
||
}
|