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 Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { winofyFetch, ApiError } from "@/lib/api/winofy";
|
import { winofyFetch, ApiError } from "@/lib/api/winofy";
|
||||||
import { resolveMediaUrl } from "@/lib/api/media";
|
|
||||||
import type { ProductDetail } from "@/types/api";
|
import type { ProductDetail } from "@/types/api";
|
||||||
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
|
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
|
||||||
|
|
||||||
|
|
@ -28,9 +27,9 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="aspect-square w-full overflow-hidden rounded-2xl bg-muted">
|
<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
|
// 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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { winofyFetch, ApiError, type PaginatedResponse } from "@/lib/api/winofy";
|
import { winofyFetch, ApiError, type PaginatedResponse } from "@/lib/api/winofy";
|
||||||
import { resolveMediaUrl } from "@/lib/api/media";
|
|
||||||
import type { ProductListItem, StoreDetail } from "@/types/api";
|
import type { ProductListItem, StoreDetail } from "@/types/api";
|
||||||
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
|
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">
|
<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="overflow-hidden rounded-2xl border border-border">
|
||||||
<div className="h-32 w-full bg-muted">
|
<div className="h-32 w-full bg-muted">
|
||||||
{store.cover_image && (
|
{store.cover_image_url && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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>
|
||||||
<div className="flex flex-col gap-3 p-4">
|
<div className="flex flex-col gap-3 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{store.logo ? (
|
{store.logo_url ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={resolveMediaUrl(store.logo) ?? undefined}
|
src={store.logo_url}
|
||||||
alt={store.name}
|
alt={store.name}
|
||||||
className="h-16 w-16 shrink-0 rounded-full border border-border object-cover"
|
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">
|
<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">
|
<Link href={`/products/${product.uuid}`} className="flex flex-col gap-2">
|
||||||
<div className="aspect-square w-full overflow-hidden rounded-xl bg-muted">
|
<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
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={resolveMediaUrl(product.image) ?? undefined}
|
src={product.image_url}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { updateCartItemQuantity, removeCartItem } from "@/lib/actions/cart";
|
import { updateCartItemQuantity, removeCartItem } from "@/lib/actions/cart";
|
||||||
import { resolveClientMediaUrl } from "@/lib/api/media-client";
|
|
||||||
import type { CartItem } from "@/types/api";
|
import type { CartItem } from "@/types/api";
|
||||||
|
|
||||||
export function CartItemRow({ item }: { item: CartItem }) {
|
export function CartItemRow({ item }: { item: CartItem }) {
|
||||||
|
|
@ -24,9 +23,9 @@ export function CartItemRow({ item }: { item: CartItem }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
|
<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">
|
<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
|
// 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>
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ export function SiteHeader({
|
||||||
return (
|
return (
|
||||||
<header className="flex w-full items-center justify-between px-4 py-3">
|
<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">
|
<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" />
|
<MapPin className="text-primary-action" />
|
||||||
|
<span className="font-semibold">{locationLabel ?? "انتخاب محله"}</span>
|
||||||
|
<ChevronLeft className="h-4 w-4 text-gray-600" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { resolveMediaUrl } from "@/lib/api/media";
|
|
||||||
import type { StoreListItem } from "@/types/api";
|
import type { StoreListItem } from "@/types/api";
|
||||||
|
|
||||||
/** winofyfrontenddoc (1).md §3.5 store-card. */
|
/** 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"
|
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">
|
<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
|
// 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>
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
<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 {
|
export interface City {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
|
@ -17,6 +25,7 @@ export interface StoreCategory {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
icon_url: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,7 +34,9 @@ export interface StoreListItem {
|
||||||
name: string;
|
name: string;
|
||||||
category: StoreCategory;
|
category: StoreCategory;
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
|
logo_url: string | null;
|
||||||
cover_image: string | null;
|
cover_image: string | null;
|
||||||
|
cover_image_url: string | null;
|
||||||
/** DRF serializes DecimalField as a string, e.g. "3.76" — confirmed against staging. */
|
/** DRF serializes DecimalField as a string, e.g. "3.76" — confirmed against staging. */
|
||||||
rating_avg: string;
|
rating_avg: string;
|
||||||
rating_count: number;
|
rating_count: number;
|
||||||
|
|
@ -58,6 +69,8 @@ export type UnitType = "gram" | "ml" | "piece";
|
||||||
export interface ProductCategory {
|
export interface ProductCategory {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
icon_url: string | null;
|
||||||
parent: string | null;
|
parent: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +78,7 @@ export interface ProductListItem {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
|
image_url: string | null;
|
||||||
price: number;
|
price: number;
|
||||||
unit_type: UnitType;
|
unit_type: UnitType;
|
||||||
unit_value: number;
|
unit_value: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue