diff --git a/README.md b/README.md index f6ed5f5..00a3a64 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,12 @@ openspec validate --all --strict # 规范校验 ## 商城的 mock 边界 -`apps/mall` 通过 `apps/mall/plugins/api.ts` 的 `liveDomains` 按域选择适配器。已有 API 的域全部走真实后端:catalog、currency、content、brands、shops、auth、cart、orders、shipments、invoices、addresses。 +`apps/mall` 通过 `apps/mall/plugins/api.ts` 的 `liveDomains` 按域选择适配器。当前真实后端域包括 +catalog、currency、content、brands、shops、auth、account、cart、orders、shipments、invoices、 +addresses、coupons、points、flashSales、groupBuying、favorites。 -仍来自 `~/mock/data` 的部分都是没有后端能力的营销/账户域,逐项记录在 `docs/TBD-marketing.md`(优惠券、收藏、账户统计、秒杀、拼团、积分商城、评价等)。每一项都是一次新的能力建设,不是适配层切换。 +仍由页面直接读取 `~/mock/data` 的能力记录在 `docs/TBD-marketing.md`。营销 fixture 仍由 +fixed-data 适配器使用,作为每个已迁移域的回滚实现。 - **fixed-data 适配器本身**:`Mock API adapter` 规范要求它仍能服务每个域,因此它是回滚路径,删除它会破坏回滚。 diff --git a/apps/api/migrations/0015_favorites.sql b/apps/api/migrations/0015_favorites.sql new file mode 100644 index 0000000..5c13a97 --- /dev/null +++ b/apps/api/migrations/0015_favorites.sql @@ -0,0 +1,25 @@ +-- Customer favorites: one row per (user, product) or (user, shop). +-- Two nullable FKs plus a check keep the target shape honest; partial unique +-- indexes enforce uniqueness without letting NULL shop/product repeat. + +CREATE TABLE favorites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + product_id UUID REFERENCES products (id) ON DELETE CASCADE, + shop_id UUID REFERENCES shops (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT favorites_exactly_one_target CHECK ( + (product_id IS NOT NULL AND shop_id IS NULL) + OR (product_id IS NULL AND shop_id IS NOT NULL) + ) +); + +CREATE INDEX favorites_user_created_idx ON favorites (user_id, created_at DESC); + +CREATE UNIQUE INDEX favorites_user_product_idx + ON favorites (user_id, product_id) + WHERE product_id IS NOT NULL; + +CREATE UNIQUE INDEX favorites_user_shop_idx + ON favorites (user_id, shop_id) + WHERE shop_id IS NOT NULL; diff --git a/apps/api/src/modules/favorite/dto.rs b/apps/api/src/modules/favorite/dto.rs new file mode 100644 index 0000000..c938225 --- /dev/null +++ b/apps/api/src/modules/favorite/dto.rs @@ -0,0 +1,116 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use serde_json::Value; +use uuid::Uuid; + +use crate::modules::shop::service::ShopProfileView; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct FavoriteProductSummary { + pub id: Uuid, + pub shop_id: Uuid, + pub slug: String, + pub name: Value, + pub image: Option, + pub price_minor: Option, + pub currency: Option, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "kind")] +pub enum Favorite { + #[serde(rename = "product")] + Product { + id: Uuid, + user_id: Uuid, + created_at: DateTime, + product: FavoriteProductSummary, + }, + #[serde(rename = "shop")] + Shop { + id: Uuid, + user_id: Uuid, + created_at: DateTime, + shop: ShopProfileView, + }, +} + +#[derive(Debug, sqlx::FromRow)] +pub struct ProductFavoriteRow { + pub id: Uuid, + pub user_id: Uuid, + pub created_at: DateTime, + pub product_id: Uuid, + pub shop_id: Uuid, + pub slug: String, + pub name: Value, + pub image: Option, + pub price_minor: Option, + pub currency: Option, +} + +impl ProductFavoriteRow { + pub fn into_favorite(self) -> Favorite { + Favorite::Product { + id: self.id, + user_id: self.user_id, + created_at: self.created_at, + product: FavoriteProductSummary { + id: self.product_id, + shop_id: self.shop_id, + slug: self.slug, + name: self.name, + image: self.image, + price_minor: self.price_minor, + currency: self.currency, + }, + } + } +} + +#[derive(Debug, sqlx::FromRow)] +pub struct ShopFavoriteRow { + pub id: Uuid, + pub user_id: Uuid, + pub created_at: DateTime, + pub shop_id: Uuid, + pub slug: String, + pub name: Value, + pub company: Option, + pub region: Option, + pub address: Option, + pub logo: Option, + pub banner: Option, + pub notice: Option, + pub after_sale: Option, + pub score_rating: Option, + pub score_agreement: Option, + pub score_service: Option, + pub score_speed: Option, +} + +impl ShopFavoriteRow { + pub fn into_favorite(self) -> Favorite { + Favorite::Shop { + id: self.id, + user_id: self.user_id, + created_at: self.created_at, + shop: ShopProfileView { + id: self.shop_id, + slug: self.slug, + name: self.name, + company: self.company, + region: self.region, + address: self.address, + logo: self.logo, + banner: self.banner, + notice: self.notice, + after_sale: self.after_sale, + score_rating: self.score_rating, + score_agreement: self.score_agreement, + score_service: self.score_service, + score_speed: self.score_speed, + }, + } + } +} diff --git a/apps/api/src/modules/favorite/handlers.rs b/apps/api/src/modules/favorite/handlers.rs new file mode 100644 index 0000000..861d53d --- /dev/null +++ b/apps/api/src/modules/favorite/handlers.rs @@ -0,0 +1,88 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{get, put}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::Paged; +use crate::state::AppState; + +use super::dto::Favorite; +use super::service; + +#[derive(Debug, Deserialize)] +struct FavoriteListQuery { + kind: String, + target_id: Option, + page: Option, + per_page: Option, +} + +pub fn router() -> Router { + Router::new() + .route("/favorites", get(list_favorites)) + .route( + "/favorites/products/{product_id}", + put(add_product).delete(remove_product), + ) + .route( + "/favorites/shops/{shop_id}", + put(add_shop).delete(remove_shop), + ) +} + +async fn list_favorites( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_customer()?; + Ok(Json( + service::list(&state, auth.id, &q.kind, q.target_id, q.page, q.per_page).await?, + )) +} + +async fn add_product( + State(state): State, + auth: AuthUser, + Path(product_id): Path, +) -> ApiResult> { + auth.require_customer()?; + Ok(Json( + service::add_product(&state, auth.id, product_id).await?, + )) +} + +async fn remove_product( + State(state): State, + auth: AuthUser, + Path(product_id): Path, +) -> ApiResult { + auth.require_customer()?; + service::remove_product(&state, auth.id, product_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn add_shop( + State(state): State, + auth: AuthUser, + Path(shop_id): Path, +) -> ApiResult> { + auth.require_customer()?; + Ok(Json(service::add_shop(&state, auth.id, shop_id).await?)) +} + +async fn remove_shop( + State(state): State, + auth: AuthUser, + Path(shop_id): Path, +) -> ApiResult { + auth.require_customer()?; + service::remove_shop(&state, auth.id, shop_id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/api/src/modules/favorite/mod.rs b/apps/api/src/modules/favorite/mod.rs new file mode 100644 index 0000000..2cdf8fe --- /dev/null +++ b/apps/api/src/modules/favorite/mod.rs @@ -0,0 +1,12 @@ +mod dto; +mod handlers; +mod repo; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/favorite/repo.rs b/apps/api/src/modules/favorite/repo.rs new file mode 100644 index 0000000..078e50d --- /dev/null +++ b/apps/api/src/modules/favorite/repo.rs @@ -0,0 +1,243 @@ +use sqlx::{PgConnection, PgPool}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; + +use super::dto::{Favorite, ProductFavoriteRow, ShopFavoriteRow}; + +const PRODUCT_VISIBLE: &str = "p.status = 'published' AND sh.status = 'active'"; + +const PRODUCT_FROM: &str = "FROM favorites f + JOIN products p ON p.id = f.product_id + JOIN shops sh ON sh.id = p.shop_id + LEFT JOIN LATERAL ( + SELECT s.price_minor, s.currency + FROM skus s + WHERE s.product_id = p.id AND s.active = TRUE + ORDER BY s.price_minor ASC, s.sku_code ASC + LIMIT 1 + ) sku ON TRUE"; + +const PRODUCT_SELECT: &str = "SELECT f.id, f.user_id, f.created_at, + p.id AS product_id, p.shop_id, p.slug, p.name, + CASE WHEN jsonb_typeof(p.images) = 'array' AND jsonb_array_length(p.images) > 0 + THEN p.images->>0 ELSE NULL END AS image, + sku.price_minor, sku.currency"; + +const SHOP_FROM: &str = "FROM favorites f + JOIN shops s ON s.id = f.shop_id + LEFT JOIN shop_profiles p ON p.shop_id = s.id"; + +const SHOP_SELECT: &str = "SELECT f.id, f.user_id, f.created_at, + s.id AS shop_id, s.slug, s.name, + p.company, p.region, p.address, p.logo, p.banner, p.notice, p.after_sale, + p.score_rating, p.score_agreement, p.score_service, p.score_speed"; + +pub async fn lock_visible_product(tx: &mut PgConnection, product_id: Uuid) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM products p + JOIN shops sh ON sh.id = p.shop_id + WHERE p.id = $1 AND p.status = 'published' AND sh.status = 'active' + FOR SHARE OF p, sh + )", + ) + .bind(product_id) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn lock_visible_shop(tx: &mut PgConnection, shop_id: Uuid) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM shops + WHERE id = $1 AND status = 'active' + FOR SHARE + )", + ) + .bind(shop_id) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn upsert_product( + tx: &mut PgConnection, + user_id: Uuid, + product_id: Uuid, +) -> ApiResult { + let inserted: Option = sqlx::query_scalar( + "INSERT INTO favorites (user_id, product_id) + VALUES ($1, $2) + ON CONFLICT (user_id, product_id) WHERE product_id IS NOT NULL + DO NOTHING + RETURNING id", + ) + .bind(user_id) + .bind(product_id) + .fetch_optional(&mut *tx) + .await?; + if let Some(id) = inserted { + return Ok(id); + } + sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND product_id = $2") + .bind(user_id) + .bind(product_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("favorite".into())) +} + +pub async fn upsert_shop(tx: &mut PgConnection, user_id: Uuid, shop_id: Uuid) -> ApiResult { + let inserted: Option = sqlx::query_scalar( + "INSERT INTO favorites (user_id, shop_id) + VALUES ($1, $2) + ON CONFLICT (user_id, shop_id) WHERE shop_id IS NOT NULL + DO NOTHING + RETURNING id", + ) + .bind(user_id) + .bind(shop_id) + .fetch_optional(&mut *tx) + .await?; + if let Some(id) = inserted { + return Ok(id); + } + sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND shop_id = $2") + .bind(user_id) + .bind(shop_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("favorite".into())) +} + +pub async fn delete_product(db: &PgPool, user_id: Uuid, product_id: Uuid) -> ApiResult<()> { + sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND product_id = $2") + .bind(user_id) + .bind(product_id) + .execute(db) + .await?; + Ok(()) +} + +pub async fn delete_shop(db: &PgPool, user_id: Uuid, shop_id: Uuid) -> ApiResult<()> { + sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND shop_id = $2") + .bind(user_id) + .bind(shop_id) + .execute(db) + .await?; + Ok(()) +} + +pub async fn count_products(db: &PgPool, user_id: Uuid, target_id: Option) -> ApiResult { + Ok(sqlx::query_scalar(&format!( + "SELECT count(*) {PRODUCT_FROM} + WHERE f.user_id = $1 AND f.product_id IS NOT NULL + AND {PRODUCT_VISIBLE} + AND ($2::uuid IS NULL OR f.product_id = $2)" + )) + .bind(user_id) + .bind(target_id) + .fetch_one(db) + .await?) +} + +pub async fn count_shops(db: &PgPool, user_id: Uuid, target_id: Option) -> ApiResult { + Ok(sqlx::query_scalar(&format!( + "SELECT count(*) {SHOP_FROM} + WHERE f.user_id = $1 AND f.shop_id IS NOT NULL + AND s.status = 'active' + AND ($2::uuid IS NULL OR f.shop_id = $2)" + )) + .bind(user_id) + .bind(target_id) + .fetch_one(db) + .await?) +} + +pub async fn list_products( + db: &PgPool, + user_id: Uuid, + target_id: Option, + limit: i64, + offset: i64, +) -> ApiResult> { + let rows = sqlx::query_as::<_, ProductFavoriteRow>(&format!( + "{PRODUCT_SELECT} {PRODUCT_FROM} + WHERE f.user_id = $1 AND f.product_id IS NOT NULL + AND {PRODUCT_VISIBLE} + AND ($2::uuid IS NULL OR f.product_id = $2) + ORDER BY f.created_at DESC + LIMIT $3 OFFSET $4" + )) + .bind(user_id) + .bind(target_id) + .bind(limit) + .bind(offset) + .fetch_all(db) + .await?; + Ok(rows + .into_iter() + .map(ProductFavoriteRow::into_favorite) + .collect()) +} + +pub async fn list_shops( + db: &PgPool, + user_id: Uuid, + target_id: Option, + limit: i64, + offset: i64, +) -> ApiResult> { + let rows = sqlx::query_as::<_, ShopFavoriteRow>(&format!( + "{SHOP_SELECT} {SHOP_FROM} + WHERE f.user_id = $1 AND f.shop_id IS NOT NULL + AND s.status = 'active' + AND ($2::uuid IS NULL OR f.shop_id = $2) + ORDER BY f.created_at DESC + LIMIT $3 OFFSET $4" + )) + .bind(user_id) + .bind(target_id) + .bind(limit) + .bind(offset) + .fetch_all(db) + .await?; + Ok(rows + .into_iter() + .map(ShopFavoriteRow::into_favorite) + .collect()) +} + +pub async fn get_product_favorite( + tx: &mut PgConnection, + user_id: Uuid, + product_id: Uuid, +) -> ApiResult { + let row = sqlx::query_as::<_, ProductFavoriteRow>(&format!( + "{PRODUCT_SELECT} {PRODUCT_FROM} + WHERE f.user_id = $1 AND f.product_id = $2 AND {PRODUCT_VISIBLE}" + )) + .bind(user_id) + .bind(product_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("favorite".into()))?; + Ok(row.into_favorite()) +} + +pub async fn get_shop_favorite( + tx: &mut PgConnection, + user_id: Uuid, + shop_id: Uuid, +) -> ApiResult { + let row = sqlx::query_as::<_, ShopFavoriteRow>(&format!( + "{SHOP_SELECT} {SHOP_FROM} + WHERE f.user_id = $1 AND f.shop_id = $2 AND s.status = 'active'" + )) + .bind(user_id) + .bind(shop_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("favorite".into()))?; + Ok(row.into_favorite()) +} diff --git a/apps/api/src/modules/favorite/service.rs b/apps/api/src/modules/favorite/service.rs new file mode 100644 index 0000000..4362910 --- /dev/null +++ b/apps/api/src/modules/favorite/service.rs @@ -0,0 +1,72 @@ +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::state::AppState; + +use super::dto::Favorite; +use super::repo; + +pub async fn add_product(state: &AppState, user_id: Uuid, product_id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + if !repo::lock_visible_product(&mut tx, product_id).await? { + return Err(ApiError::NotFound("product".into())); + } + repo::upsert_product(&mut tx, user_id, product_id).await?; + let favorite = repo::get_product_favorite(&mut tx, user_id, product_id).await?; + tx.commit().await?; + Ok(favorite) +} + +pub async fn add_shop(state: &AppState, user_id: Uuid, shop_id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + if !repo::lock_visible_shop(&mut tx, shop_id).await? { + return Err(ApiError::NotFound("shop".into())); + } + repo::upsert_shop(&mut tx, user_id, shop_id).await?; + let favorite = repo::get_shop_favorite(&mut tx, user_id, shop_id).await?; + tx.commit().await?; + Ok(favorite) +} + +pub async fn remove_product(state: &AppState, user_id: Uuid, product_id: Uuid) -> ApiResult<()> { + repo::delete_product(&state.db, user_id, product_id).await +} + +pub async fn remove_shop(state: &AppState, user_id: Uuid, shop_id: Uuid) -> ApiResult<()> { + repo::delete_shop(&state.db, user_id, shop_id).await +} + +pub async fn list( + state: &AppState, + user_id: Uuid, + kind: &str, + target_id: Option, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let offset = (page - 1) * per_page; + let (total, items) = match kind { + "product" => ( + repo::count_products(&state.db, user_id, target_id).await?, + repo::list_products(&state.db, user_id, target_id, per_page, offset).await?, + ), + "shop" => ( + repo::count_shops(&state.db, user_id, target_id).await?, + repo::list_shops(&state.db, user_id, target_id, per_page, offset).await?, + ), + other => { + return Err(ApiError::BadRequest(format!( + "kind must be product or shop, got {other}" + ))) + } + }; + Ok(Paged { + items, + total, + page, + per_page, + }) +} diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index 9baee23..a553a53 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -6,6 +6,7 @@ pub mod catalog; pub mod content; pub mod coupon; pub mod currency; +pub mod favorite; pub mod flash_sale; pub mod fulfillment; pub mod group_buying; @@ -30,6 +31,7 @@ pub fn api_router() -> Router { .merge(content::router()) .merge(cart::router()) .merge(coupon::router()) + .merge(favorite::router()) .merge(flash_sale::router()) .merge(group_buying::router()) .merge(order::router()) diff --git a/apps/api/tests/favorites.rs b/apps/api/tests/favorites.rs new file mode 100644 index 0000000..eb1a882 --- /dev/null +++ b/apps/api/tests/favorites.rs @@ -0,0 +1,464 @@ +mod common; + +use common::{ + client, create_product_with_sku, create_shop, login_admin, make_shop_owner, publish_product, + register_customer, spawn_app, +}; +use serial_test::serial; +use std::time::Duration; + +use uuid::Uuid; + +async fn put_product(app: &common::TestApp, token: &str, product_id: &str) -> reqwest::Response { + client() + .put(app.url(&format!("/api/favorites/products/{product_id}"))) + .bearer_auth(token) + .send() + .await + .unwrap() +} + +async fn put_shop(app: &common::TestApp, token: &str, shop_id: &str) -> reqwest::Response { + client() + .put(app.url(&format!("/api/favorites/shops/{shop_id}"))) + .bearer_auth(token) + .send() + .await + .unwrap() +} + +async fn delete_product(app: &common::TestApp, token: &str, product_id: &str) -> reqwest::Response { + client() + .delete(app.url(&format!("/api/favorites/products/{product_id}"))) + .bearer_auth(token) + .send() + .await + .unwrap() +} + +async fn delete_shop(app: &common::TestApp, token: &str, shop_id: &str) -> reqwest::Response { + client() + .delete(app.url(&format!("/api/favorites/shops/{shop_id}"))) + .bearer_auth(token) + .send() + .await + .unwrap() +} + +async fn list( + app: &common::TestApp, + token: &str, + kind: &str, + target_id: Option<&str>, + page: Option, + per_page: Option, +) -> serde_json::Value { + let mut req = client() + .get(app.url("/api/favorites")) + .query(&[("kind", kind)]); + if let Some(id) = target_id { + req = req.query(&[("target_id", id)]); + } + if let Some(p) = page { + req = req.query(&[("page", p)]); + } + if let Some(n) = per_page { + req = req.query(&[("per_page", n)]); + } + let res = req.bearer_auth(token).send().await.unwrap(); + assert_eq!(res.status(), 200, "list: {:?}", res.text().await); + res.json().await.unwrap() +} + +async fn sellable( + app: &common::TestApp, + admin: &str, + slug: &str, + price: i64, +) -> (String, String, String) { + let shop_id = create_shop(app, admin, slug).await; + let owner = make_shop_owner(app, admin, &shop_id).await; + let (product_id, _) = create_product_with_sku(app, &owner, slug, price, 10).await; + publish_product(app, &owner, &product_id).await; + (owner, shop_id, product_id) +} + +#[tokio::test] +#[serial] +async fn check_constraint_rejects_invalid_target_shape() { + let app = spawn_app().await; + let (_token, user_id) = register_customer(&app, "fav-shape").await; + let admin = login_admin(&app).await; + let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-shape", 1000).await; + + let both = sqlx::query( + "INSERT INTO favorites (user_id, product_id, shop_id) + VALUES ($1::uuid, $2::uuid, $3::uuid)", + ) + .bind(&user_id) + .bind(&product_id) + .bind(&shop_id) + .execute(&app.db) + .await; + assert!(both.is_err(), "both targets must be rejected"); + + let neither = sqlx::query("INSERT INTO favorites (user_id) VALUES ($1::uuid)") + .bind(&user_id) + .execute(&app.db) + .await; + assert!(neither.is_err(), "neither target must be rejected"); +} + +#[tokio::test] +#[serial] +async fn uniqueness_is_per_customer_and_target() { + let app = spawn_app().await; + let (_token, user_id) = register_customer(&app, "fav-uniq").await; + let admin = login_admin(&app).await; + let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-uniq", 1000).await; + + sqlx::query("INSERT INTO favorites (user_id, product_id) VALUES ($1::uuid, $2::uuid)") + .bind(&user_id) + .bind(&product_id) + .execute(&app.db) + .await + .unwrap(); + let dup_product = + sqlx::query("INSERT INTO favorites (user_id, product_id) VALUES ($1::uuid, $2::uuid)") + .bind(&user_id) + .bind(&product_id) + .execute(&app.db) + .await; + assert!(dup_product.is_err()); + + sqlx::query("INSERT INTO favorites (user_id, shop_id) VALUES ($1::uuid, $2::uuid)") + .bind(&user_id) + .bind(&shop_id) + .execute(&app.db) + .await + .unwrap(); + let dup_shop = + sqlx::query("INSERT INTO favorites (user_id, shop_id) VALUES ($1::uuid, $2::uuid)") + .bind(&user_id) + .bind(&shop_id) + .execute(&app.db) + .await; + assert!(dup_shop.is_err()); +} + +#[tokio::test] +#[serial] +async fn ownership_filters_list_and_remove() { + let app = spawn_app().await; + let (alice, _) = register_customer(&app, "fav-alice").await; + let (bob, _) = register_customer(&app, "fav-bob").await; + let admin = login_admin(&app).await; + let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-own", 2500).await; + + assert_eq!(put_product(&app, &alice, &product_id).await.status(), 200); + assert_eq!(put_shop(&app, &alice, &shop_id).await.status(), 200); + + let bob_products = list(&app, &bob, "product", None, None, None).await; + assert_eq!(bob_products["total"], 0); + assert_eq!(bob_products["items"].as_array().unwrap().len(), 0); + + assert_eq!(delete_product(&app, &bob, &product_id).await.status(), 204); + let alice_products = list(&app, &alice, "product", None, None, None).await; + assert_eq!(alice_products["total"], 1); + assert_eq!(alice_products["items"][0]["product"]["id"], product_id); +} + +#[tokio::test] +#[serial] +async fn missing_or_unavailable_targets_are_not_found_on_add() { + let app = spawn_app().await; + let (token, _) = register_customer(&app, "fav-miss").await; + let admin = login_admin(&app).await; + let (owner, shop_id, product_id) = sellable(&app, &admin, "fav-miss", 1000).await; + + let missing = Uuid::new_v4(); + assert_eq!( + put_product(&app, &token, &missing.to_string()) + .await + .status(), + 404 + ); + assert_eq!( + put_shop(&app, &token, &missing.to_string()).await.status(), + 404 + ); + + let res = client() + .post(app.url(&format!("/api/shop/products/{product_id}/unpublish"))) + .bearer_auth(&owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + assert_eq!(put_product(&app, &token, &product_id).await.status(), 404); + + let res = client() + .put(app.url(&format!("/api/admin/shops/{shop_id}/status"))) + .bearer_auth(&admin) + .json(&serde_json::json!({ "status": "suspended" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + assert_eq!(put_shop(&app, &token, &shop_id).await.status(), 404); +} + +#[tokio::test] +#[serial] +async fn repeated_add_and_remove_are_idempotent() { + let app = spawn_app().await; + let (token, _) = register_customer(&app, "fav-idem").await; + let admin = login_admin(&app).await; + let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-idem", 1800).await; + + let first = put_product(&app, &token, &product_id).await; + assert_eq!(first.status(), 200); + let first_id = first.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let second = put_product(&app, &token, &product_id).await; + assert_eq!(second.status(), 200); + let second_body: serde_json::Value = second.json().await.unwrap(); + assert_eq!(second_body["id"], first_id); + assert_eq!( + list(&app, &token, "product", None, None, None).await["total"], + 1 + ); + + assert_eq!(put_shop(&app, &token, &shop_id).await.status(), 200); + assert_eq!(put_shop(&app, &token, &shop_id).await.status(), 200); + assert_eq!( + list(&app, &token, "shop", None, None, None).await["total"], + 1 + ); + + assert_eq!(delete_shop(&app, &token, &shop_id).await.status(), 204); + assert_eq!(delete_shop(&app, &token, &shop_id).await.status(), 204); + assert_eq!( + list(&app, &token, "shop", None, None, None).await["total"], + 0 + ); + assert_eq!( + delete_product(&app, &token, &product_id).await.status(), + 204 + ); +} + +#[tokio::test] +#[serial] +async fn listing_hydrates_filters_and_paginates_visible_targets() { + let app = spawn_app().await; + let (token, _) = register_customer(&app, "fav-list").await; + let admin = login_admin(&app).await; + let (owner, shop_a, product_a) = sellable(&app, &admin, "fav-lista", 500).await; + let (_owner_b, shop_b, product_b) = sellable(&app, &admin, "fav-listb", 1500).await; + let (_owner_c, _shop_c, product_c) = sellable(&app, &admin, "fav-listc", 900).await; + + assert_eq!(put_product(&app, &token, &product_a).await.status(), 200); + assert_eq!(put_product(&app, &token, &product_b).await.status(), 200); + assert_eq!(put_product(&app, &token, &product_c).await.status(), 200); + assert_eq!(put_shop(&app, &token, &shop_a).await.status(), 200); + assert_eq!(put_shop(&app, &token, &shop_b).await.status(), 200); + + let products = list(&app, &token, "product", None, None, None).await; + assert_eq!(products["total"], 3); + let items = products["items"].as_array().unwrap(); + assert_eq!(items.len(), 3); + assert_eq!(items[0]["kind"], "product"); + assert!(items + .iter() + .any(|row| { row["product"]["id"] == product_b && row["product"]["price_minor"] == 1500 })); + + let filtered = list(&app, &token, "product", Some(&product_a), None, None).await; + assert_eq!(filtered["total"], 1); + assert_eq!(filtered["items"][0]["product"]["id"], product_a); + + let shops = list(&app, &token, "shop", None, None, None).await; + assert_eq!(shops["total"], 2); + assert_eq!(shops["items"][0]["kind"], "shop"); + let shop_filter = list(&app, &token, "shop", Some(&shop_b), None, None).await; + assert_eq!(shop_filter["total"], 1); + assert_eq!(shop_filter["items"][0]["shop"]["id"], shop_b); + + let page1 = list(&app, &token, "product", None, Some(1), Some(1)).await; + let page2 = list(&app, &token, "product", None, Some(2), Some(1)).await; + assert_eq!(page1["total"], 3); + assert_eq!(page1["items"].as_array().unwrap().len(), 1); + assert_eq!(page2["items"].as_array().unwrap().len(), 1); + assert_ne!(page1["items"][0]["id"], page2["items"][0]["id"]); + + let res = client() + .post(app.url(&format!("/api/shop/products/{product_a}/unpublish"))) + .bearer_auth(&owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let after = list(&app, &token, "product", None, None, None).await; + assert_eq!(after["total"], 2); + assert!(after["items"] + .as_array() + .unwrap() + .iter() + .all(|row| row["product"]["id"] != product_a)); + + let res = client() + .put(app.url(&format!("/api/admin/shops/{shop_a}/status"))) + .bearer_auth(&admin) + .json(&serde_json::json!({ "status": "suspended" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let shops_after = list(&app, &token, "shop", None, None, None).await; + assert_eq!(shops_after["total"], 1); + assert_eq!(shops_after["items"][0]["shop"]["id"], shop_b); +} + +#[tokio::test] +#[serial] +async fn non_customers_cannot_use_favorite_routes() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let res = client() + .get(app.url("/api/favorites")) + .query(&[("kind", "product")]) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403); +} + +#[tokio::test] +#[serial] +async fn add_holds_target_visible_until_favorite_commits() { + let app = spawn_app().await; + let (token, _) = register_customer(&app, "fav-atomic").await; + let admin = login_admin(&app).await; + let (_owner, shop_id, _product_id) = sellable(&app, &admin, "fav-atomic", 1000).await; + + sqlx::query("DROP TRIGGER IF EXISTS favorites_atomic_delay ON favorites") + .execute(&app.db) + .await + .unwrap(); + sqlx::query("DROP FUNCTION IF EXISTS favorites_atomic_delay()") + .execute(&app.db) + .await + .unwrap(); + sqlx::query( + "CREATE FUNCTION favorites_atomic_delay() RETURNS trigger AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(915001); + RETURN NEW; + END + $$ LANGUAGE plpgsql", + ) + .execute(&app.db) + .await + .unwrap(); + sqlx::query( + "CREATE TRIGGER favorites_atomic_delay BEFORE INSERT ON favorites + FOR EACH ROW EXECUTE FUNCTION favorites_atomic_delay()", + ) + .execute(&app.db) + .await + .unwrap(); + + let mut lock_conn = app.db.acquire().await.unwrap(); + sqlx::query("SELECT pg_advisory_lock(915001)") + .execute(&mut *lock_conn) + .await + .unwrap(); + + let base = app.base.clone(); + let add_token = token.clone(); + let add_shop_id = shop_id.clone(); + let add = tokio::spawn(async move { + client() + .put(format!("{base}/api/favorites/shops/{add_shop_id}")) + .bearer_auth(add_token) + .send() + .await + .unwrap() + }); + + let insert_waiting = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM pg_stat_activity + WHERE wait_event_type = 'Lock' + AND query LIKE 'INSERT INTO favorites%' + )", + ) + .fetch_one(&app.db) + .await + .unwrap(); + if waiting { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .is_ok(); + + let suspend_base = app.base.clone(); + let suspend_admin = admin.clone(); + let suspend_shop_id = shop_id.clone(); + let mut suspend = tokio::spawn(async move { + client() + .put(format!( + "{suspend_base}/api/admin/shops/{suspend_shop_id}/status" + )) + .bearer_auth(suspend_admin) + .json(&serde_json::json!({ "status": "suspended" })) + .send() + .await + .unwrap() + }); + let status_changed_before_insert = + tokio::time::timeout(Duration::from_millis(150), &mut suspend) + .await + .is_ok(); + + sqlx::query("SELECT pg_advisory_unlock(915001)") + .execute(&mut *lock_conn) + .await + .unwrap(); + let add_response = add.await.unwrap(); + let suspend_response = if status_changed_before_insert { + None + } else { + Some(suspend.await.unwrap()) + }; + + sqlx::query("DROP TRIGGER favorites_atomic_delay ON favorites") + .execute(&app.db) + .await + .unwrap(); + sqlx::query("DROP FUNCTION favorites_atomic_delay()") + .execute(&app.db) + .await + .unwrap(); + + assert!(insert_waiting, "favorite insert never reached the trigger"); + assert!( + !status_changed_before_insert, + "shop status changed while favorite creation was in flight" + ); + assert_eq!( + add_response.status(), + 200, + "add: {:?}", + add_response.text().await + ); + assert_eq!(suspend_response.unwrap().status(), 200); +} diff --git a/apps/mall/composables/useSignInRedirect.ts b/apps/mall/composables/useSignInRedirect.ts new file mode 100644 index 0000000..6ad7b2e --- /dev/null +++ b/apps/mall/composables/useSignInRedirect.ts @@ -0,0 +1,10 @@ +import { signInPath } from "~/utils/auth"; + +/** Send a signed-out shopper to sign in and back to the page they were on. */ +export function useSignInRedirect() { + const route = useRoute(); + const router = useRouter(); + return async function signInThenReturn(): Promise { + await router.push(signInPath(route.fullPath)); + }; +} diff --git a/apps/mall/locales/product.ts b/apps/mall/locales/product.ts index 9427952..f3b080b 100644 --- a/apps/mall/locales/product.ts +++ b/apps/mall/locales/product.ts @@ -24,6 +24,8 @@ export default { addedCart: "Added to cart", favorite: "Favorite", unfavorite: "Unfavorite", + favoriteFailed: "Unable to update favorite", + favoriteLoadFailed: "Unable to load favorite state", noStock: "Out of stock", unavailable: "This combination is unavailable", store: "Store", @@ -72,6 +74,8 @@ export default { addedCart: "已加入购物车", favorite: "收藏", unfavorite: "取消收藏", + favoriteFailed: "收藏更新失败", + favoriteLoadFailed: "无法加载收藏状态", noStock: "暂时缺货", unavailable: "该规格暂不可用", store: "店铺", diff --git a/apps/mall/locales/stores.ts b/apps/mall/locales/stores.ts index 6bcd2cc..b5abc05 100644 --- a/apps/mall/locales/stores.ts +++ b/apps/mall/locales/stores.ts @@ -18,6 +18,10 @@ export default { speed: "Delivery", favorite: "Follow store", favorited: "Following", + favoriteFailed: "Unable to update store favorite", + favoriteLoadFailed: "Unable to load store favorite", + notice: "Store notice", + afterSale: "After-sales policy", salesRank: "Top sellers", storeProducts: "Store products", sortPrice: "Price", @@ -47,6 +51,10 @@ export default { speed: "发货速度", favorite: "收藏店铺", favorited: "已收藏", + favoriteFailed: "店铺收藏更新失败", + favoriteLoadFailed: "无法加载店铺收藏", + notice: "店铺公告", + afterSale: "售后服务", salesRank: "本店销量排行", storeProducts: "店内商品", sortPrice: "价格", diff --git a/apps/mall/locales/user.ts b/apps/mall/locales/user.ts index 3fea389..0f41eac 100644 --- a/apps/mall/locales/user.ts +++ b/apps/mall/locales/user.ts @@ -77,6 +77,8 @@ export default { removeFavorite: "Remove favorite", enterStore: "Enter store", noFavoriteStores: "No favorite stores yet.", + favoriteLoadFailed: "Unable to load favorites", + favoriteRemoveFailed: "Unable to remove favorite", couponsTitle: "My coupons", couponTitle: "Coupon", amount: "Amount", @@ -174,6 +176,8 @@ export default { removeFavorite: "取消收藏", enterStore: "进入店铺", noFavoriteStores: "暂无关注店铺。", + favoriteLoadFailed: "收藏加载失败", + favoriteRemoveFailed: "取消收藏失败", couponsTitle: "我的优惠券", couponTitle: "优惠券", amount: "优惠金额", diff --git a/apps/mall/middleware/auth.ts b/apps/mall/middleware/auth.ts index 9c0c26c..03e3349 100644 --- a/apps/mall/middleware/auth.ts +++ b/apps/mall/middleware/auth.ts @@ -1,10 +1,15 @@ -export default defineNuxtRouteMiddleware(async () => { +import { signInPath } from "~/utils/auth"; + +export default defineNuxtRouteMiddleware(async (to) => { if (import.meta.server) return; + // The requested page, not the current one, is where the shopper should land + // after signing in — deep links into the buyer centre must survive. + const signIn = signInPath(to.fullPath); const session = useSessionStore(); if (!session.token) session.hydrate(); - if (!session.token) return navigateTo("/login"); + if (!session.token) return navigateTo(signIn); // Trust the auth API's answer rather than whatever localStorage claims; a // rejected token clears the session inside validate(). - if (!(await session.validate())) return navigateTo("/login"); - if (session.user?.role !== "customer") return navigateTo("/login"); + if (!(await session.validate())) return navigateTo(signIn); + if (session.user?.role !== "customer") return navigateTo(signIn); }); diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index 9ace0c8..e911cbb 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -15,6 +15,9 @@ import type { Coupon, CouponTemplate, CouponTemplateInput, + Favorite, + FavoriteListQuery, + FavoriteProductSummary, FlashSaleItem, FlashSaleItemInput, FlashSaleSession, @@ -45,6 +48,7 @@ import { MOCK_CATEGORIES, MOCK_COUPONS, MOCK_CURRENCIES, + MOCK_FAVORITES, INTEGRAL_PRODUCTS, MOCK_PROMOS, MOCK_QUICK_LINKS, @@ -71,19 +75,26 @@ interface MockState { addresses: AddressBookEntry[]; /** In-memory only: claims made during this browser session. */ coupons: Coupon[]; + /** Persisted customer favorites for fixed-adapter reload parity. */ + favorites: Favorite[]; /** In-memory points catalog and redemptions for the fixed-data path. */ pointsProducts: IntegralProduct[]; redemptions: IntegralOrder[]; addressSeq: number; + favoriteSeq: number; orderSeq: number; invoiceSeq: number; redemptionSeq: number; } -// v3: address book joined the persisted state. -const STORAGE_KEY = "vmall.mock.state.v3"; +// v4: customer favorites joined the persisted rollback state. +const STORAGE_KEY = "vmall.mock.state.v4"; -type PersistedState = Pick; +type PersistedState = Pick< + MockState, + "cart" | "orders" | "shipments" | "invoices" | "addresses" | "favorites" | + "orderSeq" | "invoiceSeq" | "addressSeq" | "favoriteSeq" +>; // Load cart/order session state persisted by a previous page load (client only). function loadPersisted(): PersistedState | null { @@ -98,6 +109,7 @@ function loadPersisted(): PersistedState | null { if (!Array.isArray(p.shipments) || !Array.isArray(p.invoices)) return null; if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null; if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null; + if (!Array.isArray(p.favorites) || typeof p.favoriteSeq !== "number") return null; return p as PersistedState; } catch { return null; @@ -145,6 +157,64 @@ function seedCoupons(): Coupon[] { return MOCK_COUPONS.map(mockOwnedCoupon); } +function clampPage(page?: number): number { + return Math.max(1, page ?? 1); +} + +function clampPerPage(perPage?: number): number { + return Math.min(100, Math.max(1, perPage ?? 20)); +} + +function productSummary(product: Product): FavoriteProductSummary { + const sku = lowestSku(product); + return { + id: product.id, + shop_id: product.shop_id, + slug: product.slug, + name: product.name, + image: product.images[0] ?? null, + price_minor: sku?.price_minor ?? null, + currency: sku?.currency ?? null, + }; +} + +function hydrateProductFavorite(id: string, createdAt: string, product: Product): Favorite { + return { + kind: "product", + id, + user_id: MOCK_USER.id, + created_at: createdAt, + product: productSummary(product), + }; +} + +function hydrateShopFavorite(id: string, createdAt: string, store: MockStoreRecord): Favorite { + return { + kind: "shop", + id, + user_id: MOCK_USER.id, + created_at: createdAt, + shop: toShopProfile(store), + }; +} + +function seedFavorites(): Favorite[] { + const rows: Favorite[] = []; + for (const item of MOCK_FAVORITES) { + const created = `${item.createdAt}T09:00:00.000Z`; + if (item.kind === "product") { + const product = productById(item.refId); + if (product && product.status === "published") { + rows.push(hydrateProductFavorite(item.id, created, product)); + } + } else { + const store = storeById(item.refId); + if (store) rows.push(hydrateShopFavorite(item.id, created, store)); + } + } + return rows; +} + function seedPointsProducts(): IntegralProduct[] { return INTEGRAL_PRODUCTS.map((p) => ({ id: p.id, @@ -277,9 +347,11 @@ function initialState(): MockState { invoices: seed.invoices, addresses: seededAddresses, coupons: seedCoupons(), + favorites: seedFavorites(), pointsProducts: seedPointsProducts(), redemptions: [], addressSeq: 100, + favoriteSeq: 200, orderSeq: 100, invoiceSeq: 100, redemptionSeq: 0, @@ -335,6 +407,8 @@ export function createMockApi(): ApiClient { invoiceSeq: state.invoiceSeq, addresses: state.addresses, addressSeq: state.addressSeq, + favorites: state.favorites, + favoriteSeq: state.favoriteSeq, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch { @@ -722,6 +796,83 @@ export function createMockApi(): ApiClient { return Promise.resolve({ ...entry }); }, + listFavorites: (q: FavoriteListQuery) => { + const page = clampPage(q.page); + const perPage = clampPerPage(q.per_page); + const visible = state.favorites + .map((row) => { + if (row.kind === "product") { + const product = productById(row.product.id); + if (!product || product.status !== "published") return null; + return hydrateProductFavorite(row.id, row.created_at, product); + } + const store = storeById(row.shop.id); + if (!store) return null; + return hydrateShopFavorite(row.id, row.created_at, store); + }) + .filter((row): row is Favorite => row !== null) + .filter((row) => row.kind === q.kind) + .filter((row) => { + if (!q.target_id) return true; + return row.kind === "product" ? row.product.id === q.target_id : row.shop.id === q.target_id; + }) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + const total = visible.length; + const start = (page - 1) * perPage; + return Promise.resolve({ + items: visible.slice(start, start + perPage), + total, + page, + per_page: perPage, + }); + }, + + addProductFavorite: (productId: string) => { + const product = productById(productId); + if (!product || product.status !== "published") { + return Promise.reject(new ApiError(404, "NOT_FOUND", "product")); + } + const existing = state.favorites.find( + (row) => row.kind === "product" && row.product.id === productId, + ); + if (existing) { + return Promise.resolve(hydrateProductFavorite(existing.id, existing.created_at, product)); + } + state.favoriteSeq += 1; + const row = hydrateProductFavorite(`f-${state.favoriteSeq}`, new Date().toISOString(), product); + state.favorites.push(row); + persist(); + return Promise.resolve(row); + }, + + removeProductFavorite: (productId: string) => { + state.favorites = state.favorites.filter( + (row) => !(row.kind === "product" && row.product.id === productId), + ); + persist(); + return Promise.resolve(); + }, + + addShopFavorite: (shopId: string) => { + const store = storeById(shopId); + if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "shop")); + const existing = state.favorites.find((row) => row.kind === "shop" && row.shop.id === shopId); + if (existing) { + return Promise.resolve(hydrateShopFavorite(existing.id, existing.created_at, store)); + } + state.favoriteSeq += 1; + const row = hydrateShopFavorite(`f-${state.favoriteSeq}`, new Date().toISOString(), store); + state.favorites.push(row); + persist(); + return Promise.resolve(row); + }, + + removeShopFavorite: (shopId: string) => { + state.favorites = state.favorites.filter((row) => !(row.kind === "shop" && row.shop.id === shopId)); + persist(); + return Promise.resolve(); + }, + listShopCouponTemplates: (shopId: string) => Promise.resolve(MOCK_COUPONS.map((t) => mockTemplate(t, shopId))), diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index 33f8b7f..6d70c02 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -9,7 +9,7 @@ export default defineNuxtConfig({ // Domains served by the live backend; every other domain stays on the // fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'. // See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3. - liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales", "groupBuying"], + liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales", "groupBuying", "favorites"], appName: "mall", }, }, diff --git a/apps/mall/pages/collective.vue b/apps/mall/pages/collective.vue index de79425..a6c1cd8 100644 --- a/apps/mall/pages/collective.vue +++ b/apps/mall/pages/collective.vue @@ -7,6 +7,7 @@ const { $api } = useNuxtApp(); const session = useSessionStore(); const cart = useCartStore(); const router = useRouter(); +const signInThenReturn = useSignInRedirect(); const activities = ref([]); const loading = ref(true); const error = ref(""); @@ -42,7 +43,7 @@ function joined(activity: GroupBuyingActivityView, groupId: string): string { // intent; the server prices and validates it at checkout. async function start(activity: GroupBuyingActivityView, open: boolean): Promise { if (!session.isLoggedIn) { - await navigateTo("/login"); + await signInThenReturn(); return; } error.value = ""; diff --git a/apps/mall/pages/goods/[id].vue b/apps/mall/pages/goods/[id].vue index e1385af..a28b1b6 100644 --- a/apps/mall/pages/goods/[id].vue +++ b/apps/mall/pages/goods/[id].vue @@ -14,6 +14,7 @@ const { locale, t } = useI18n(); const { $api } = useNuxtApp(); const cart = useCartStore(); const session = useSessionStore(); +const signInThenReturn = useSignInRedirect(); const routeId = computed(() => { const value = route.params.id; @@ -72,7 +73,7 @@ const claimedIds = ref>(new Set()); async function claim(coupon: CouponTemplate): Promise { if (!session.isLoggedIn) { - await navigateTo("/login"); + await signInThenReturn(); return; } claimingId.value = coupon.id; @@ -92,6 +93,10 @@ const selectedAttributes = reactive>({}); const quantity = ref(1); const galleryIndex = ref(0); const favorite = ref(false); +const favoriteLoading = ref(false); +const favoriteBusy = ref(false); +const favoriteError = ref(""); +let favoriteRequestVersion = 0; const cartSuccess = ref(false); const activeTab = ref("detail"); @@ -116,7 +121,6 @@ watch( if (initial) Object.assign(selectedAttributes, initial.attributes); quantity.value = 1; galleryIndex.value = 0; - favorite.value = false; cartSuccess.value = false; activeTab.value = "detail"; }, @@ -176,7 +180,7 @@ const addToCart = async (): Promise => { } catch (error) { // A live cart needs a token; send a signed-out shopper to sign in and back. if (error instanceof ApiError && error.status === 401) { - await router.push(`/login?redirect=${encodeURIComponent(route.fullPath)}`); + await signInThenReturn(); return false; } throw error; @@ -198,6 +202,66 @@ const addCart = async (): Promise => { } }; +watch( + () => [product.value?.id, session.isLoggedIn] as const, + async ([id, loggedIn]) => { + const version = ++favoriteRequestVersion; + favorite.value = false; + favoriteLoading.value = Boolean(id && loggedIn); + favoriteBusy.value = false; + favoriteError.value = ""; + if (!id || !loggedIn) return; + try { + const page = await $api.listFavorites({ kind: "product", target_id: id, per_page: 1 }); + if (version === favoriteRequestVersion && product.value?.id === id) { + favorite.value = page.items.length > 0; + } + } catch { + if (version === favoriteRequestVersion && product.value?.id === id) { + favoriteError.value = t("product.favoriteLoadFailed"); + } + } finally { + if (version === favoriteRequestVersion) favoriteLoading.value = false; + } + }, + { immediate: true }, +); + +const toggleFavorite = async (): Promise => { + const current = product.value; + if (!current || favoriteLoading.value || favoriteBusy.value) return; + if (!session.isLoggedIn) { + await signInThenReturn(); + return; + } + const version = ++favoriteRequestVersion; + favoriteBusy.value = true; + favoriteError.value = ""; + try { + if (favorite.value) { + await $api.removeProductFavorite(current.id); + if (version === favoriteRequestVersion && product.value?.id === current.id) { + favorite.value = false; + } + } else { + await $api.addProductFavorite(current.id); + if (version === favoriteRequestVersion && product.value?.id === current.id) { + favorite.value = true; + } + } + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + await signInThenReturn(); + return; + } + if (version === favoriteRequestVersion && product.value?.id === current.id) { + favoriteError.value = t("product.favoriteFailed"); + } + } finally { + if (version === favoriteRequestVersion) favoriteBusy.value = false; + } +}; + // No reviews tab: there is no reviews capability, and the mall will not present // invented reviewers and ratings as fact. See the wave-6 design. const tabs = computed(() => [ @@ -256,10 +320,12 @@ const detailImages = computed(() => product.value?.images ?? []); type="button" class="favorite" :class="{ active: favorite }" + :disabled="favoriteLoading || favoriteBusy" :aria-label="favorite ? t('product.unfavorite') : t('product.favorite')" - @click="favorite = !favorite" + @click="toggleFavorite" >{{ favorite ? "♥" : "♡" }} +

