feat: consume MinIO presigned media URLs (feature/minio-integration)
icon/image/logo/cover_image are now MinIO object keys, not URLs -- every serializer with one of these fields now returns a <field>_url sibling that's an already-absolute, freshly presigned URL. Added those fields to every affected type (StoreCategory, StoreListItem, ProductCategory, ProductListItem) and switched every <img> call site to use the _url field directly. Deleted resolveMediaUrl/resolveClientMediaUrl and the NEXT_PUBLIC_WINOFY_MEDIA_ORIGIN env var -- they existed only to turn relative paths under the Winofy origin into absolute URLs, which no longer applies now that media lives on a different host (MinIO) and comes back pre-resolved. Also fixes cart-item-row.tsx's product thumbnail, which had been commented out (broken under the old relative-path assumption). Restored image_url as a required non-optional field per the doc rather than treating it as possibly-missing, since every serializer covered by this migration always includes it (null when no image, not omitted).
This commit is contained in:
parent
6f207753be
commit
e5b00c4d55
8 changed files with 29 additions and 37 deletions
|
|
@ -1,7 +1,6 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { winofyFetch, ApiError } from "@/lib/api/winofy";
|
||||
import { resolveMediaUrl } from "@/lib/api/media";
|
||||
import type { ProductDetail } from "@/types/api";
|
||||
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
|
||||
|
||||
|
|
@ -28,9 +27,9 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
|
|||
</Link>
|
||||
|
||||
<div className="aspect-square w-full overflow-hidden rounded-2xl bg-muted">
|
||||
{product.image && (
|
||||
{product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={resolveMediaUrl(product.image) ?? undefined} alt={product.name} className="h-full w-full object-cover" />
|
||||
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { winofyFetch, ApiError, type PaginatedResponse } from "@/lib/api/winofy";
|
||||
import { resolveMediaUrl } from "@/lib/api/media";
|
||||
import type { ProductListItem, StoreDetail } from "@/types/api";
|
||||
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
|
||||
|
||||
|
|
@ -35,17 +34,17 @@ export default async function StoreDetailPage({ params }: PageProps<"/stores/[uu
|
|||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<div className="overflow-hidden rounded-2xl border border-border">
|
||||
<div className="h-32 w-full bg-muted">
|
||||
{store.cover_image && (
|
||||
{store.cover_image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={resolveMediaUrl(store.cover_image) ?? undefined} alt="" className="h-full w-full object-cover" />
|
||||
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{store.logo ? (
|
||||
{store.logo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveMediaUrl(store.logo) ?? undefined}
|
||||
src={store.logo_url}
|
||||
alt={store.name}
|
||||
className="h-16 w-16 shrink-0 rounded-full border border-border object-cover"
|
||||
/>
|
||||
|
|
@ -109,10 +108,10 @@ export default async function StoreDetailPage({ params }: PageProps<"/stores/[uu
|
|||
<article key={product.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-3">
|
||||
<Link href={`/products/${product.uuid}`} className="flex flex-col gap-2">
|
||||
<div className="aspect-square w-full overflow-hidden rounded-xl bg-muted">
|
||||
{product.image && (
|
||||
{product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveMediaUrl(product.image) ?? undefined}
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateCartItemQuantity, removeCartItem } from "@/lib/actions/cart";
|
||||
import { resolveClientMediaUrl } from "@/lib/api/media-client";
|
||||
import type { CartItem } from "@/types/api";
|
||||
|
||||
export function CartItemRow({ item }: { item: CartItem }) {
|
||||
|
|
@ -24,9 +23,9 @@ export function CartItemRow({ item }: { item: CartItem }) {
|
|||
return (
|
||||
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
|
||||
{item.product.image && (
|
||||
{item.product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={resolveClientMediaUrl(item.product.image) ?? undefined} alt="" className="h-full w-full object-cover" />
|
||||
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ export function SiteHeader({
|
|||
return (
|
||||
<header className="flex w-full items-center justify-between px-4 py-3">
|
||||
<Link href="/location/search" className="flex items-center gap-1 text-body-medium text-foreground">
|
||||
<ChevronLeft className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-semibold">{locationLabel ?? "انتخاب محله"}</span>
|
||||
<MapPin className="text-primary-action" />
|
||||
<span className="font-semibold">{locationLabel ?? "انتخاب محله"}</span>
|
||||
<ChevronLeft className="h-4 w-4 text-gray-600" />
|
||||
</Link>
|
||||
|
||||
{isAuthenticated ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import Link from "next/link";
|
||||
import { resolveMediaUrl } from "@/lib/api/media";
|
||||
import type { StoreListItem } from "@/types/api";
|
||||
|
||||
/** winofyfrontenddoc (1).md §3.5 store-card. */
|
||||
|
|
@ -10,9 +9,9 @@ export function StoreCard({ store }: { store: StoreListItem }) {
|
|||
className="flex items-center gap-3 rounded-xl border border-gray-300 bg-white p-3 shadow-sm"
|
||||
>
|
||||
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded-[10px] border border-gray-300 bg-muted">
|
||||
{store.logo && (
|
||||
{store.logo_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={resolveMediaUrl(store.logo) ?? undefined} alt={store.name} className="h-full w-full object-cover" />
|
||||
<img src={store.logo_url} alt={store.name} className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
const MEDIA_ORIGIN = process.env.NEXT_PUBLIC_WINOFY_MEDIA_ORIGIN!;
|
||||
|
||||
/** Client-safe counterpart to lib/api/media.ts's resolveMediaUrl. */
|
||||
export function resolveClientMediaUrl(path: string | null): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
return MEDIA_ORIGIN + (path.startsWith("/") ? path : `/${path}`);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import "server-only";
|
||||
|
||||
const API_ORIGIN = new URL(process.env.WINOFY_API_BASE_URL!).origin;
|
||||
|
||||
/** Store/product image fields are `null` or an absolute/relative media URL — see FRONTEND_GUIDE.md §4.5. */
|
||||
export function resolveMediaUrl(path: string | null): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
return API_ORIGIN + (path.startsWith("/") ? path : `/${path}`);
|
||||
}
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
/** Shared API types mirrored from FRONTEND_GUIDE.md. Money fields are integer Toman. */
|
||||
/**
|
||||
* Shared API types mirrored from FRONTEND_GUIDE.md. Money fields are integer Toman.
|
||||
*
|
||||
* Media fields (`icon`, `image`, `logo`, `cover_image`) are MinIO object keys, not
|
||||
* URLs — per FRONTEND_GUIDE.md §4.7 (feature/minio-integration), every one of them
|
||||
* has a `<field>_url` sibling that's a freshly presigned, already-absolute URL.
|
||||
* Always render the `_url` field directly; never resolve the raw key yourself, and
|
||||
* don't cache `_url` past the response — it's re-signed (and can change) on every read.
|
||||
*/
|
||||
|
||||
export interface City {
|
||||
uuid: string;
|
||||
|
|
@ -17,6 +25,7 @@ export interface StoreCategory {
|
|||
uuid: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
icon_url: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +34,9 @@ export interface StoreListItem {
|
|||
name: string;
|
||||
category: StoreCategory;
|
||||
logo: string | null;
|
||||
logo_url: string | null;
|
||||
cover_image: string | null;
|
||||
cover_image_url: string | null;
|
||||
/** DRF serializes DecimalField as a string, e.g. "3.76" — confirmed against staging. */
|
||||
rating_avg: string;
|
||||
rating_count: number;
|
||||
|
|
@ -58,6 +69,8 @@ export type UnitType = "gram" | "ml" | "piece";
|
|||
export interface ProductCategory {
|
||||
uuid: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
icon_url: string | null;
|
||||
parent: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +78,7 @@ export interface ProductListItem {
|
|||
uuid: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
image_url: string | null;
|
||||
price: number;
|
||||
unit_type: UnitType;
|
||||
unit_value: number;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue