Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireOwnerEmail } from "@/lib/auth";
import { serviceClient } from "@/lib/supabase/server";
import { deleteListingImage } from "@presswork/shared";

export const dynamic = "force-dynamic";

/**
* DELETE /api/listings/[id]/etsy-images/[imageId]
* Removes a single image from the live Etsy listing.
* imageId is the Etsy listing_image_id (numeric).
*/
export async function DELETE(
_req: NextRequest,
ctx: { params: Promise<{ id: string; imageId: string }> }
) {
if (!(await requireOwnerEmail())) {
return new NextResponse("Unauthorized", { status: 401 });
}

const { id, imageId: imageIdStr } = await ctx.params;
const imageId = Number(imageIdStr);
if (!Number.isInteger(imageId) || imageId <= 0) {
return NextResponse.json(
{ error: "imageId must be a positive integer" },
{ status: 400 }
);
}

const db = serviceClient();

const { data: listing, error } = await db
.from("listings")
.select("etsy_listing_id")
.eq("id", id)
.maybeSingle();

if (error || !listing) {
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
}

const etsyListingId = listing.etsy_listing_id as number | null;
if (!etsyListingId) {
return NextResponse.json(
{ error: "Listing has no etsy_listing_id — not yet published to Etsy" },
{ status: 409 }
);
}

try {
await deleteListingImage(db, etsyListingId, imageId);
return NextResponse.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to delete image from Etsy";
return NextResponse.json({ error: message }, { status: 502 });
}
}
154 changes: 154 additions & 0 deletions packages/dashboard/app/api/listings/[id]/etsy-images/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireOwnerEmail } from "@/lib/auth";
import { serviceClient } from "@/lib/supabase/server";
import {
getListingImages,
uploadListingImage,
getListingImageCount,
} from "@presswork/shared";

export const dynamic = "force-dynamic";

/** GET /api/listings/[id]/etsy-images — returns the images currently live on the Etsy listing. */
export async function GET(
_req: NextRequest,
ctx: { params: Promise<{ id: string }> }
) {
if (!(await requireOwnerEmail())) {
return new NextResponse("Unauthorized", { status: 401 });
}

const { id } = await ctx.params;
const db = serviceClient();

const { data: listing, error } = await db
.from("listings")
.select("etsy_listing_id")
.eq("id", id)
.maybeSingle();

if (error || !listing) {
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
}

const etsyListingId = listing.etsy_listing_id as number | null;
if (!etsyListingId) {
return NextResponse.json(
{ error: "Listing has no etsy_listing_id — not yet published to Etsy" },
{ status: 409 }
);
}

try {
const images = await getListingImages(db, etsyListingId);
return NextResponse.json({ images });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to fetch Etsy images";
return NextResponse.json({ error: message }, { status: 502 });
}
}

// uploadListingImage fetches imageUrl server-side, so an unconstrained URL is an
// SSRF vector (e.g. http://169.254.169.254/...). Restrict to https on the hosts we
// actually serve listing images from: Supabase Storage (design PNG) and the mockup
// CDNs (Printify, Dynamic Mockups).
const TRUSTED_IMAGE_HOST_SUFFIXES = [
".supabase.co",
".printify.com",
".dynamicmockups.com",
] as const;

function isTrustedImageUrl(raw: string): boolean {
let url: URL;
try {
url = new URL(raw);
} catch {
return false;
}
if (url.protocol !== "https:") return false;
const host = url.hostname.toLowerCase();
return TRUSTED_IMAGE_HOST_SUFFIXES.some(
(suffix) => host === suffix.slice(1) || host.endsWith(suffix)
);
}

const PostBodySchema = z.object({
imageUrl: z
.string()
.url()
.refine(isTrustedImageUrl, {
message:
"imageUrl must be an https URL on a trusted image host (Supabase Storage, Printify, or Dynamic Mockups)",
}),
rank: z.number().int().positive().optional(),
altText: z.string().max(250).optional(),
});

