perf: logger + store page modification #4

Merged
mohammadali merged 1 commit from feature/WNPT-734 into develop 2026-09-01 02:53:22 -04:00
13 changed files with 289 additions and 188 deletions

View file

@ -2,6 +2,7 @@ import Link from "next/link";
import { getCart } from "@/lib/api/cart";
import { requireSession } from "@/lib/auth/session";
import { CartItemRow } from "@/components/customer/cart-item-row";
import { ClearCartButton } from "@/components/customer/clear-cart-button";
import { Button } from "@/components/ui/button";
export default async function CartPage() {
@ -22,7 +23,10 @@ export default async function CartPage() {
return (
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold">سبد خرید</h1>
<ClearCartButton />
</div>
{cart.groups.map((group) => (
<section key={group.store.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-4">

View file

@ -25,45 +25,70 @@ export default async function StoreDetailPage({
});
if (!store) notFound();
const [products, session] = await Promise.all([getProducts({ store: uuid, search, limit: 100 }), getValidSession()]);
const [products, session] = await Promise.all([
getProducts({ store: uuid, search, limit: 100 }),
getValidSession(),
]);
const cart = session ? await getCart() : null;
const storeGroup = cart?.groups.find((g) => g.store.uuid === uuid) ?? null;
const cartItemByProduct = new Map<string, CartItem>();
storeGroup?.items.forEach((item) => cartItemByProduct.set(item.product.uuid, item));
storeGroup?.items.forEach((item) =>
cartItemByProduct.set(item.product.uuid, item),
);
return (
<main className="flex min-h-full flex-1 flex-col bg-page-bg">
<div className="relative h-[257px] w-full shrink-0 overflow-hidden rounded-b-3xl bg-muted">
<div className="relative h-64.25 w-full shrink-0 overflow-hidden rounded-b-2xl bg-muted">
{store.cover_image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
<img
src={store.cover_image_url}
alt=""
className="h-full w-full object-cover"
/>
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
<HeroBackButton href="/" />
</div>
<div className="w-full h-auto flex flex-col items-center">
<StoreInfoCard store={store} />
</div>
<section className="px-4 pt-2">
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">توضیحات</h2>
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">{store.description}</p>
<section className="p-6">
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">
توضیحات
</h2>
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">
{store.description}
</p>
</section>
<div className="pt-4">
<div className="pb-6">
<StoreSearchBar storeUuid={uuid} defaultValue={search} />
</div>
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-[120px]">
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-30">
{products.map((product) => (
<ProductGridCard key={product.uuid} product={product} cartItem={cartItemByProduct.get(product.uuid) ?? null} />
<ProductGridCard
key={product.uuid}
product={product}
cartItem={cartItemByProduct.get(product.uuid) ?? null}
/>
))}
{products.length === 0 && (
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">محصولی یافت نشد.</p>
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">
محصولی یافت نشد.
</p>
)}
</section>
{storeGroup && <StickyCartBar itemCount={storeGroup.items.length} total={storeGroup.total} />}
{storeGroup && (
<StickyCartBar
itemCount={storeGroup.items.length}
total={storeGroup.total}
/>
)}
</main>
);
}

View file

@ -31,7 +31,7 @@ export function RoleCard({
className={cn(
"flex h-12 w-full items-center justify-center rounded-full text-body-large font-semibold transition-colors",
ctaVariant === "primary"
? "bg-primary-action text-white hover:bg-primary-action/90"
? "border-0 bg-primary-action text-white hover:bg-primary-action/90"
: "border border-primary-action bg-white text-primary-action hover:bg-primary-action/5",
)}
>

View file

@ -1,54 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { addToCart } from "@/lib/api/cart";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils/cn";
export function AddToCartButton({
productUuid,
quantity = 1,
disabled,
outOfStock,
className,
}: {
productUuid: string;
quantity?: number;
disabled?: boolean;
outOfStock?: boolean;
className?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [feedback, setFeedback] = useState<"added" | "error" | null>(null);
function handleClick() {
startTransition(async () => {
const result = await addToCart(productUuid, quantity);
if (result.ok) {
setFeedback("added");
router.refresh();
setTimeout(() => setFeedback(null), 1500);
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setFeedback("error");
}
});
}
return (
<Button
type="button"
size="sm"
variant={feedback === "added" ? "secondary" : "primary"}
disabled={disabled || isPending}
onClick={handleClick}
className={cn("w-full", className)}
>
{outOfStock ? "ناموجود" : isPending ? "..." : feedback === "added" ? "افزوده شد ✓" : feedback === "error" ? "خطا، دوباره تلاش کنید" : "افزودن به سبد"}
</Button>
);
}

View file

@ -1,27 +1,40 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
import type { CartItem } from "@/types/api";
export function CartItemRow({ item }: { item: CartItem }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!error) return;
const timeout = setTimeout(() => setError(null), 3000);
return () => clearTimeout(timeout);
}, [error]);
function changeQuantity(nextQuantity: number) {
startTransition(async () => {
if (nextQuantity <= 0) {
await removeCartItem(item.uuid);
} else {
await updateCartItemQuantity(item.uuid, nextQuantity);
}
const result =
nextQuantity <= 0 ? await removeCartItem(item.uuid) : await updateCartItemQuantity(item.uuid, nextQuantity);
if (result.ok) {
router.refresh();
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setError(result.message);
}
});
}
return (
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
<div className="flex flex-col gap-1.5 py-3">
<div className={`flex items-center gap-3 ${isPending ? "opacity-50" : ""}`}>
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
{item.product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
@ -42,7 +55,7 @@ export function CartItemRow({ item }: { item: CartItem }) {
>
−
</button>
<span className="w-5 text-center text-sm">{item.quantity}</span>
<span className="w-5 text-center text-sm">{item.quantity.toLocaleString("fa-IR")}</span>
<button
type="button"
disabled={isPending}
@ -55,5 +68,7 @@ export function CartItemRow({ item }: { item: CartItem }) {
</div>
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
</div>
{error && <p className="text-end text-xs text-error">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,48 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Trash2 } from "lucide-react";
import { clearCart } from "@/lib/api/cart";
export function ClearCartButton() {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!error) return;
const timeout = setTimeout(() => setError(null), 3000);
return () => clearTimeout(timeout);
}, [error]);
function handleClear() {
if (!window.confirm("سبد خرید شما به‌طور کامل خالی می‌شود. ادامه می‌دهید؟")) return;
startTransition(async () => {
const result = await clearCart();
if (result.ok) {
router.refresh();
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setError(result.message);
}
});
}
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={handleClear}
disabled={isPending}
className="flex items-center gap-1.5 text-xs font-medium text-error disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" />
خالی کردن سبد
</button>
{error && <p className="text-xs text-error">{error}</p>}
</div>
);
}

View file

@ -4,11 +4,20 @@ import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { Minus, Plus } from "lucide-react";
import { addToCart, updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
import {
addToCart,
updateCartItemQuantity,
removeCartItem,
} from "@/lib/api/cart";
import type { CartItem, ProductListItem } from "@/types/api";
/** designspec.md §2.5 — product-card, 157×254, with the two-state add/stepper control. */
export function ProductGridCard({ product, cartItem }: { product: ProductListItem; cartItem: CartItem | null }) {
export function ProductGridCard({
product,
cartItem,
}: {
product: ProductListItem;
cartItem: CartItem | null;
}) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
@ -43,12 +52,13 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
startTransition(async () => {
if (nextQuantity <= 0) {
const result = await removeCartItem(localItem.uuid);
// Only clear locally once the backend actually confirms removal — otherwise
// the card would show "add" again while the item still exists server-side.
if (result.ok) setLocalItem(null);
else setError(result.message);
} else {
const result = await updateCartItemQuantity(localItem.uuid, nextQuantity);
const result = await updateCartItemQuantity(
localItem.uuid,
nextQuantity,
);
if (result.ok) setLocalItem(result.data);
else setError(result.message);
}
@ -57,67 +67,83 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
}
return (
<article className="relative flex h-[254px] w-[157px] flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
<article className="relative flex h-63.5 w-39.25 flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
{error && (
<div className="absolute inset-x-1 top-1 z-10 rounded-lg bg-error px-2 py-1 text-center text-[11px] leading-tight text-white">
{error}
</div>
)}
<Link href={`/products/${product.uuid}`} className="block shrink-0">
<div className="relative h-[130px] w-full overflow-hidden rounded-t-xl bg-muted">
<div className="relative h-32.5 w-full overflow-hidden rounded-t-xl bg-muted">
{product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={product.image_url} 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 className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/40 to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-linear-to-t from-black/40 to-transparent" />
</div>
</Link>
<div className="flex flex-1 flex-col justify-between p-3">
<div className="flex flex-col gap-1.5">
<div className="flex flex-1 flex-col gap-1.5 p-3">
<Link href={`/products/${product.uuid}`}>
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">{product.name}</h3>
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">
{product.name}
</h3>
</Link>
<p className="text-right text-body-medium font-extrabold text-primary-action">
{product.price.toLocaleString("fa-IR")} تومان
</p>
{disabled && (
<span className="mt-auto text-center text-label text-gray-500">
ناموجود
</span>
)}
</div>
{disabled ? (
<span className="text-center text-label text-gray-500">ناموجود</span>
) : quantity === 0 ? (
{!disabled && (
<div
className={`motion-reduce:transition-none absolute bottom-3 left-3 overflow-hidden rounded-full border transition-[width,background-color,border-color] duration-300 ease-out ${
quantity === 0
? "h-10 w-10 border-transparent bg-primary-action"
: "h-10 w-28 border-gray-300 bg-white"
}`}
dir="ltr"
>
<div className="relative flex h-full w-full items-center justify-between px-1">
<button
type="button"
onClick={handleAdd}
onClick={
quantity === 0 ? handleAdd : () => handleChange(quantity + 1)
}
disabled={isPending}
aria-label="افزودن به سبد"
className="flex h-8 w-8 items-center justify-center self-start rounded-full bg-primary-action text-white disabled:opacity-40"
aria-label={quantity === 0 ? "افزودن به سبد" : "افزایش تعداد"}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-primary-action p-0 text-white disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
) : (
<div className="flex h-10 w-[110px] items-center justify-between gap-4 rounded-3xl border border-gray-300 bg-white p-1">
<button
type="button"
onClick={() => handleChange(quantity + 1)}
disabled={isPending}
aria-label="افزایش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-action text-white disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
<span className="text-[18px] font-bold leading-none text-foreground">{quantity.toLocaleString("fa-IR")}</span>
{quantity > 0 && (
<>
<span className="flex h-full w-8 shrink-0 items-center justify-center text-center text-[18px] font-bold leading-none text-foreground">
{quantity.toLocaleString("fa-IR")}
</span>
<button
type="button"
onClick={() => handleChange(quantity - 1)}
disabled={isPending}
aria-label="کاهش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-bg text-primary-action disabled:opacity-40"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-blue-bg p-0 text-primary-action disabled:opacity-40"
>
<Minus className="h-4 w-4" />
</button>
</div>
</>
)}
</div>
</div>
)}
</article>
);
}

View file

@ -6,7 +6,9 @@ import type { StoreDetail } from "@/types/api";
function todaysWorkingHours(store: StoreDetail) {
const jsDay = new Date().getDay();
const iranianWeekday = (jsDay + 1) % 7;
return store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null;
return (
store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null
);
}
function formatTime(value: string | null) {
@ -20,35 +22,52 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${store.name} ${store.address}`)}`;
return (
<div className="relative z-10 -mt-6 rounded-t-3xl bg-white px-4 pt-5 pb-4">
<h1 className="text-title-large font-extrabold leading-none text-primary-action">{store.name}</h1>
<p className="mt-1 text-[14px] leading-none text-gray-700">{store.category.name}</p>
<div className="w-[90%] relative z-10 -mt-24 rounded-3xl bg-white px-4 pt-5 pb-4 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.102)]">
<h1 className="text-title-large font-extrabold leading-none text-primary-action">
{store.name}
</h1>
<p className="mt-1 text-[14px] leading-none text-gray-700">
{store.category.name}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{today && (
<span className="rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
{today.is_closed ? "تعطیل" : `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
</span>
)}
<span className="text-[13px] leading-none text-gray-600">|</span>
<span
className={`rounded-full border px-2.5 py-1.5 text-[12px] font-semibold leading-none ${
store.is_open ? "border-emerald-border bg-emerald-bg text-emerald-text-strong" : "border-gray-300 bg-gray-100 text-gray-600"
store.is_open
? "border-emerald-border bg-emerald-bg text-emerald-text-strong"
: "border-gray-300 bg-gray-100 text-gray-600"
}`}
>
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`} />
<span
className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`}
/>
{store.is_open ? "فعال" : "بسته"}
</span>
</span>
<span className="text-[13px] leading-none text-gray-600">|</span>
{today && (
<span className="h-6 flex items-center rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
{today.is_closed
? "تعطیل"
: `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
</span>
)}
</div>
<div className="mt-4 flex items-center gap-2 rounded-[10px] border border-teal-border p-2">
<div className="mt-4 flex items-center gap-2 p-2">
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
<MapPin className="h-4 w-4" />
</span>
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">{store.address}</span>
<a href={mapsUrl} target="_blank" rel="noreferrer" className="shrink-0 text-[13px] font-medium text-teal">
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">
{store.address}
</span>
<a
href={mapsUrl}
target="_blank"
rel="noreferrer"
className="shrink-0 text-[13px] font-medium text-teal"
>
مسیریابی
</a>
</div>
@ -56,7 +75,7 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
{store.phone_number && (
<a
href={`tel:${store.phone_number}`}
className="mt-2 flex items-center gap-2 rounded-[10px] border border-teal-border p-2"
className="mt-2 flex items-center gap-2 p-2"
>
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
<Phone className="h-4 w-4" />

View file

@ -8,10 +8,14 @@ const buttonVariants = cva(
{
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
// Tailwind's preflight doesn't zero out <button>'s default border/padding (only
// font/color/background/border-radius/opacity) — every variant that doesn't set
// its own border must reset it explicitly, or the browser's default UA button
// border/bevel survives underneath and throws off icon/label centering.
primary: "border-0 bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border-0 bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-border bg-transparent hover:bg-muted",
ghost: "bg-transparent hover:bg-muted",
ghost: "border-0 bg-transparent hover:bg-muted",
},
size: {
default: "h-12 px-4 text-body-large",

View file

@ -7,9 +7,9 @@ export function HeroBackButton({ href }: { href: string }) {
<Link
href={href}
aria-label="بازگشت"
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full border border-white bg-white/80 backdrop-blur-md"
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full border border-white bg-white/80 backdrop-blur-lg"
>
<ArrowRight className="h-[19.8px] w-[19.8px] text-white" />
<ArrowRight className="h-[19.8px] w-[19.8px] text-black" />
</Link>
);
}

View file

@ -46,17 +46,19 @@ function indent(text: string): string {
/**
* Dev-only request/response logging — the terminal-equivalent of Chrome's Network
* tab for server-side calls, since they run on the Next.js server and the browser
* never sees them (no CORS on the Winofy API). Never runs in production.
* tab for every server-side call this app makes (Winofy API, Gooyal accounts),
* since those run on the Next.js server and the browser never sees them. Shared
* across every backend this app talks to, labeled by `service` so interleaved
* calls stay easy to tell apart. Never runs in production.
*/
export function logWinofyRequest(method: string, url: string, body: unknown) {
export function logApiRequest(service: string, method: string, url: string, body: unknown) {
if (!DEBUG) return;
console.log(`\n${CYAN}→ [winofy]${RESET} ${BOLD}${method}${RESET} ${url}`);
console.log(`\n${CYAN}→ [${service}]${RESET} ${BOLD}${method}${RESET} ${url}`);
if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`);
}
export function logWinofyResponse(method: string, url: string, status: number, data: unknown) {
export function logApiResponse(service: string, method: string, url: string, status: number, data: unknown) {
if (!DEBUG) return;
console.log(`${statusColor(status)}← [winofy] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
console.log(`${statusColor(status)}← [${service}] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`);
}

View file

@ -1,7 +1,7 @@
import "server-only";
import { getValidSession } from "@/lib/auth/session";
import { ApiError, type WinofyErrorDetails } from "./errors";
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
import { logApiRequest, logApiResponse } from "./dev-logger";
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
@ -41,7 +41,7 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
}
logWinofyRequest(method, url.toString(), body);
logApiRequest("winofy", method, url.toString(), body);
const res = await fetch(url.toString(), {
...rest,
@ -51,12 +51,12 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
});
if (res.status === 204) {
logWinofyResponse(method, url.toString(), res.status, null);
logApiResponse("winofy", method, url.toString(), res.status, null);
return undefined as T;
}
const data = await res.json().catch(() => null);
logWinofyResponse(method, url.toString(), res.status, data);
logApiResponse("winofy", method, url.toString(), res.status, data);
if (!res.ok) {
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;

View file

@ -1,4 +1,5 @@
import "server-only";
import { logApiRequest, logApiResponse } from "@/lib/api/dev-logger";
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
@ -34,11 +35,16 @@ export interface GooyalTokenResponse {
}
export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
const url = `${ACCOUNTS_BASE_URL}/oauth2/token/`;
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
});
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
// Never print real tokens to the terminal — log the request shape, not the secret values.
logApiRequest("gooyal", "POST", url, { grant_type: "refresh_token", refresh_token: "<redacted>" });
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: basicAuthHeader(),
@ -48,6 +54,12 @@ export async function refreshAccessToken(refreshToken: string): Promise<GooyalTo
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
logApiResponse("gooyal", "POST", url, res.status, {
...data,
access_token: data.access_token ? "<redacted>" : undefined,
refresh_token: data.refresh_token ? "<redacted>" : undefined,
});
if (!res.ok) {
throw new GooyalAuthError(res.status, data.error ?? "refresh_failed", data.error_description);
}