{{ favoriteError }}

{{ t("product.currentPrice") }} @@ -489,9 +555,14 @@ h1 { padding: 13px 0; } .summary-meta .soldout, -.stock-warning { +.stock-warning, +.favorite-error { color: var(--mall-red); } +.favorite-error { + margin: 6px 0 0; + font-size: 12px; +} .coupon-row, .attribute-row, .quantity-row { diff --git a/apps/mall/pages/integral.vue b/apps/mall/pages/integral.vue index 15115d7..0bf2ef7 100644 --- a/apps/mall/pages/integral.vue +++ b/apps/mall/pages/integral.vue @@ -11,6 +11,7 @@ import { ApiError, t as pick } from "@vmall/shared"; const { locale, t } = useI18n(); const { $api } = useNuxtApp(); const session = useSessionStore(); +const signInThenReturn = useSignInRedirect(); const stats = ref(null); const products = ref([]); const redemptions = ref([]); @@ -85,7 +86,7 @@ function addressForRedemption(): Address | null { async function redeem(product: IntegralProduct): Promise { if (!session.isLoggedIn) { - await navigateTo("/login"); + await signInThenReturn(); return; } const address = addressForRedemption(); diff --git a/apps/mall/pages/stores/[id].vue b/apps/mall/pages/stores/[id].vue index 9db3f36..ae979af 100644 --- a/apps/mall/pages/stores/[id].vue +++ b/apps/mall/pages/stores/[id].vue @@ -1,11 +1,15 @@