Eight Features in One Commit
Linesheet deployed 8 new features in a single 2,839-line commit across 30 files. Two waves: buyer-facing (wave 1) and seller operations (wave 2).
Wave 1 — Buyer Experience (ships-now filter, sustainability metadata, order confirmation emails, multi-currency display): Improves the product showcase experience for wholesale buyers, with clearer shipping timelines, environmental impact transparency, formal order receipts, and flexible pricing in buyer home currencies.
Wave 2 — Seller Operations (season archives, buyer segments, PO PDF generation, enhanced analytics): Gives sellers new tools to manage inventory lifecycles, segment buyers for custom pricing, automate purchase order workflows, and track sales trends.
Buyer Segments with Price Modifiers
Buyer segments allow sellers to create audience-specific share links with custom pricing. Each segment has a price modifier (a multiplier applied to base prices), optional style include/exclude lists, and a description.
// convex/buyerSegments.ts
export const create = mutation({
args: {
name: v.string(),
description: v.optional(v.string()),
priceModifier: v.optional(v.number()),
includedStyleIds: v.optional(v.array(v.id("styles"))),
excludedStyleIds: v.optional(v.array(v.id("styles"))),
},
handler: async (ctx, args) => {
await requireAuth(ctx);
const tenantId = await getTenantId(ctx);
const trimmedName = args.name.trim();
if (trimmedName.length === 0)
throw new Error("Segment name cannot be empty");
if (args.priceModifier !== undefined &&
(args.priceModifier <= 0 || !isFinite(args.priceModifier)))
throw new Error("Price modifier must be a positive finite number");
// ...
},
});The priceModifier validation catches a subtle bug — 0 as a falsy value was initially treated as "no modifier." The fix uses an explicit check: priceModifier <= 0 || !isFinite(priceModifier). Delete cascades are handled by nullifying segmentId on all affected share links when a segment is removed.
Multi-Currency Display
Share links can now display prices in any currency supported by Intl.NumberFormat. The implementation is presentation-only — all prices are stored in the base currency (USD), and conversion happens at display time using a manually configured exchange rate:
// src/lib/currency.ts
export function formatPrice(
price: number,
currencyCode?: string,
exchangeRate?: number
): string {
const code = currencyCode || "USD";
const rate = exchangeRate != null && exchangeRate > 0
&& isFinite(exchangeRate) ? exchangeRate : 1;
const converted = price * rate;
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: code,
}).format(converted);
} catch {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(converted);
}
}The try/catch wrapping Intl.NumberFormat handles invalid currency codes — if someone types "XYZ" as a currency, it falls back to USD formatting instead of crashing the share page. The exchange rate also guards against zero, negative, and Infinity values.
Season Archives
Season archives capture a frozen snapshot of a linesheet's complete state — styles, colors, images, overrides, brand settings, pricing — into a single JSON document. This lets sellers preserve the exact state of a Fall 2026 linesheet before modifying it for Spring 2027.
The archive mutation resolves all style data inline (including price resolution through the full tier/discount/override chain) and serializes it to a JSON string stored as a single Convex document:
// convex/linesheetArchives.ts — size guard
const snapshot = JSON.stringify({ name, template, styles: resolvedStyles, /* ... */ });
if (snapshot.length > 900_000) {
throw new Error(
`Linesheet too large to archive (${Math.round(snapshot.length / 1024)}KB). ` +
`Try removing unused styles first.`
);
}The 900KB guard exists because Convex has a 1MB document size limit. A linesheet with hundreds of styles and full color/image data can approach that boundary. The archive stores everything needed to render the linesheet independently — a viewer page at /linesheets/[id]/archives/[archiveId] deserializes and displays the snapshot without any live database queries.
Purchase Order PDF Generation
PO PDFs use react-pdf with dynamic imports ({ ssr: false }) to avoid server-side rendering issues. The template renders a professional document with brand header, buyer info, line items table, and summary totals:
// src/components/pdf/PurchaseOrderTemplate.tsx
export function PurchaseOrderTemplate({ order }: { order: PurchaseOrderData }) {
const totalItems = order.lineItems.reduce((sum, li) => sum + li.quantity, 0);
const hasAnyPrice = order.lineItems.some((li) => li.price !== undefined);
const subtotal = order.lineItems.reduce(
(sum, li) => sum + li.quantity * (li.price ?? 0), 0
);
// Renders: Header (brand + PO#) → Bill To → Line Items Table → Summary → Notes → Footer
}The template conditionally shows price columns — if no line items have a price, the Unit Price and Total columns are omitted entirely. The download flow uses usePoPdfDownload, a custom hook that renders the PDF to a blob and triggers a browser download. Error feedback was added to surface rendering failures to the user instead of silently failing.
Order Confirmation Emails
When a buyer submits an order through a share link, an automated confirmation email is sent via Convex actions and a Next.js API route. The email pipeline uses Resend as the provider with HTML escaping to prevent injection:
// convex/emails.ts
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}Rate limiting is enforced at 30 emails per 10 minutes per tenant. Email validation supports comma-separated recipient lists with per-address regex checking.
Ships-Now Filter and Sustainability Metadata
The ships-now filter is a client-side toggle on share link pages that filters styles to only those with totalInventory > 0. Buyers browsing a linesheet can flip the toggle to see only immediately available product.
Sustainability metadata adds three new fields to the style schema: originCountry, certifications (an array of strings), and factoryInfo. These fields propagate through all 5 output paths — web preview, PDF export, email, share link, and CSV export. The share page displays origin and certifications as inline badges when populated.
Enhanced Analytics
The engagement analytics system gained three new metrics: time-on-page tracking (via visibility API), device breakdown (desktop/mobile/tablet from user agent), and per-style interaction tracking. Conversion rate — orders divided by unique sessions — is computed in the analytics detail query. The updateEngagement mutation includes share link validation to prevent tracking calls against invalid or expired links. Engagement tracking calls use fire-and-forget .catch() patterns to avoid blocking the buyer's browsing experience.
Quality Review Fixes
The commit includes 11 quality fixes discovered during code review: fixing the resolvePriceForStyle call signature in archives (was 3 args, needed 5), adding cascade delete on segment removal, correcting a stale closure in useCallback dependency arrays, handling the falsy 0 in price modifiers, and more. The archive size guard and currency formatting fallback were both added during this review pass — they address edge cases that wouldn't surface until production data hit the boundaries.