/** POST /api/listings/[id]/etsy-images — adds an image to the live Etsy listing. */
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ id: string }> }
) {
if (!(await requireOwnerEmail())) {
return new NextResponse("Unauthorized", { status: 401 });
}

const { id } = await ctx.params;

let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const parsed = PostBodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request body", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { imageUrl, rank: requestedRank, altText } = parsed.data;

const db = serviceClient();

const { data: listing, error } = await db
.from("listings")
.select("etsy_listing_id, title")
.eq("id", id)
.maybeSingle();

if (error || !listing) {
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
}

const etsyListingId = listing.etsy_listing_id as number | null;
if (!etsyListingId) {
return NextResponse.json(
{ error: "Listing has no etsy_listing_id — not yet published to Etsy" },
{ status: 409 }
);
}

try {
// Default rank to current count + 1 so the new image appends to the end.
const rank =
requestedRank ?? (await getListingImageCount(db, etsyListingId)) + 1;

const resolvedAltText =
altText ??
(listing.title ? `Product photo: ${listing.title}` : undefined);

await uploadListingImage(db, etsyListingId, imageUrl, {
rank,
altText: resolvedAltText,
});

return NextResponse.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to upload image to Etsy";
return NextResponse.json({ error: message }, { status: 502 });
}
}
86 changes: 83 additions & 3 deletions packages/dashboard/app/listings/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SurfaceCard } from "@/components/ui/SurfaceCard";
import { Button } from "@/components/ui/Button";
import { StatusBadge } from "@/components/status/StatusBadge";
import { MockupCarousel } from "@/components/listings/MockupCarousel";
import { EtsyImagePanel } from "@/components/listings/EtsyImagePanel";
import { ComplianceChecks } from "@/components/listings/ComplianceChecks";
import { EtsyPayloadPreview } from "@/components/listings/EtsyPayloadPreview";
import { CopyEditor } from "@/components/listings/CopyEditor";
Expand All @@ -12,6 +13,7 @@ import { DynamicMockupsTrigger } from "@/components/listings/DynamicMockupsTrigg
import { SubmitButton } from "@/components/ui/SubmitButton";
import {
approveListing,
publishListingNow,
pushListingToEtsy,
recreatePrintifyProduct,
regenerateCopy,
Expand All @@ -20,6 +22,7 @@ import {
} from "@/lib/actions/listings";
import { getListing, type ListingWithDesign } from "@/lib/queries/listings";
import { getEtsyPayloadPreview } from "@/lib/queries/etsy-preview";
import { getIsListingAgentRunning } from "@/lib/actions/triggers";
import { formatRelative, formatUsd } from "@/lib/format";

export const dynamic = "force-dynamic";
Expand All @@ -30,9 +33,12 @@ interface Params {

export default async function ListingDetailPage({ params }: Params) {
const { id } = await params;
const listing = await getListing(id);
const [listing, etsyPreview, isAgentRunning] = await Promise.all([
getListing(id),
getEtsyPayloadPreview(id),
getIsListingAgentRunning(),
]);
if (!listing) notFound();
const etsyPreview = await getEtsyPayloadPreview(id);

const etsyHref = listing.etsy_listing_id
? `https://www.etsy.com/listing/${listing.etsy_listing_id}`
Expand Down Expand Up @@ -92,6 +98,15 @@ export default async function ListingDetailPage({ params }: Params) {
</div>
</SurfaceCard>

{listing.status === "active" && listing.etsy_listing_id && (
<EtsyImagePanel
listingId={listing.id}
etsyListingId={listing.etsy_listing_id}
designImageUrl={listing.design_packages?.image_url ?? null}
mockupUrls={listing.design_packages?.mockup_urls ?? null}
/>
)}

<SurfaceCard
title="Copy"
subtitle={
Expand Down Expand Up @@ -219,7 +234,7 @@ export default async function ListingDetailPage({ params }: Params) {
<form action={approveListing}>
<input type="hidden" name="id" value={listing.id} />
<Button type="submit" variant="primary" className="w-full">
Approve & publish
Approve
</Button>
</form>
)}
Expand Down Expand Up @@ -266,6 +281,13 @@ export default async function ListingDetailPage({ params }: Params) {
</details>
</div>
</SurfaceCard>

{listing.status === "pending_publish" && (
<PublishNowCard
listingId={listing.id}
isAgentRunning={isAgentRunning}
/>
)}
</div>
</div>
</div>
Expand Down Expand Up @@ -323,3 +345,61 @@ function PushToEtsyForm({ listing }: { listing: ListingWithDesign }) {
</form>
);
}

/**
* Confirmation card for per-listing publish. Shown only when status is
* 'pending_publish'. Uses the <details> popover pattern from the Reject card
* so no JS is required and the operator sees the cost warning before clicking.
*
* Disabled when the listing agent is already actively publishing (isAgentRunning),
* matching the Run Listing button lock so both the agent and this button can't
* race against the same row at the same time.
*/
function PublishNowCard({
listingId,
isAgentRunning,
}: {
listingId: string;
isAgentRunning: boolean;
}) {
return (
<SurfaceCard
title="Publish to Etsy"
subtitle={
isAgentRunning
? "The listing agent is currently publishing — wait for it to finish before publishing individually."
: "Publish this listing now. Each publish charges the $0.20 Etsy listing fee."
}
>
{isAgentRunning ? (
<p className="text-xs text-(--text-muted)">
Locked while the listing agent is running.
</p>
) : (
<details className="rounded-(--radius-sm) border border-(--surface-line) p-2 text-xs">
<summary className="cursor-pointer text-(--text-secondary)">
Publish now…
</summary>
<form
action={publishListingNow}
className="mt-2 flex flex-col gap-2"
>
<input type="hidden" name="id" value={listingId} />
<p className="text-xs text-(--text-muted)">
This will POST a new Etsy listing draft, upload mockup images, and
activate the listing. Etsy charges a{" "}
<strong className="text-(--text-primary)">$0.20 listing fee</strong>{" "}
per listing.
</p>
<SubmitButton
variant="primary"
size="sm"
idleLabel="Publish to Etsy ($0.20 listing fee)"
pendingLabel="Publishing…"
/>
</form>
</details>
)}
</SurfaceCard>
);
}
2 changes: 1 addition & 1 deletion packages/dashboard/app/listings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default async function ListingsPage() {
{pendingPublish.length > 0 && (
<SurfaceCard
title={`Pending publish — ${pendingPublish.length}`}
subtitle="Approved · queued for Etsy. Run the listing agent to push, or open a row to push manually. Back up returns to review."
subtitle="Approved · queued for Etsy. Open a row to publish individually ($0.20/listing), or run the listing agent to publish all. Back up returns to review."
>
<div className="flex flex-col gap-2">
{pendingPublish.map((l) => (
Expand Down
Loading
Loading