v0.6.0 — Phase β-5 Marketplace

Voller Marketplace-Flow mit TabBar und Universal-Link-Handler.
Drei Live-Decks (Geografie, English A2, Periodensystem) sind
browse-, abonnier- und lernbar.

- PublicDeckEntry/PublicDeck/PublicDeckVersion/PublicDeckOwner/
  PublicDeckDetail Codable mit snake_case
- ExploreResponse, BrowseResponse, SubscribeResponse
- MarketplaceSort-Enum (recent/popular/trending)
- CardsAPI.explore/browseMarketplace/publicDeck/subscribe/unsubscribe
- MarketplaceStore @Observable mit Explore + Browse States
- ExploreView: Featured + Trending Horizontal-Carousels, Browse-Link
- BrowseView: Searchable + Sort-Picker + List
- PublicDeckView: Header/Metadata/Subscribe — Subscribe löst Auto-Fork
  serverseitig aus, Response liefert private_deck_id, NavigationLink
  zum eigenen Deck
- PublicDeckCard + BrowseRow mit forest-Theme
- RootView: TabBar (Decks/Entdecken/Account) statt Single-View
- Universal-Link-Handler: onOpenURL + onContinueUserActivity für
  https://cardecky.mana.how/d/<slug> und cards://d/<slug>
- associated-domains: applinks:cardecky.mana.how im entitlement
- 5 neue Marketplace-Decoding-Tests (35 Total grün)

Universal-Links funktionieren erst nach AASA-Setup auf
cardecky.mana.how/.well-known/apple-app-site-association
(Web-Aufgabe, heute 404).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 00:51:12 +02:00
parent 80eb3708b4
commit 07ada72b0f
10 changed files with 1015 additions and 24 deletions

View file

@ -54,6 +54,72 @@ actor CardsAPI {
return try decoder.decode(DueReviewsResponse.self, from: data).total
}
// MARK: - Marketplace
/// `GET /api/v1/marketplace/explore` Featured + Trending.
func explore() async throws -> ExploreResponse {
let (data, http) = try await transport.request(path: "/api/v1/marketplace/explore")
try ensureOK(http, data: data)
return try decoder.decode(ExploreResponse.self, from: data)
}
/// `GET /api/v1/marketplace/decks` Browse mit Filtern.
func browseMarketplace(
query: String? = nil,
sort: MarketplaceSort = .recent,
language: String? = nil,
limit: Int = 20,
offset: Int = 0
) async throws -> BrowseResponse {
var items: [URLQueryItem] = [
.init(name: "sort", value: sort.rawValue),
.init(name: "limit", value: "\(limit)"),
.init(name: "offset", value: "\(offset)"),
]
if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty {
items.append(.init(name: "q", value: query))
}
if let language {
items.append(.init(name: "language", value: language))
}
var components = URLComponents()
components.queryItems = items
let queryString = components.percentEncodedQuery ?? ""
let path = "/api/v1/marketplace/decks?\(queryString)"
let (data, http) = try await transport.request(path: path)
try ensureOK(http, data: data)
return try decoder.decode(BrowseResponse.self, from: data)
}
/// `GET /api/v1/marketplace/decks/:slug`.
func publicDeck(slug: String) async throws -> PublicDeckDetail {
let (data, http) = try await transport.request(path: "/api/v1/marketplace/decks/\(slug)")
try ensureOK(http, data: data)
return try decoder.decode(PublicDeckDetail.self, from: data)
}
/// `POST /api/v1/marketplace/decks/:slug/subscribe` Auto-Fork
/// passiert serverseitig, Response liefert `private_deck_id`.
@discardableResult
func subscribe(slug: String) async throws -> SubscribeResponse {
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks/\(slug)/subscribe",
method: "POST"
)
try ensureOK(http, data: data)
return try decoder.decode(SubscribeResponse.self, from: data)
}
/// `DELETE /api/v1/marketplace/decks/:slug/subscribe`.
func unsubscribe(slug: String) async throws {
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks/\(slug)/subscribe",
method: "DELETE"
)
try ensureOK(http, data: data)
}
// MARK: - Media
/// `POST /api/v1/media/upload` Multipart-Upload. Max 25 MiB.