6de2455917
- Added GLOBAL_MARKETS_TITLE to all translation files - Updated footer with 12 markets (4 active + 8 upcoming) - Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi - Built and deployed to production - CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
293 lines
9.4 KiB
Swift
293 lines
9.4 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - API Infrastructure
|
|
|
|
enum APIError: LocalizedError {
|
|
case invalidURL
|
|
case unauthorized
|
|
case serverError(Int, String?)
|
|
case networkError(Error)
|
|
case decodingError
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL: return "Ogiltig URL"
|
|
case .unauthorized: return "Felaktiga inloggningsuppgifter"
|
|
case .serverError(let c, let m): return m ?? "Serverfel (\(c))"
|
|
case .networkError(let e): return "Nätverksfel: \(e.localizedDescription)"
|
|
case .decodingError: return "Dataformatfel från servern"
|
|
}
|
|
}
|
|
}
|
|
|
|
final class APIClient {
|
|
static let shared = APIClient()
|
|
|
|
var baseURL = "https://api.aamos.io/v1"
|
|
private var token: String?
|
|
private let session: URLSession
|
|
|
|
private init() {
|
|
let cfg = URLSessionConfiguration.default
|
|
cfg.timeoutIntervalForRequest = 30
|
|
cfg.timeoutIntervalForResource = 60
|
|
session = URLSession(configuration: cfg)
|
|
}
|
|
|
|
func setToken(_ token: String?) { self.token = token }
|
|
|
|
func get<T: Decodable>(_ path: String) async throws -> T {
|
|
try await request(path, method: "GET")
|
|
}
|
|
|
|
func post<B: Encodable, T: Decodable>(_ path: String, body: B) async throws -> T {
|
|
try await request(path, method: "POST", body: try JSONEncoder().encode(body))
|
|
}
|
|
|
|
func patch<B: Encodable, T: Decodable>(_ path: String, body: B) async throws -> T {
|
|
try await request(path, method: "PATCH", body: try JSONEncoder().encode(body))
|
|
}
|
|
|
|
func delete(_ path: String) async throws {
|
|
let _: EmptyResponse = try await request(path, method: "DELETE")
|
|
}
|
|
|
|
private struct EmptyResponse: Decodable {}
|
|
private struct ServerError: Decodable { let message: String? }
|
|
|
|
private func request<T: Decodable>(_ path: String, method: String, body: Data? = nil) async throws -> T {
|
|
guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL }
|
|
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = method
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
|
req.httpBody = body
|
|
|
|
let data: Data
|
|
let response: URLResponse
|
|
do {
|
|
(data, response) = try await session.data(for: req)
|
|
} catch {
|
|
throw APIError.networkError(error)
|
|
}
|
|
|
|
if let http = response as? HTTPURLResponse {
|
|
if http.statusCode == 401 { throw APIError.unauthorized }
|
|
if http.statusCode >= 400 {
|
|
let msg = try? JSONDecoder().decode(ServerError.self, from: data)
|
|
throw APIError.serverError(http.statusCode, msg?.message)
|
|
}
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
guard let result = try? decoder.decode(T.self, from: data) else {
|
|
throw APIError.decodingError
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: - Auth Model
|
|
|
|
struct AdminUser: Codable, Identifiable {
|
|
let id: String
|
|
let name: String
|
|
let email: String
|
|
let role: String
|
|
}
|
|
|
|
// MARK: - Auth ViewModel
|
|
|
|
@MainActor
|
|
final class AuthViewModel: ObservableObject {
|
|
@Published var isAuthenticated = false
|
|
@Published var isLoading = false
|
|
@Published var errorMessage: String?
|
|
@Published var currentUser: AdminUser?
|
|
|
|
private let api = APIClient.shared
|
|
|
|
private struct LoginRequest: Encodable {
|
|
let email: String
|
|
let password: String
|
|
}
|
|
|
|
private struct LoginResponse: Decodable {
|
|
let token: String
|
|
let user: AdminUser
|
|
}
|
|
|
|
func login(email: String, password: String) async {
|
|
guard !email.trimmingCharacters(in: .whitespaces).isEmpty, !password.isEmpty else {
|
|
errorMessage = "Fyll i e-post och lösenord"
|
|
return
|
|
}
|
|
isLoading = true
|
|
errorMessage = nil
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
let response: LoginResponse = try await api.post(
|
|
"/auth/login",
|
|
body: LoginRequest(email: email, password: password)
|
|
)
|
|
api.setToken(response.token)
|
|
currentUser = response.user
|
|
isAuthenticated = true
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func logout() {
|
|
api.setToken(nil)
|
|
currentUser = nil
|
|
isAuthenticated = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Login View
|
|
|
|
struct LoginView: View {
|
|
@EnvironmentObject private var auth: AuthViewModel
|
|
@State private var email = ""
|
|
@State private var password = ""
|
|
@FocusState private var focused: LoginField?
|
|
|
|
private enum LoginField { case email, password }
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color(.systemBackground).ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
Spacer(minLength: 72)
|
|
logoSection
|
|
Spacer(minLength: 48)
|
|
formSection
|
|
Spacer(minLength: 40)
|
|
Text("AAMOS Admin v1.0")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.bottom, 8)
|
|
}
|
|
.padding(.horizontal, 24)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var logoSection: some View {
|
|
VStack(spacing: 14) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
.fill(Color(.systemBlue).opacity(0.12))
|
|
.frame(width: 88, height: 88)
|
|
Image(systemName: "shield.checkered")
|
|
.font(.system(size: 44, weight: .semibold))
|
|
.foregroundStyle(Color(.systemBlue))
|
|
}
|
|
Text("AAMOS Admin")
|
|
.font(.system(size: 28, weight: .bold))
|
|
.foregroundStyle(.primary)
|
|
Text("Administrationsportal")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var formSection: some View {
|
|
VStack(spacing: 16) {
|
|
VStack(spacing: 8) {
|
|
TextField("E-postadress", text: $email)
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.autocapitalization(.none)
|
|
.autocorrectionDisabled()
|
|
.focused($focused, equals: .email)
|
|
.submitLabel(.next)
|
|
.onSubmit { focused = .password }
|
|
.padding(16)
|
|
.background(Color(.secondarySystemBackground))
|
|
.cornerRadius(12)
|
|
|
|
SecureField("Lösenord", text: $password)
|
|
.textContentType(.password)
|
|
.focused($focused, equals: .password)
|
|
.submitLabel(.go)
|
|
.onSubmit {
|
|
focused = nil
|
|
Task { await auth.login(email: email, password: password) }
|
|
}
|
|
.padding(16)
|
|
.background(Color(.secondarySystemBackground))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
if let error = auth.errorMessage {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.font(.callout)
|
|
.foregroundStyle(Color(.systemRed))
|
|
Text(error)
|
|
.font(.callout)
|
|
.foregroundStyle(Color(.systemRed))
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.systemRed).opacity(0.08))
|
|
.cornerRadius(12)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
Button {
|
|
focused = nil
|
|
Task { await auth.login(email: email, password: password) }
|
|
} label: {
|
|
Group {
|
|
if auth.isLoading {
|
|
ProgressView().tint(.white).scaleEffect(0.9)
|
|
} else {
|
|
Text("Logga in").font(.headline)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 52)
|
|
.background(auth.isLoading ? Color(.systemBlue).opacity(0.7) : Color(.systemBlue))
|
|
.foregroundStyle(.white)
|
|
.cornerRadius(12)
|
|
}
|
|
.disabled(auth.isLoading)
|
|
.animation(.easeInOut(duration: 0.15), value: auth.isLoading)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - App Entry Point
|
|
|
|
@main
|
|
struct AamosAdminApp: App {
|
|
@StateObject private var auth = AuthViewModel()
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
Group {
|
|
if auth.isAuthenticated {
|
|
ContentView()
|
|
.environmentObject(auth)
|
|
.transition(.opacity)
|
|
} else {
|
|
LoginView()
|
|
.environmentObject(auth)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.25), value: auth.isAuthenticated)
|
|
}
|
|
}
|
|
}
|