Takes a founder from a rough sketch to a manufactured, sellable product — design, tech pack, vendor sourcing, quoting, production, and sales — in one tool instead of a scattered stack of spreadsheets, DMs, and freelance tech-pack files.
Positioning, on purpose: this is production intelligence, not an AI design generator. The AI never makes creative or final business decisions — it drafts, extracts, scores, and suggests; the founder always reviews and decides. Every AI feature in this repo follows that rule.
grainline/
├── la-guia/ React + Vite frontend
│ ├── src/
│ │ ├── components/ Sidebar, Photopea embed, garment silhouettes, dashboard widgets, shared UI
│ │ ├── context/ Auth, Products, Vendors, Production, Notifications, Materials, Team, Sales,
│ │ │ Billing (AIUsage), UserPreferences, Onboarding, AppUI (Supabase-backed)
│ │ ├── lib/ Supabase client, formatters, theme + keyboard-shortcut hooks
│ │ └── pages/ One file per route
│ ├── .env.local VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY (not committed)
│ └── package.json
├── api/ Express backend — the only place secret keys are used
│ ├── index.js AI (OpenAI text + images + Tavily), Stripe billing
│ │ and AI credits, storefront/social OAuth, Resend email
│ ├── config/aiCredits.js AUTHORITATIVE per-feature credit costs, tier grants, packs
│ ├── scripts/ One-time setup scripts (Stripe products/prices, webhook)
│ ├── .env Secret keys (not committed) — see Local setup
│ └── package.json
└── supabase/
└── migrations/ SQL schema for your Supabase project, run in order
The split is deliberate: the frontend talks to Supabase directly for all data (products, designs, vendors, quotes, etc.), protected by Row Level Security — no backend round-trip needed for CRUD. The Express backend (api/) exists only for calls that need a secret key that cannot live in browser code (OpenAI, Tavily, Stripe, the storefront/social OAuth apps, Resend) — plus the two things that must not be client-trusted at all: validating the caller's JWT and moving AI credits.
The frontend was scaffolded with static mock data first, then converted page-by-page to real Supabase data.
Real (Supabase-backed): Auth · Brands (multi-brand — a user can own or belong to several, switching reloads every context) · Products (with favoriting and permanent delete) · Product Management (categories, colorway/size SKU matrix, duplicate, archive/status, lifecycle history — see below) · Designs (including AI-generated silhouettes) · Tech Packs (BOM, Measurements, Sampling Checklist — deletable independently of the design) · Collections (deletable — un-groups its products rather than deleting them) · Materials Library (fabric/trim/notion filtering, sustainability & cost history, supplier links — see below) · Vendor Platform (structured search, certifications/capabilities/price, comparison — see below) · RFQ & Quote Economics (multi-vendor RFQs, negotiation log, cost breakdown wheel, landed cost calculator, AI cost simulator — see below) · Sampling (rounds, photo annotations, fit feedback, approval workflow — see below) · Production Tracking (manufacturing timeline, QC checklist, issues, shipment, inventory, analytics — see below) · Notifications · Settings · Team members & permissions (invite-by-email with automated Resend delivery) · User preferences (theme, onboarding state) · Command palette / global search (entity content and sidebar pages) · Keyboard shortcuts · Onboarding walkthrough (auto-scrolls the highlighted feature into view, first-visit only) · Personalized Home dashboard (Continue where you left off, AI suggestions, Project health, Favorite projects, Calendar timeline, Recent activity, Upcoming deadlines, Quick actions, a Suggestion Inbox for feedback/bug reports, the hero card's pinned photo now shows the featured product's actual tech pack image/design snapshot instead of the placeholder texture once one exists) · Team Chat & AI Assistant (see below) · the hero's "Working sketch" is a real type-or-draw sticky note with 3-slot swap storage, pencil/eraser tools, and a full clear action (not decorative) · Sales Dashboard & product break-even tracking (Shopify integration) · AI Design Studio (see below)
Every delete (design, tech pack, material, collection) goes through ConfirmDeleteModal (la-guia/src/components/ConfirmDeleteModal.jsx) — a deliberate trash-icon click opens it, and the actual delete button stays disabled until you type the item's exact name, so an accidental click or stray Enter key can't finish it.
AI Design Studio (la-guia/src/components/design-studio/, opened as tabs on a Design's detail page): real image generation on OpenAI gpt-image-1, split across two endpoints by what each tool actually does to the canvas —
- Transform tools (sketch-to-design, polish, AI text edit, background remover, recolor, fabric swap, mockup generator, flat sketch, alternate views, variants — 10 modes) edit the founder's actual existing design, so they go through
/api/design/ai-image(one mode-specific prompt template per tool) with the current canvas passed in as a reference image. Applying a result replaces the canvas outright, since these are genuinely whole-image changes (there's no partial "layer" for "this garment is now green"). The one exception is alternate views, which no longer overwrite anything — see the garment views note below. - Addition tools (Add Element, Pattern Generator) generate a brand-new, isolated element with no reference to the existing design at all, through
/api/design/generate-element(modesadd-element,pattern, plussilhouettefor Design.jsx's custom-garment generator). A result never overwrites anything: it's either inserted as a genuinely new, movable/deletable Photopea layer (PhotopeaEditor.addLayer, uses Photopea's ownapp.open(url, "", true)smart-object-layer behavior) or downloaded as a transparent PNG for anyone working in Photoshop/Illustrator instead of the in-app canvas.
Both endpoints share one callOpenAIImage helper. gpt-image-1 returns real alpha, so background removal, flat sketches, logos and silhouettes come back as genuinely transparent drop-in layers rather than the painted-white approximation an earlier provider could only fake. Every design-image prompt gets IMAGE_OUTPUT_RULES appended — the two biggest real-world failure modes were the model returning a contact sheet of variations on one canvas and the rendering style drifting run to run, so every mode pins one subject, one panel, consistent presentation, no stray text.
(History: the transform tools originally ran on Gemini's image model and the addition tools on Stable Diffusion via Pixazo. Both moved to gpt-image-1; PIXAZO_API_KEY is no longer read anywhere in this repo and can be deleted from your env. Text moved off Gemini to gpt-5.4-mini on 2026-08-07, so AI spend lands on one dashboard. Gemini flash-lite is retained purely as a failsafe: callAIText uses it only when OpenAI is unreachable, and logs loudly when it does.)
Also real: a moodboard (uploaded reference images), an AI color palette generator, AI trend inspiration (Tavily-grounded, cached once per category per day), AI-generated design variants, version history (every saved AI result), and a comment thread — all Supabase-backed per design. AI design critique (/api/analyze-design, scores a canvas snapshot) predates this and lives on the Canvas tab.
Tech Pack Builder (la-guia/src/pages/TechPackDetail.jsx): opening a tech pack for the first time shows an intake questionnaire (TechPackQuestionnaire.jsx) — free-text answers per section (materials, sizing, construction, print placements, trims, labels, packaging, material usage, manufacturing/compliance notes, plus a catch-all "other" field), with two paths out of it: "Generate with AI" (/api/generate-tech-pack-full, the model fills every section from the answers + garment category, always shown next to an explicit "won't always be accurate" warning) or "Start blank / from my answers" (each non-empty answer seeds one real row in the corresponding table, no AI involved). Every section is a real editable table after that — construction, print placements, trims, labels, packaging, material usage, manufacturing notes, compliance notes, on top of the pre-existing BOM/measurements/sampling checklist — via a shared EditableSectionTable component. Also real: a missing-information banner on Overview (checks every section, not just the ones that feed the readiness score), an approval workflow (draft → pending → approved/rejected, gated on team role for the approve/reject step), version history (manual "Save version" snapshots the whole tech pack, restorable), PDF export (existing print-CSS layout, extended to cover every new section), and CSV export ("Export Excel" in the UI — deliberately CSV under the hood, not the xlsx npm package, which has open high-severity prototype-pollution/ReDoS advisories on its last published version; CSV opens natively in both Excel and Sheets with zero added dependency risk).
I did not build the "AI edits a template image sourced from online" version of this — sourcing and rights-clearing a real tech pack template isn't something I can do reliably, and a structured, per-section editable document (what's built) is more useful for actually editing/exporting than an image with parts erased by AI. This matches the fallback you offered ("can also be done in Google Sheets or Excel if it's easier").
Product Management (la-guia/src/context/ProductsContext.jsx, la-guia/src/pages/Design.jsx, la-guia/src/pages/DesignDetail.jsx, la-guia/src/pages/Settings.jsx): no AI here — categories, colorways, sizes, SKUs, status, and lifecycle history are all deterministic data, not AI decisions. Brand-level Categories are managed from Settings > Brand Details and pickable per-product from the Design detail page. A design's SKUs & Variants tab lets you build a colorway × size matrix and generate real SKUs (la-guia/src/lib/sku.js, format {BRAND}-{CATEGORY}-{PRODUCT}-{COLOR}-{SIZE}, pure string formatting — nothing calls out to AI for this) into a product_variants table; "Generate SKUs" only fills in missing combinations, so relabeling a colorway or adding a size never touches SKUs that already exist. Duplicate clones a product's row and its design (not its tech pack or variants — those are meant to be built fresh for the copy). Archive sets products.status and moves the product out of the default list into a separate "Show archived" view on Design.jsx and DesignDetail's Details panel; discontinued is a status too but, unlike archived, doesn't get hidden — it's still meant to show up in Kanban/history. Every stage change (Kanban move, new design, duplicate) is best-effort logged to product_stage_history for a real lifecycle audit trail, without ever blocking the actual move if the write fails.
Note: the existing "Variants" tab on a design (AI-generated image variants, from AI Design Studio) was renamed to "Image Variants" to avoid colliding with the new SKU/colorway "Variants" concept — same feature, same data, just relabeled.
Vendor Platform (la-guia/src/pages/VendorDiscovery.jsx, VendorDetail.jsx, la-guia/src/context/VendorsContext.jsx): vendor search now takes structured fields (material/style keywords, category, location, plus an "Advanced filters" panel for quantity, max MOQ, target unit price, and certifications) instead of one opaque text box — /api/search-vendors builds a sharper Tavily query from whichever fields are actually filled in, and the extraction prompt now also pulls certifications, factory capabilities, and a price range out of real search results (never estimated — left blank if the source text doesn't state one). A design's "Find Vendors for this Design" button (on DesignDetail.jsx's Details panel) captures the current canvas snapshot and hands it, plus the design's category, to a pre-filled vendor search — the image is passed straight into the same model call so it can weigh a candidate's fit against the actual garment's construction/fabric weight, not just a text description of it. Saved vendors have a prominent, inline-editable price range (shown large on the vendor card, row, and detail page — this was explicitly asked to be a bigger visual element than the rest of the profile), an editable certifications/capabilities/specialties tag set, a manual onboarding stage (prospect → contacted → sampling → onboarded), and a manual verification toggle with notes — verification is a human trust judgment about a real-world business relationship, so it's founder-set, never AI-decided. A new Compare tab lets you check up to 5 saved vendors (from Discover or Favorites) and view them side by side in a table, price as the standout row, everything else (rating, MOQ, lead time, certifications, capabilities, onboarding stage, verification, quotes exchanged) underneath. VendorDetail.jsx also now shows a Performance history section — quotes requested/accepted/acceptance-rate and linked production orders — computed from real quotes/production_orders rows, not a fabricated score.
RFQ & Quote Economics (la-guia/src/pages/QuoteTracker.jsx, QuoteDetail.jsx, la-guia/src/components/CostBreakdownWheel.jsx, la-guia/src/context/VendorsContext.jsx): a New RFQ lets one request fan out to any number of vendors at once (rfqs + one quotes row per vendor sharing the same quantity/target price/deadline), reachable from Quote Tracker or as a "Request quotes from these N" shortcut on the Vendor Hub's Compare tab. Every quote now has its own detail page (/quotes/:id) with a founder-kept negotiation log (counter-offers and vendor responses, typed in manually — vendors aren't Atelier users, so this is honest record-keeping, not a simulated two-way chat), Accept/Decline, and three cost tools: an interactive Cost Breakdown Wheel (/api/quote-economics — the Fabric slice is real, pulled straight from the tech pack's BOM total; Labor/Shipping/Packaging/Profit are AI's estimate of how the rest of the quoted price likely splits, clearly labeled as such), a deterministic Landed Cost Calculator (quoted price + shipping + duty % + other fees, pre-filled from the AI's shipping/duty estimates but always editable, pure arithmetic once filled in), and an AI Cost Simulator — most levers are on/off toggles for common apparel cost changes (add embroidery, switch to organic cotton, move sourcing region, etc.), but fabric weight (GSM) is a set of mutually-exclusive options (~220/320/380/450/550 GSM) rather than a single toggle, since "increase the GSM" isn't one change — each option shows its own AI-estimated $ delta (/api/cost-simulator's response splits into a flat levers array for toggles and a nested choiceLevers array for GSM's per-option deltas). Everything sums live into an estimated new unit price, "like configuring a car." All three tools are computed once per click (not re-queried on every toggle) and cached on the quote row so reopening it doesn't lose the estimate or re-spend AI usage.
Sampling (la-guia/src/pages/Sampling.jsx, SampleDetail.jsx, la-guia/src/context/SamplingContext.jsx): request a physical sample for any product with a tech pack; each request is a round (samples.round_number), and requesting a revision doesn't edit the old round — it opens a new one, so the full history stays intact as a real timeline instead of being overwritten. Each round has a photo gallery where clicking anywhere on a photo pins a note at that exact spot (sample_annotations.x_percent/y_percent) — click a pin to mark it resolved — plus structured fit feedback (area + rating + note, e.g. "Sleeve — Too tight"). The approval workflow (Approve / Reject / Request Revision) is gated behind useTeam()'s canManage the same way the Tech Pack Builder's approval step is — only brand admins/owners can finalize a round, though anyone can log photos and feedback. No AI anywhere in this feature; it's pure request/track/approve bookkeeping. FlowStepper gained a Sampling stop between Vendors & Quotes and Production, and stageLink() now routes a product's sampling stage there instead of to Production Orders.
Production Tracking (la-guia/src/pages/ProductionOrders.jsx, ProductionOrderDetail.jsx, la-guia/src/context/ProductionContext.jsx): ProductionOrderDetail.jsx moved from one long flat scroll to three tabs — Overview (stats, a manufacturing-timeline strip built from the order's checkpoints, and a deterministic delivery estimate: real progress-so-far divided by real days-elapsed, projected forward and flagged red if it'd land after the due date — no AI, just arithmetic on data that's already there), Quality & Issues (the QC checklist is no longer a fixed 4 items — you can add/remove steps, same toggle-to-complete interaction as before — plus a severity-tagged issue log with resolve/reopen), and Shipment & Inventory (carrier/tracking number/tracking link/shipped date, a factory updates feed of timestamped notes, and units-received tracking). That last part is deliberately not called "Shopify sync" — the existing Shopify integration only reads orders (/api/shopify/fetch-orders), there's no inventory read or write endpoint anywhere in this codebase, so inventory here is an honest in-app ledger the founder updates as units physically arrive, not a claim of a live external sync. ProductionOrders.jsx gained a List / Analytics pill toggle (same pattern as Quote Tracker's List/Compare Matrix) instead of a separate analytics page — units in progress, average days to delivery, on-time rate, and orders-by-stage, all computed from orders already in memory.
Materials Library (la-guia/src/pages/MaterialLibrary.jsx, MaterialDetail.jsx, la-guia/src/context/MaterialsContext.jsx): materials gained a type (Fabric/Trim/Notion) with a pill filter on the library grid — "fabric search" and "trim library" are the same list, filtered, not two separate pages. The library page also gained a real Add material form and working create/update (there wasn't one before — materials were previously read/delete-only from the app, and the delete RLS policy didn't actually exist either, so deleteMaterial() had nothing to delete with until this migration added it). MaterialDetail.jsx is now three tabs: Overview (the existing warning/handling-notes cards, plus editable sustainability notes, a certifications tag list, an availability status, and a deterministic "similar materials" list — other materials sharing the same category, not an AI suggestion), Cost & Suppliers (a real price-log-over-time chart reusing PriceHistoryChart.jsx, and supplier links to real vendors from the Vendor Platform via a new material_vendors table — the old "supplier" concept was only ever a free-text field on a tech pack's BOM row, never an actual link to a vendor), and Usage (the existing tech-pack cross-reference, unchanged, just moved into a tab). Cost history and supplier links are brand-scoped even though the base material row is shared across every brand using the app — what you paid and who you buy from are your business's facts, not everyone's.
Financial Tools (la-guia/src/pages/FinancialTools.jsx, new /financial route, la-guia/src/context/ProductionContext.jsx): five tabs, all deterministic arithmetic over real data — no AI, no useAIUsage() gating needed. Cost & Profit and Break-Even & Pricing generalize the per-product math that already lived on ProductInsights.jsx (BOM cost, landed cost, retail/wholesale margin, break-even units against real factory spend) into a brand-wide tool with an optional product picker, plus a Pricing Suggestions calculator (target margin → suggested price, pure algebra). MOQ Optimization is a new manual quantity-tier calculator — vendor records only carry a single MOQ, so tiered pricing is founder-entered, and the tool shows the cost-per-unit vs. upfront-capital tradeoff across tiers. Cash Flow & Forecast nets real production payments (outflow) against real Shopify sales revenue (inflow) by month for a running cash position, plus a trailing-average revenue projection labeled honestly as a trend estimate, not a prediction. Manufacturing Cost History charts real payment-ledger spend per product over time, reusing PriceHistoryChart.jsx the same way Materials Library's cost log does. This tab required ProductionContext.jsx to gain a brand-wide allPayments (the existing paymentsByOrder was per-order only) — see migration 023 below. Along the way, a real bug in lib/format.js's percent() was fixed: every caller already passed an already-×100 margin value, but the function multiplied by 100 again, so ProductInsights.jsx's Retail Margin stat had been rendering as e.g. "6500%" instead of "65%". The sidebar's Analytics group gained a second entry for this — the existing "Dashboard" label was renamed Overview so the group reads as "Overview + Financial Tools" rather than "Dashboard + Financial Tools."
Analytics Expansion (la-guia/src/pages/SalesDashboard.jsx, route stays /sales, sidebar label renamed Overview — see the Financial Tools entry above): grew from two tabs to seven, all real data, no fabricated numbers. Vendors rolls up VendorDetail.jsx's per-vendor metrics (quotes requested/accepted, acceptance rate, order count) plus on-time delivery rate across every vendor, same real formula as ProductionOrders.jsx's Analytics pill. Manufacturing is a brand-wide version of that same Analytics pill (stage funnel, on-time rate, avg days to delivery) plus a production_issues severity breakdown. Inventory generalizes ProductInsights.jsx's per-product "Est. Stock on Hand" math into a brand-wide produced/sold/stock/runway table. Marketing is an honest empty state, not invented metrics — content_posts/social_accounts have no engagement/reach columns even where they exist, so there's genuinely nothing real to chart yet; it links out to Content Hub instead of pretending to have data. Reports is a modest custom-report builder — pick a data source (Sales/Production/Vendors/Inventory, all already-loaded real context data), toggle columns, filter, preview a table, export CSV — using the new shared la-guia/src/lib/csvExport.js (the same Blob+download mechanism techPackExcel.js already used for tech pack exports, generalized instead of duplicated).
OAuth security hardening (api/index.js, new "OAuth handoff helper" section right before the Shopify integration): every platform connect flow in this app redirects through a third-party authorize page and back, and the existing Shopify/Instagram/TikTok flows had two real gaps — state was just the raw brandId with no signature (so a constructed callback URL could satisfy the frontend's only check), and the access token traveled back to the browser embedded in the redirect URL's query string (visible in browser history and referrer headers). Both are fixed once, centrally, since the upcoming WooCommerce/Etsy/YouTube/Pinterest integrations reuse the same shape: signOAuthState/verifyOAuthState HMAC-sign state with a new OAUTH_STATE_SECRET env var, and createOAuthHandoff/GET /api/oauth/consume swap the token for a random single-use code with a 2-minute TTL (kept in an in-memory Map, consistent with this backend having no database of its own) — the frontend calls /api/oauth/consume once via the new la-guia/src/lib/oauthHandoff.js to retrieve the real token and persist it to Supabase itself, same as before. Shopify's flow now uses this; Instagram/TikTok/the new platforms below all build on it too.
WooCommerce integration (api/index.js's new "4B. WOOCOMMERCE INTEGRATION" section, la-guia/src/lib/ecommerceSync.js, SalesDashboard.jsx's Connections tab): a second real, working storefront connection alongside Shopify — and the easiest one in this whole batch, since WooCommerce's REST API is plain Basic Auth over HTTPS with a Consumer Key/Secret the founder generates themselves in their own wp-admin (WooCommerce > Settings > Advanced > REST API). No OAuth app, no platform review, no redirect — just a form (store URL + key + secret) that gets verified with a real GET /wp-json/wc/v3/system_status call before it's persisted. ecommerceSync.js is the start of a per-platform adapter pattern ({ validate, fetchOrders, fetchInventory }) so the next platforms don't each get a copy-pasted sync function; SalesDashboard.jsx's order-aggregation logic (SKU matching, month bucketing, sales_data upsert) is now a shared aggregateAndUpsertOrders(orders, platform) both Shopify and WooCommerce call, rather than duplicated per platform. This surfaced a real pre-existing gap: sales_data's unique constraint never included a platform column, so a WooCommerce sale and a Shopify sale for the same product in the same month would have silently overwritten each other — fixed in migration 025 (below) now that there's a second platform for it to actually matter for. SalesContext.jsx also gained connections (plural — every connected platform, not just Shopify) and a real bug fix: disconnectStore() used to delete all of a brand's sales_data regardless of platform, so disconnecting Shopify would have wiped WooCommerce's revenue history too; it's now scoped to the platform being disconnected.
Etsy integration (api/index.js's new "4C. ETSY INTEGRATION" section, ecommerceSync.js's etsy adapter, SalesDashboard.jsx's Connections tab): a third real, working storefront connection. Unlike Shopify/WooCommerce, Etsy Open API v3 requires OAuth 2.0 with PKCE (mandatory, not optional) — the backend generates a random code_verifier, sends its SHA-256 hash as code_challenge on the authorize redirect, and proves it later at token exchange by sending the original verifier back. PKCE verifiers live in the same short-lived in-memory pattern as the OAuth handoff store (this backend has no database of its own). After the token exchange, the callback makes one more real call (GET /v3/application/users/{user_id}/shops) to resolve which shop the connecting account owns, since Etsy's API is shop-scoped. Etsy access tokens expire hourly (unlike Shopify's, which don't) — ecommerceSync.js's adapter checks token_expires_at before every sync and calls the new /api/etsy/refresh-token endpoint automatically when needed, handing the refreshed token back to SalesDashboard.jsx to persist. Etsy's prices arrive as Money objects ({amount, divisor, currency_code}) and each order's line items live in a nested transactions array — both get normalized server-side in /api/etsy/fetch-orders into the same flat { created_at, total_price, line_items } shape every other platform already produces, so the frontend sync logic doesn't need to know the difference.
TikTok Shop, cross-platform sync, and Product Publishing (api/index.js's "4D. TIKTOK SHOP" section, ecommerceSync.js's shopify adapter and publishProduct methods, SalesDashboard.jsx's Inventory and new Listings tabs): TikTok Shop is an honest stub, not a real connection — its Partner API isn't self-serve like Shopify/WooCommerce/Etsy (TikTok has to approve the seller/partner relationship first), and the exact OAuth shape varies by API version and region in ways not confidently verifiable without an approved account to test against. Rather than guess at an auth URL that might be wrong, /api/tiktokshop/auth always returns a clear "not available yet" response instead of attempting a redirect — same honesty pattern as Shopify's own "coming soon" card. Shopify's sync logic is now routed through ecommerceSync.js's adapter pattern too (previously bespoke, now symmetric with WooCommerce/Etsy), and gained a real read-only fetchInventory (/api/shopify/fetch-inventory, already covered by the existing read_products scope — not the same thing as the README's long-standing "no inventory endpoint" note, which was about a live write-back sync). The Inventory tab's new "Compare to Storefront Stock" button pulls live stock counts from every connected platform, SKU-matched against Atelier's own produced-minus-sold calculation, as an on-demand check rather than a running sync. The new Listings tab is real, confirm-gated Product Publishing: pick a product and a write-capable platform (WooCommerce or Etsy — Shopify's connection is disabled, so it's excluded from publishing even though its read side still works), fill in a title/description/price, review an explicit preview, and only then does it create a real draft product/listing on the live store (never auto-published — the founder reviews and goes live themselves on the platform) via the new /api/woocommerce/publish-product//api/etsy/publish-listing endpoints, recorded in the new platform_listings table so re-publishing the same product to the same platform updates rather than duplicates. Etsy listings require a real numeric taxonomy category ID with no safe default across garment types — the form asks for it rather than guessing wrong on a live listing — and Etsy image upload (a separate multipart endpoint) isn't wired up yet, so Etsy listings publish as text-only drafts; photos get added directly in Etsy afterward.
Marketing foundation (migration 026, api/index.js's rebuilt "8. SOCIAL MEDIA OAUTH" section, ContentContext.jsx, ContentHub.jsx): content_posts/social_accounts finally have a real migration — same drift pattern as production_payments before them, read/written since an earlier session with nothing ever creating the tables. More importantly, the Instagram/TikTok connect flow had a real bug: it ran a genuine OAuth handshake but then discarded the access token entirely — connectAccount() never read it, so every "connected" account had no real credential behind it. Both platforms are rebuilt on the same shared OAuth handoff helper Shopify/Etsy use (signed state, single-use handoff code instead of a token in the URL), and the token now actually gets stored on social_accounts.access_token. Two more platforms join on the same helper: YouTube (Google OAuth, access_type=offline&prompt=consent for a refresh token, needed for any future upload feature) and Pinterest (OAuth 2.0 with Basic-auth token exchange) — both self-serve to register, unlike TikTok Shop. No follower count is fabricated anywhere anymore either — the old code filled it with Math.random(); getting a real count needs its own API call/scope per platform this doesn't make yet, so it's left unset rather than shown as a made-up number. Also lays schema for two upcoming features: influencers/influencer_deals (Influencer CRM) and email_contacts/email_campaigns (Email Campaigns), plus products.launch_date/launch_plan (Product Launch Planner).
Publish attempt, Product Launch Planner, Influencer CRM, Email Campaigns (api/index.js's /api/social/publish/:platform and /api/send-campaign, ContentHub.jsx's new Launch Planner/Influencers/Email Campaigns/Analytics tabs, new InfluencersContext.jsx): closes out the Marketing feature set from migration 026.
- Publish attempt is real where the underlying OAuth connection actually has write access, and honest where it doesn't. Only Pinterest's connect flow requested a write scope (
pins:write) — its "Publish Now" button makes a realPOST /v5/pinscall. Instagram and TikTok were deliberately connected with read-only scopes back in the Marketing foundation batch (real content-publish permissions on both need a separate business-verified app review this integration doesn't have), so publishing returns a clear explanation instead of attempting a call guaranteed to fail. YouTube did request an upload scope, butcontent_postsonly stores animage_url— there's no video file to upload, so there's genuinely nothing to publish yet. A failed publish attempt sets the post's status toFailed(a real state, not silently dropped) and surfaces the platform's actual error inline. - Product Launch Planner generates a real T-minus checklist (
products.launch_date/launch_plan, 8 fixed milestones from T-30 to T+7) against a chosen product and launch date, with due dates computed live from the offset — no AI, pure date arithmetic, checkbox-toggleable. - Influencer CRM (
influencers/influencer_deals) mirrors the Vendor Platform's shape in miniature: status pipeline (Prospect → Contacted → Negotiating → Active → Completed), and an expandable deal log per influencer (deliverables, amount, linked product, date) — entirely manual, no AI, no fabricated follower/engagement data beyond what's typed in. - Email Campaigns (
email_contacts/email_campaigns) is a real, modestly-scoped sender, not a full ESP: a manual contact list (add one at a time or CSV-import — a plain two-column parser, no quoting/escaping support, matching the simplicity of the existingtechPackExcel.jsexport path on the way out), a subject/HTML-body composer, an explicit preview-then-confirm step before anything sends (this can email real people), and a real send through Resend — one call per recipient rather than a batch API this app has no retry/queue infrastructure to back up. Delivery failures are reported per-recipient, not swallowed. - Campaign Analytics (new Analytics tab) shows real counts only — posts scheduled/posted/failed by platform, campaigns sent, total recipients — and says plainly that there's no click/open/engagement tracking wired up, rather than inventing any.
Shared UI primitives + Collaboration/AI partials (migration 027, new la-guia/src/lib//la-guia/src/components/ primitives, CommentsPanel.jsx, ActivityFeed.jsx): the start of the Collaboration/QoL/UI-UX batch. Following the audit that found most of Collaboration and AI Assistant already real, this finishes the genuinely partial pieces and lays reusable primitives for everything still to come, rather than one-off implementations per page.
- New primitives, each real and reusable (not yet applied everywhere — see the page-by-page batches that follow):
useMultiSelect/BulkActionBar(checkbox multi-select + a bottom-docked action bar),ContextMenu(real right-click menu),Skeleton(placeholder loading shapes, replacing theph-spinconvention),HoverPreview(rich hover popover vs. plaintitle=""tooltips),Breadcrumbs(a real hierarchical trail, distinct fromFlowStepper.jsx's fixed cross-feature stage jumper),useDragAndDrop(generalizes the DnD already proven on Home's production kanban),Splitter(plain mousedown/mousemove resize divider, no dependency),CalendarGrid(a real month grid, vs. the vertical timeline lists elsewhere),GanttChart(real date-range bars),useAutosave(debounced save + status indicator). - Comments, generalized: a new
commentstable (entity_type + entity_id, reusable across any record) andCommentsPanel.jsx, now live on Vendor, Quote, and Tech Pack detail pages — previously comment-like features only existed on Designs and Samples. - Activity Feed (
components/dashboard/ActivityFeed.jsx): mergesproduct_stage_history,production_updates,design_versions, andtech_pack_versions— four real per-entity logs that already existed separately — into one chronological feed. Now live on the Home dashboard, replacing the old notifications-only "Recent activity" card. - Pinned items, unified: new
pinned_itemstable +PinnedContext.jsx, a generic pin concept that works for any entity type. Rather than a disruptive rip-out of the two existing, working favorite mechanisms (is_favoriteon products,favoritedon vendors), it fills the actual gap: materials had no pin/favorite concept at all, soMaterialDetail.jsxgets a real pin toggle backed bypinned_items. The Home dashboard's "Favorite projects" widget (FavoriteProjects.jsx) is now "Pinned" — it merges all three (favorite products, favorite vendors, pinned materials) into one list, each item still un-pinnable from the widget itself, each source page keeping its own existing toggle. - AI Design Critique: the existing
/api/analyze-designscoring (already real — a live model read of the captured canvas snapshot, not scripted) was functionally complete but only labeled "AI Design Analysis"; retitled and given a one-line explanation of what it actually is, per the audit's "partial" finding. designs.fabric_tagsanduser_preferences.dashboard_layoutcolumns are added here (migration 027) for the Design tags and customizable-dashboard batches that follow.
Fast loading: the biggest unbounded reads in the app — ProductsContext, VendorsContext, MaterialsContext, ProductionContext's production_orders query, and MaterialDetail.jsx's tech-pack cross-reference lookup — had no query limit at all, relying purely on RLS to scope results with no cap on row count. All five now carry a .limit(500) safety cap. This is a real but modest fix, not full paginated "load more" UI (which would need matching changes on every page that renders these lists) — no brand realistically has thousands of rows yet, so the cap is the honest, contained fix for today's real risk rather than a bigger unrequested rebuild. MaterialDetail.jsx's usage lookup remains a genuine N+1 in spirit (it still scans every tech pack in the brand to name-match a material inside a jsonb BOM array, since that can't be pushed into a WHERE clause without a proper reverse-lookup table) but is now bounded instead of unbounded.
Autosave rollout (la-guia/src/lib/useAutosave.jsx, already built in the primitives batch above): applied to the two forms that were genuinely manual, blur-triggered saves — ProductInsights.jsx's financial model (previously a single "Save Model" button the user had to remember to click) and MaterialDetail.jsx's sustainability notes textarea (previously saved only on blur, so a mid-typing tab-away or crash lost the edit). Both now save automatically ~800ms after typing stops, with a small "Saving… / Saved / Could not save" indicator next to the card title; ProductInsights.jsx keeps its manual button too, relabeled "Save Now," for anyone who wants to force it immediately. MaterialDetail.jsx's certifications field and DesignDetail.jsx were left alone — the former is a structured comma-list better committed on blur than re-parsed on every keystroke, and the latter has no manual-typing fields at all (its editable surfaces are dropdowns and chip-adds that already save instantly, or AI Studio fields that already persist incrementally), so there was nothing to convert.
Home dashboard: activity, pins, recently-viewed (la-guia/src/pages/Home.jsx, FavoriteProjects.jsx, ContinueWhereYouLeftOff.jsx): the real cross-entity ActivityFeed built in the primitives batch is now live on Home, replacing the old card that only showed notifications. "Continue where you left off" now shows all 8 recently-viewed items AppUIContext actually tracks (was hard-capped to 4, inside a scrollable list so it doesn't blow out the dashboard grid's row height). "Favorite projects" is now the unified "Pinned" widget described above.
Home dashboard: customizable layout (la-guia/src/pages/Home.jsx, UserPreferencesContext.jsx): the "Your dashboard" section's five widgets (Continue where you left off, AI suggestions, Project health, Pinned, Calendar timeline) can now be reordered by drag-and-drop and hidden/shown individually via a "Customize" toggle, using the useDragAndDrop primitive built earlier. Saved to user_preferences.dashboard_layout (schema added in an earlier batch, unused until now) as { order, hidden } through the already-generic updatePreferences() — synced to the founder's account, not just localStorage, so it follows them across browsers. One real layout tradeoff: the two rows previously used different column widths (a 3-even-column row, then a 1fr/1.4fr row) so Calendar timeline could run wider than its neighbors; making every widget freely reorderable against every other one meant collapsing both rows into a single uniform auto-fill grid where all five widgets are equal width. Reordering across differently-shaped rows isn't something a drag-and-drop primitive can honestly promise, so the visual trade was made in favor of the customization actually working correctly rather than looking preserved but silently breaking on reorder.
Real bug caught running the actual dev server, not just a syntax checker: useAutosave.js returns JSX from AutosaveIndicator but had a .js extension, which Vite's default pipeline refuses to parse as JSX (only .jsx/.tsx get that treatment) — this broke the dev server for every page that imports it. Renamed to useAutosave.jsx and fixed both import sites (ProductInsights.jsx, MaterialDetail.jsx).
Design.jsx overhaul (la-guia/src/pages/Design.jsx, DesignDetail.jsx, ProductsContext.jsx): the biggest single-page batch in the QoL/UI-UX plan. Gained a real Cards / Kanban / Table view toggle. Cards is the existing grid, now with checkbox multi-select and a right-click context menu (same duplicate/archive/delete actions already on the card's buttons — a shortcut, not new functionality) via the shared primitives from an earlier batch. Kanban is a genuinely new capability, not just a new view on old data: designs.status ("Sketching"/"Refining"/"Ready") turned out to be write-once — set at creation and never touched again anywhere in the app, so every design silently stuck at "Sketching" forever. ProductsContext gained a real updateDesignStatus(), and the Kanban view's drag-and-drop (useDragAndDrop, the same primitive proven on Home's production kanban) between the three status columns is the first place a design's status can actually change post-creation. DesignDetail.jsx's "Design status" line was upgraded from a static, dead tag to a real dropdown using the same function, so it's editable there too, not just via drag. Table is a sortable-by-eye list (name, category, design status, risk, readiness) for scanning many designs at once. A bulk-archive action appears via BulkActionBar once anything is checkbox-selected in Cards view — bulk delete was deliberately left out: every single-item delete already goes through a type-the-name confirm modal specifically to add friction to an irreversible action, and a bulk-delete shortcut would undermine that on purpose-built friction. Skeleton loading (SkeletonCard) replaces a blank flash while designs are still loading. HoverPreview was evaluated and skipped here on purpose — the existing cards already show thumbnail, name, category, and status inline, so a hover popover would duplicate information already visible rather than add any.
Also new: a real fabric tags editor on DesignDetail.jsx (composition/care/origin/certification chips, backed by designs.fabric_tags — schema existed since an earlier batch but nothing wrote to it until now), and ProductsContext gained updateDesignFabricTags() alongside updateDesignStatus().
Vendors/Materials/Collections rollout (VendorDiscovery.jsx, MaterialLibrary.jsx, Collections.jsx): the shared primitives applied to these three pages, scoped to what each page actually needed rather than mechanically copy-pasting the same feature set everywhere.
- Materials Library gets the fullest treatment: a real Cards / Table view toggle, a Sort by control (Name/Category/Risk/Availability), a right-click context menu on cards (same delete action already on the card's button), skeleton loading in place of the old spinner icon, and
HoverPreview— genuinely useful here, since the card doesn't show sustainability notes or certifications at all, so hovering surfaces real information the card was hiding. - Collections gets the same Cards/Table toggle, Sort by (Name/Launch window/Total cost), skeleton loading, and a context menu. No multi-select/bulk actions — a collection's only destructive action is delete, and it already goes through the same confirm-modal friction as everywhere else; there was no safe non-destructive bulk action to attach a selection UI to.
- Vendor Discovery turned out to already have most of this batch's intent built, just under different names: the existing compare-checkbox mechanism is a working multi-select, and the Compare tab is a real table view — building a second, parallel version of either would have been pure duplication. What it was actually missing: a Sort by control (Name/Category/Rating) on the "All vendors" list, skeleton loading (
SkeletonRow) in place of the spinner-text, andHoverPreviewon each compact row surfacing MOQ/lead time/certifications/capabilities/specialties that the row itself has no space to show. Price range was deliberately left out of the sort options — it's a free-text field (e.g. "$8-$12/unit"), and sorting it numerically would require guessing a parsing convention that could misrepresent vendors' actual pricing.
Multi-select + bulk actions were evaluated for Materials and Vendors too and skipped for the same underlying reason as Collections: there's no second safe, reversible bulk action on either page beyond single-item delete (materials and vendors have no archive concept), and manufacturing a bulk-delete shortcut would cut against the deliberate confirm-modal friction every delete in this app already has.
Production Orders rollout + Gantt (la-guia/src/pages/ProductionOrders.jsx): the List/Analytics pill toggle from an earlier batch gains a third real option, Gantt, using the GanttChart primitive built earlier — one bar per order spanning created_at → due_date, colored by stage, with a legend. Orders missing either date are excluded from the chart rather than guessed at (a rare case — this app doesn't let an order exist without a due date — but a real one, e.g. an order data-repaired directly in Supabase). Skeleton loading (SkeletonRow) replaces the old spinner-and-text. No multi-select, context menu, or HoverPreview here — every row's only interaction is "click through to the order," there's no per-item action to shortcut (status/checkpoint changes all happen on the order detail page, not this list) and no extra information a hover would surface that the row doesn't already show.
Content Hub: real month calendar (la-guia/src/pages/ContentHub.jsx, using the CalendarGrid primitive built earlier): the "Drop Calendar" tab's existing vertical timeline (content posts merged with production due dates, sorted chronologically) stays as the default Timeline view, now alongside a real Month view — an actual day-grid calendar, not another timeline dressed up. Each day cell shows a small platform-icon chip per scheduled post and a package icon per factory arrival landing that day; clicking an empty day opens the composer pre-filled with that date (9am default) instead of leaving the founder to type it in from scratch. Both views read from the same merged calendarItems data — the Month view doesn't introduce a second, parallel data path.
DesignDetail split-screen + breadcrumbs rollout (la-guia/src/pages/DesignDetail.jsx, plus VendorDetail.jsx, MaterialDetail.jsx, ProductInsights.jsx): Canvas and AI Studio used to be strictly mutually-exclusive tabs — opening AI Studio hid the canvas entirely, so applying an AI edit and comparing it against the live canvas meant tabbing back and forth. A new Split view toggle (topbar, shown only on the Canvas/AI Studio tabs) puts them side by side instead, using the Splitter primitive built earlier for a real drag-to-resize divider between the two panes. This isn't a second, parallel AI Studio — the same <AIStudioTab> instance and the same always-mounted canvas div (already kept alive with display:none when off-tab, so the Photopea iframe never reloads) are just reparented into a two-pane layout instead of being duplicated; the original tab-only AIStudioTab render is explicitly skipped while split view is showing its own copy, so nothing renders twice. Split view defaults to off, so nothing about the existing tab behavior changes unless a founder opts in. The other tabs (Inspiration, Image Variants, SKUs & Variants, History) were left as plain tabs — the plan's ask was specifically about the canvas/AI-Studio relationship, and splitting five panels at once would turn "compare an edit against the canvas" into a cramped, unreadable layout.
Breadcrumbs (built earlier) is now live on the four detail pages named in the plan — VendorDetail.jsx, MaterialDetail.jsx, ProductInsights.jsx, DesignDetail.jsx — each showing a real Home → list page → current-item trail above the existing page title.
Mobile responsive pass (la-guia/src/index.css, Sidebar.jsx, DesignDetail.jsx): a real breakpoint on the app shell, not a pixel-perfect redesign of all ~30 pages. Below 768px, the 258px-wide sidebar — previously always on-screen, permanently eating most of a phone's width — becomes a slide-in panel triggered by a fixed hamburger button, closing on backdrop click or on navigating to a page. .topbar gains flex-wrap so its action buttons wrap onto a second line instead of overflowing off-screen, and DesignDetail.jsx's canvas + Details/AI-Studio row (previously a fixed-width flex row, the exact case named in the plan) stacks vertically, with the desktop-only drag-to-resize Splitter hidden since a horizontal-resize handle has no sensible meaning between two vertically-stacked panels. The five main list pages (Design, Vendors, Materials, Collections, Production) and Home mostly already handled narrow widths correctly going into this batch — .grid-cards (repeat(auto-fill, minmax(240px,1fr))) and .grid-2/.grid-3 (collapsing to one column under 900px) were both real, working responsive rules from earlier in the project, not new work here.
Verified by checking the actual computed CSS at a 375px viewport (mobile-preset browser resize) rather than just reading the stylesheet: confirmed .mobile-nav-toggle switches to display: flex, .app-sidebar becomes position: fixed and correctly resolves translateX(-100%) to a real negative pixel offset equal to its width (off-screen), and .mobile-nav-backdrop renders. Full interaction (tapping the hamburger, watching the panel slide in) couldn't be exercised end-to-end pre-auth, same limitation as every other verification in this session — there's no login credential available to reach the authenticated app shell.
Readiness gate bypass: the two "80%+ factory readiness required" hard gates (starting a production order in ProductionOrders.jsx, requesting a quote in VendorDetail.jsx) are no longer absolute — when a product is under 80%, an explicit "I understand the risks and want to proceed anyway" checkbox appears next to the warning and unblocks the button once checked. The gate still defaults to blocking and resets itself whenever a different product is selected, so it stays a deliberate, one-time-per-attempt override rather than something that quietly stays bypassed.
Team Chat & AI Assistant (la-guia/src/components/FloatingChat.jsx, la-guia/src/context/ChatContext.jsx, mounted once in App.jsx's shell so it persists across every page): a circular button in the bottom-right corner opens a panel with two kinds of real, Supabase-backed conversations. Every founder gets one personal AI Assistant chat (chats.type = 'ai') — /api/chat-reply grounds its replies in a text summary of the brand's own products/vendors/quotes/production-orders/materials (the same "client assembles context, server just prompts" shape /api/dashboard-suggestions already used), gated behind useAIUsage() like every other AI feature, and it says so plainly when the brand data it was given doesn't answer the question rather than guessing. The rest are real group chats with any combination of teammates (chats.type = 'group') — pick anyone from the brand's active team members (including an "Add everyone" shortcut), no cap on participants. Visibility is enforced per-chat-membership in Postgres RLS (a new is_chat_member() helper), not just brand membership, so teammates on the same brand can't read each other's AI conversations or a group chat they weren't added to. There's no realtime infrastructure anywhere else in this app, so group chats poll for new messages every 8s while a thread is open rather than introducing Supabase Realtime as a one-off. If the AI Assistant entry doesn't show up in the panel (only the "+ New chat" affordance does), ChatContext.jsx now surfaces the real Postgres error as a banner instead of only console.error-ing it — almost always either migration 016 hasn't been run yet or the backend hasn't been restarted since it was.
AI credits (migration 028, api/config/aiCredits.js, la-guia/src/data/aiCredits.js, AIUsageContext.jsx, BillingTab.jsx, OutOfCreditsModal.jsx): AI access is metered as a per-brand credit balance, not a monthly call count — an image edit and a one-line chat reply cost wildly different amounts to serve, so counting "generations" priced them identically and left the expensive ones underwater. Costs are derived from real API spend under one invariant stated in api/config/aiCredits.js: no action may cost more than $0.005 of API spend per credit charged, so the worst case a customer can inflict — burning an entire balance on the priciest action — is bounded at credits x $0.005, which every plan and pack absorbs at >75% gross margin (the file carries the full per-product table).
- Server-authoritative.
api/config/aiCredits.jsis the only source of truth the backend enforces;la-guia/src/data/aiCredits.jsis a display-only mirror for "this costs N credits" labels and must be kept in sync by hand. Prices are never taken from the client — top-up checkout looks the pack up by id server-side. - Atomic debit.
metered()wraps every AI route: it verifies brand membership, then calls thedebit_ai_creditsPostgres function, which locks the balance rowFOR UPDATEso concurrent calls can't overspend. Subscription credits are spent before purchased top-ups. A handler that ends ≥400 auto-refunds onres.on('finish'), so a failed generation never costs anything. Out of credits returns402withcode: 'INSUFFICIENT_CREDITS', whichaiApi.jsturns into the top-up modal from any call site. - Two buckets.
subscription_creditsare SET (not accumulated) to the tier allowance on everyinvoice.paid, so Stripe retries are idempotent;topup_creditsare purchased one-off and persist until spent, guarded against double-crediting by checkingai_credit_ledgerfor the Stripe session id. Every movement is appended to that ledger with the resulting balance. - Clients can only read.
brand_ai_credits/ai_credit_ledgerhave SELECT-only RLS policies and no client insert/update path at all — balances change solely through the service-role API. This is why the backend now needsSUPABASE_SERVICE_ROLE_KEY. - Silhouette generation is user-selectable draft/standard/high quality, and each tier costs a very different amount of API spend, so the three are priced as separate features (5 / 15 / 50 credits) resolved from the request body rather than one flat price —
metered()accepts a(req) => featureKeyresolver for exactly this.
Alternate garment views (migration 029, designs.views): generating a back/side/detail view used to replace the canvas outright, destroying the front view to show the back. Views are now their own switchable tabs beside the canvas — the main canvas stays the front view (backed by design_versions as before) and each generated view is an entry in a designs.views jsonb array ({ key, label, imageUrl, psdUrl }). Switching tabs reopens that view's own file in place.
Layered working files per version (migration 030, design_versions.psd_url, designImages.js, PhotopeaEditor.jsx): a save used to store only a flattened PNG, with the layer stack living in a single rolling "Working file (PSD)" row — so restoring an older version, or opening any view other than the front, collapsed the design into one flat image and permanently lost its layers. Every design_versions row now carries both: image_url is the flattened thumbnail history and previews use, psd_url is the layered file actually reopened. PhotopeaEditor gained capturePsd()/openFile() alongside capture()/openImage() — posting raw bytes is the only path that reliably opens a PSD with its layer stack intact (app.open with a URL flattens). Two rules the display and restore paths depend on: surfaces that show images (previews, history, activity) skip rows labelled PSD_VERSION_LABEL, and restore is recency-first — the newest real save wins, with layers preferred only within that save. Preferring "whichever row has a layered file" instead is what used to reopen the stale legacy rolling row, i.e. designs coming back as their first generated version. Saving degrades rather than fails: if the PSD capture or the psd_url column isn't available, the flattened save still lands.
Group chats & teammate names (migration 031): two fixes on top of the Team Chat feature below. Group chat creation failed with an RLS violation because 022's INSERT policy compared bm.brand_id = brand_id inside a subquery over brand_members — since that table has a brand_id column, the unqualified name bound to the subquery's own column instead of the new row's, making the member branch both meaningless and a quiet cross-brand hole. 031 rewrites it with chats.brand_id qualified explicitly, and adds a create_group_chat() SECURITY DEFINER RPC (mirroring ensure_personal_ai_chat) that makes the access decision in one place and creates the chat plus its participant rows in a single transaction, so a chat can never be orphaned without members. Separately, teammates were displayed as raw invite emails — user_preferences.full_name can't help, since RLS scopes it to its own user — so the name now lives on brand_members.display_name where the rest of the brand can read it, seeded from the user's profile when they claim an invite, promptable via MemberNamePrompt, and set across every brand they belong to at once.
Production hardening (api/index.js top, la-guia/src/main.jsx): helmet security headers (CSP and cross-origin isolation off on purpose — this is a pure cross-origin JSON API also used for redirect-based OAuth); a CORS allowlist from ALLOWED_ORIGINS that stays permissive-with-a-warning while unset so nothing breaks before it's configured; trust proxy set to one hop so req.ip is the real client behind Railway's proxy; and three tiers of rate limiting — broad (600/15min), strict on the AI paths (20/min, since each call costs real money) and tight on email endpoints (15/15min, since abuse there means spam from your domain) — with webhooks and /health exempt so Stripe/Shopify server-to-server bursts are never dropped. CORS alone was never the protection here: it only stops browser cross-site calls, and scripts ignore it. Sentry is wired on both sides, active only when a DSN is set (SENTRY_DSN / VITE_SENTRY_DSN); the frontend sends errors only, no tracing or replay, and ?sentry-test=1 on any page fires a deliberate test error to verify a deploy reaches the dashboard.
Mobile (la-guia/capacitor.config.json, la-guia/MOBILE.md): the same React/Vite bundle ships to iOS and Android through Capacitor — no UI rewrite, the native shell loads the built web bundle, and the WebGL intro is auto-skipped on device. Platform projects aren't generated in this repo (each must be built on its own OS); see MOBILE.md for the one-time setup and the appId warning before a first store submission.
Real, needs your own keys to actually process/send:
Billing & subscription plans (Free / Basic / Premium) — real Stripe Checkout, Customer Portal, one-time credit top-ups, and plan-limit enforcement (active products and team seats, both checked in the UI; AI spend is enforced server-side as credits — see AI credits above). Sales data — real Shopify / WooCommerce / Etsy connections. Team invite emails and campaigns — real Resend delivery (see Gotchas below for its free-tier limits). Every AI feature needs OPENAI_API_KEY — gpt-5.4-mini for text, gpt-image-1 for images; vendor search additionally needs TAVILY_API_KEY. Nothing in plans.js is marketing-only: the old roadmap: true "Coming soon" plan lines were removed rather than shown as promises, so every feature listed on a tier is something the app does today.
Still static mock data: none. la-guia/src/data/mockData.js survives only as a shared STAGES constant imported by Home.jsx and Welcome.jsx — every page, ContentHub.jsx included, now reads real Supabase data.
You need access to your Supabase project. Run these in the SQL Editor in order:
supabase/migrations/INITIAL_SCHEMA.sql(core tables: brands, collections, products, designs, tech_packs, vendors, quotes, production_orders, materials, notifications — with RLS)supabase/migrations/002_vendors_and_quotes.sqlsupabase/migrations/003_vendor_enhancements.sqlsupabase/migrations/004_production_orders.sqlsupabase/migrations/005_ai_silhouette.sqlsupabase/migrations/006_user_preferences.sql(per-user theme/onboarding/preferences)supabase/migrations/007_teams_and_rls.sql— required for multi-brand switching, team members, and global search to work; also enables row-level security on every table that didn't have it (everything exceptnotificationswas previously wide open to any authenticated client). Run this even on an existing project.supabase/migrations/008_billing.sql(plan_tier + Stripe IDs onbrands,ai_usage_logfor metering)supabase/migrations/009_shopify.sql(Shopify connection + order sync tables)supabase/migrations/010_favorites.sql(is_favoriteonproducts, powers the Favorite projects dashboard widget)supabase/migrations/011_design_studio.sql— required for AI Design Studio and the Home dashboard's sticky notes: addsmoodboard/palette/variantscolumns todesigns, newdesign_versionsanddesign_commentstables (with RLS), and a newbrand_notestable (with RLS) for the 3-slot sticky notes.supabase/migrations/012_feedback.sql— required for the Home dashboard's Suggestion Inbox: newfeedback_submissionstable (with RLS).supabase/migrations/013_tech_pack_builder.sql— required for the Tech Pack Builder: addsconstruction/print_placements/trims/labels/packaging/material_usage/manufacturing_notes/compliance_notes/questionnaire/approval columns totech_packs, and a newtech_pack_versionstable (with RLS).supabase/migrations/014_product_management.sql— required for Product Management: a newcategoriestable (with RLS),status/colorways/sizescolumns onproducts, and newproduct_variants/product_stage_historytables (with RLS). The frontend degrades gracefully without it (falls back to an unfiltered product list and empty category list) so an out-of-date DB won't blank the whole app, but Categories, SKUs & Variants, Archive, and lifecycle history won't work until it's run.supabase/migrations/015_vendor_platform.sql— required for the Vendor Platform:certifications/capabilities/price_range/verified/verified_notes/onboarding_stagecolumns onvendors.supabase/migrations/016_chat.sql— required for Team Chat & the AI Assistant: newchats/chat_participants/chat_messagestables (with RLS keyed off per-chat membership, not just brand membership, via a newis_chat_member()helper) and the floating chat button won't load without it.supabase/migrations/017_rfq_and_quote_economics.sql— required for RFQs & Quote Economics: a newrfqstable (with RLS) plusquotes.rfq_idfor sending one request to multiple vendors at once, a newquote_negotiationstable (with RLS) for the counter-offer log, andcost_breakdown/landed_cost_inputs/cost_simulatorjsonb columns onquotes.supabase/migrations/018_sampling.sql— required for Sampling: newsamples/sample_images/sample_annotations/sample_fit_feedbacktables (with RLS) — the Sampling page and/sampling/:productIdwon't load without it.supabase/migrations/019_production_tracking.sql— required for Production's shipment tracking, QC/issues, and inventory tabs:carrier/tracking_number/tracking_url/shipped_at/delivered_at/received_unitscolumns onproduction_orders, plus newproduction_issues/production_updatestables (with RLS). The core stage-change path degrades gracefully without it (falls back to updating juststage) so an out-of-date DB doesn't break the Kanban flow every other feature depends on.supabase/migrations/020_materials_library.sql— required for the Materials Library's new fields and tabs:type/sustainability_info/certifications/availabilitycolumns onmaterials, real insert/update/delete RLS policies onmaterials(it was select-only before this —deleteMaterial()had nothing to actually delete with), and new brand-scopedmaterial_cost_log/material_vendorstables (with RLS).supabase/migrations/021_chat_ai_creation_rpc.sql— repair path for personal AI chat creation: addsensure_personal_ai_chat(), a SECURITY DEFINER function that centralizes the brand-access decision and then creates-or-returns the user's one AI chat, for projects that could pass the chat SELECT policy but fail the direct client INSERT.supabase/migrations/022_chat_rls_policy_repair.sql— recreates thechatsINSERT policy with explicit brand-owner/member checks so chat creation no longer depends on a stale or differently-definedhas_brand_access()helper (21's RPC doesn't help a deployed browser bundle that hasn't been rebuilt and still uses the direct INSERT path). Superseded by 031 — this version has thebrand_idbinding bug described there; run 031 as well.supabase/migrations/023_production_payments.sql— required for Financial Tools' Cash Flow and Manufacturing Cost History tabs, and for the Production Order Payments tab / break-even math onProductInsights.jsxto actually persist anything: createsproduction_payments(with RLS), which those three features had been reading and writing since an earlier session with no migration ever creating the table.supabase/migrations/024_ecommerce_platforms.sql— required for WooCommerce/Etsy (next up) and Product Publishing: addsapi_key/refresh_token/token_expires_attostore_connections(WooCommerce's consumer key, Etsy's refresh flow — Shopify's tokens don't expire so this wasn't needed until now), and a newplatform_listingstable (with RLS) tracking which product was published to which platform.supabase/migrations/025_woocommerce_and_multiplatform_sales.sql— required for WooCommerce (and any second sales platform generally): adds aplatformcolumn tosales_dataand moves its unique constraint from(brand_id, product_id, month)to(brand_id, product_id, month, platform)— without this, connecting a second storefront silently overwrites the first's revenue rows for any month/product they both sold.supabase/migrations/026_marketing_foundation.sql— required for Content Hub (social_accounts/content_postsfinally have a real table), and for the upcoming Influencer CRM/Email Campaigns/Launch Planner: addsinfluencers/influencer_deals/email_contacts/email_campaigns(all with RLS) andproducts.launch_date/launch_plan.supabase/migrations/027_qol_uiux_foundation.sql— required for the new Comments panels (Vendor/Quote/Tech Pack detail pages), the unified Pinned system, Design fabric tags, and the customizable dashboard: addscomments/pinned_items(both with RLS),designs.fabric_tags, anduser_preferences.dashboard_layout.supabase/migrations/028_ai_credits.sql— required for every AI feature: createsbrand_ai_credits(the per-brand balance) andai_credit_ledger(append-only audit trail), plus the four functions the backend calls —debit_ai_credits(row-locked, subscription bucket first),refund_ai_credits,grant_subscription_credits(SET semantics, so Stripe retries are idempotent) andadd_topup_credits. Both tables are SELECT-only under RLS; only the service-role API can change a balance. Seeds every existing brand with its tier's allowance so nobody is locked out on deploy. Without this, every metered endpoint returns a credit-system error.supabase/migrations/029_design_views.sql— required for alternate garment views: addsdesigns.viewsjsonb.supabase/migrations/030_design_version_psd.sql— required for layers surviving a save/restore: addsdesign_versions.psd_url. The save path degrades gracefully without it (retries the write without the column and stores a flattened image only), so an out-of-date DB loses layers rather than losing saves.supabase/migrations/031_group_chat_and_member_names.sql— required for group chats and teammate display names: rewrites 022'schatsINSERT policy with the new row qualified explicitly (fixing both an RLS failure on creation and a quiet cross-brand hole), adds thecreate_group_chat()RPC, and addsbrand_members.display_namewith a policy letting a member rename themselves.
Migrations 002–020 use IF NOT EXISTS/ADD COLUMN IF NOT EXISTS, so they're safe no-ops on a DB that already has those columns — run them anyway on a fresh project, in order. 021–031 are likewise written to be re-runnable (create or replace, drop policy if exists, add column if not exists).
- Storage bucket: A public bucket named
mockupsmust exist — it holds Design Studio snapshots, tech pack images, moodboard uploads, product assets and the layered.psdworking files. If you restrict the bucket's allowed MIME types, includeimage/vnd.adobe.photoshop, or every save silently falls back to a flattened image with no layers. Give it enough of a file-size limit for a real layered document (a PNG snapshot is small; its PSD is not). - Auth: "Confirm email" should be disabled in Auth settings for local testing.
cd api
npm install
node index.jsCreate api/.env:
PORT=3001
# Required for auth + AI credits — every metered AI endpoint fails without these
SUPABASE_URL=...
SUPABASE_SERVICE_ROLE_KEY=...
OPENAI_API_KEY=...
GEMINI_API_KEY=... # optional: text-only failsafe if OpenAI is down
TAVILY_API_KEY=...
STRIPE_SECRET_KEY=...
STRIPE_WEBHOOK_SECRET=...
SHOPIFY_CLIENT_ID=...
SHOPIFY_CLIENT_SECRET=...
RESEND_API_KEY=...
OAUTH_STATE_SECRET=...
INSTAGRAM_CLIENT_ID=...
INSTAGRAM_CLIENT_SECRET=...
TIKTOK_CLIENT_KEY=...
TIKTOK_CLIENT_SECRET=...
ETSY_KEYSTRING=...
YOUTUBE_CLIENT_ID=...
YOUTUBE_CLIENT_SECRET=...
PINTEREST_CLIENT_ID=...
PINTEREST_CLIENT_SECRET=...
# Deployment (safe to omit locally — defaults are localhost)
APP_URL=https://your-frontend
API_URL=https://your-api
ALLOWED_ORIGINS=https://your-frontend
SENTRY_DSN=...
STRIPE_PRICE_BASIC/STRIPE_PRICE_PREMIUM get written into this same file automatically by the billing setup script below.
SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY: the backend uses these for two things it can't do with an anon key — validating the caller's JWT on every metered route, and moving AI credits (grant/debit/refund/top-up), which are deliberately unreachable from any client. Without them requireAuth fails closed with a clear 500 rather than letting requests through unauthenticated, so every AI feature stops working. The service-role key bypasses RLS entirely: keep it server-side, never in la-guia/.env.local.
OPENAI_API_KEY: powers every image feature — AI Design Studio's transform and addition tools, and silhouette generation — via gpt-image-1. Without it those tools return "OPENAI_API_KEY is not set" inline in the tool card. Text AI runs on the same key (gpt-5.4-mini), so a bad key breaks text too — unless GEMINI_API_KEY is set, in which case text silently fails over and only images stay broken.
STRIPE_WEBHOOK_SECRET: required for AI credits to be granted at all. Subscriptions grant credits on invoice.paid and top-ups on checkout.session.completed, both of which arrive only by webhook — see the Billing section below.
OAUTH_STATE_SECRET: any random string, used to HMAC-sign the state param on every OAuth connect flow (Shopify today; WooCommerce/Etsy/Instagram/TikTok/YouTube/Pinterest as they're added). Falls back to an insecure hardcoded dev default if unset, so local dev works without it, but set a real one before deploying anywhere real — without it, the CSRF protection those flows depend on doesn't actually protect anything.
INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET/TIKTOK_CLIENT_KEY/TIKTOK_CLIENT_SECRET: power the Content Hub's Instagram/TikTok connect buttons (/api/social/auth/:platform, /api/social/callback/:platform). These were already required by the code before this session but were missing from this README — genuinely undocumented until now.
ETSY_KEYSTRING: get one from developer.etsy.com — creating an app is self-serve and near-instant for the read scopes this integration uses (transactions_r listings_r listings_w shops_r). Register your callback URL there as {API_URL}/api/etsy/callback. Unlike Shopify/WooCommerce there's no separate secret — Etsy's OAuth flow authenticates via PKCE instead, so the keystring is the only value you need.
YOUTUBE_CLIENT_ID/YOUTUBE_CLIENT_SECRET: a Google Cloud Console OAuth client (self-serve) with the YouTube Data API v3 enabled. Register {API_URL}/api/social/callback/youtube as an authorized redirect URI.
PINTEREST_CLIENT_ID/PINTEREST_CLIENT_SECRET: from developers.pinterest.com (self-serve). Register {API_URL}/api/social/callback/pinterest as a redirect URI.
ALLOWED_ORIGINS (deployment): comma-separated origin allowlist for CORS. Left unset it stays open to all origins and logs a warning on boot, so local dev and a first deploy don't break before it's configured — set it to your real frontend origin(s) once you have one. Note this is not your abuse protection: CORS only constrains browsers, and the rate limiters are what actually stand between the AI endpoints and a script.
The API loads api/.env first and also tolerates keys placed in la-guia/.env.local; existing process-env values always win, so Railway/host secrets are never overwritten by a stray local file. Restart node index.js after changing either env file.
cd la-guia
npm install
npm run devCreate la-guia/.env.local with VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY (the anon key — never the service-role one; this file ships to the browser). Two optional extras: VITE_API_URL if the backend isn't on http://localhost:3001, and VITE_SENTRY_DSN to enable frontend error reporting (production builds only).
Open http://localhost:5173. Both servers must be running.
- Add
STRIPE_SECRET_KEY=sk_...toapi/.env. - From
api/, runnode scripts/setup-stripe-products.jsonce — creates the Basic ($29/mo) and Premium ($79/mo) Products/Prices in your Stripe account and writesSTRIPE_PRICE_BASIC/STRIPE_PRICE_PREMIUMback intoapi/.env. Safe to re-run. - Run
node scripts/setup-stripe-webhook.jsonce — creates (or updates, never duplicates) an endpoint at{API_URL}/api/stripe/webhooksubscribed toinvoice.paid,customer.subscription.deletedandcheckout.session.completed, and prints theSTRIPE_WEBHOOK_SECRETto add toapi/.env. Locally, usestripe listen --forward-to localhost:3001/api/stripe/webhookinstead — it prints its own signing secret. - Restart the backend so it picks up the new env vars.
- (Optional) add
APP_URLtoapi/.envif the frontend isn't onhttp://localhost:5173— it's used to build Stripe Checkout redirect URLs and the Resend invite link.
The webhook is not optional any more. Plan changes still reconcile without it — checkout confirmation and subscription-status both call Stripe directly from the backend and write the result to Supabase under the signed-in user's own session — but AI credits are only ever granted by webhook: invoice.paid sets that cycle's subscription allowance, checkout.session.completed adds a purchased top-up, and customer.subscription.deleted zeroes the grant on cancellation. Without a working webhook, subscriptions upgrade the plan tier but never fund it, and paid top-ups never arrive. A cancellation made through the Stripe portal still takes effect in the UI the next time the founder opens Settings > Billing (that's when the reconciliation check runs), not instantly.
Test and live mode are separate universes — separate keys, products, prices and webhook secrets. Re-run both setup scripts against the live key when you switch, and put the test key back in api/.env afterwards so local dev stays in test mode. LAUNCH.md walks through that changeover step by step.
Testing plan-gated features locally: npm run dev (Vite dev mode) shows a "Developer tools" block at the bottom of Settings > Billing & Plan with Force Free / Force Basic / Force Premium buttons — these write plan_tier directly, bypassing Stripe entirely, so you can test each tier's gating without a real Checkout session. Gated behind import.meta.env.DEV, so it never renders in a production build.
Add SHOPIFY_CLIENT_ID/SHOPIFY_CLIENT_SECRET from a Shopify Custom App to api/.env. Connect a store from Settings once both servers are running — /api/shopify/auth starts the OAuth flow and /api/shopify/callback completes it.
Add RESEND_API_KEY to api/.env. Without it, invites still create a real brand_members row but the email send is skipped (logged as a warning) — the invited person is still attached automatically the next time they sign up/log in with that email.
Every AI endpoint listed in AI_PATHS (the generation/analysis ones) is authenticated and metered: it requires a valid Supabase JWT as Authorization: Bearer <token>, a brandId in the body the caller actually belongs to, and enough credits — which metered() debits before the handler runs and refunds if it fails. la-guia/src/lib/aiApi.js's aiPost() attaches the token, the active brand id and the brand's philosophy profile automatically, so call sites just pass their own fields. Billing endpoints require the JWT but aren't metered; webhooks and /health are exempt from both auth and rate limiting (they're verified by signature instead).
| Endpoint | Purpose |
|---|---|
/api/analyze-design |
Scores a captured canvas snapshot |
/api/generate-tech-pack |
Generates BOM + graded measurements from canvas (used by DesignDetail's quick "Auto-Generate Tech Pack") |
/api/generate-tech-pack-full |
Tech Pack Builder's questionnaire-driven generator — { imageBase64?, category, answers }, returns every section (BOM, measurements, construction, print placements, trims, labels, packaging, material usage, notes) |
/api/parse-vendor |
Extracts structured profile (incl. certifications, capabilities, price range) from pasted text |
/api/search-vendors |
{ keywords?, category?, location?, quantity?, moq?, targetPrice?, certifications?, imageBase64? } — structured-filter web search via Tavily + model extraction (certifications/capabilities/price range included), optionally weighted by an attached design image |
/api/analyze-vendor-fit |
Scores vendor/product material & economic fit |
/api/draft-vendor-email |
Drafts a structured vendor outreach email |
/api/dashboard-suggestions |
Generates 2–4 short, data-grounded suggestions for the Home dashboard's AI suggestions widget |
/api/chat-reply |
{ message, history, brandContext } — conversational reply for the floating AI Assistant chat, grounded in a client-assembled brand data summary |
/api/quote-economics |
{ vendor, product, quote, bom } — real BOM-derived fabric cost/%, AI splits the rest into labor/shipping/packaging/profit %, plus a shipping cost and duty-rate estimate |
/api/cost-simulator |
{ vendor, product, quote, bom } — AI-estimated per-unit $ delta for each of a fixed set of apparel cost levers (GSM increase, add embroidery, switch to organic cotton, move sourcing region, etc.) |
/api/create-checkout-session |
Starts a Stripe Checkout session for a plan upgrade |
/api/confirm-checkout |
Verifies a completed Checkout session before the frontend writes the new plan |
/api/create-portal-session |
Opens Stripe's Customer Portal for managing/cancelling a subscription |
/api/subscription-status |
Reconciles a brand's plan against the live Stripe subscription status |
/api/create-topup-session |
Starts a one-time Checkout for an AI credit pack — the price is looked up server-side by pack id, never taken from the client |
/api/stripe/webhook |
Signature-verified Stripe events: grants the cycle's AI credits on invoice.paid, adds purchased credits on checkout.session.completed, downgrades and zeroes the grant on customer.subscription.deleted |
/api/shopify/auth |
Starts the Shopify OAuth flow for a brand |
/api/shopify/callback |
Completes Shopify OAuth and stores the access token |
/api/shopify/fetch-orders |
Pulls recent orders for Sales Dashboard analytics |
/api/shopify/fetch-inventory |
Read-only stock levels per SKU, for the Inventory tab's storefront comparison |
/api/woocommerce/validate |
Verifies a Consumer Key/Secret against the store before it's saved |
/api/woocommerce/fetch-orders |
Pulls recent orders for Sales Dashboard analytics |
/api/woocommerce/fetch-inventory |
Read-only stock levels per SKU |
/api/woocommerce/publish-product |
Creates a real draft product on the connected store — only ever called after an explicit UI confirmation |
/api/etsy/auth |
Starts Etsy's OAuth2 + PKCE flow for a brand |
/api/etsy/callback |
Completes the PKCE token exchange and resolves the connecting account's shop |
/api/etsy/refresh-token |
Refreshes an hourly-expiring Etsy access token |
/api/etsy/fetch-orders |
Pulls recent receipts, normalized into the same shape every platform's orders use |
/api/etsy/fetch-inventory |
Read-only stock levels per SKU |
/api/etsy/publish-listing |
Creates a real draft listing (text only, no images yet) — only ever called after an explicit UI confirmation |
/api/tiktokshop/auth |
Always returns "not available yet" — TikTok Shop's Partner API isn't self-serve, so this is an honest stub, not a real connection |
/api/oauth/consume |
One-time exchange of a short-lived handoff code for the OAuth token it represents — see the OAuth security hardening note above |
/api/social/auth/:platform |
Starts the OAuth flow for instagram/tiktok/youtube/pinterest |
/api/social/callback/:platform |
Completes the OAuth flow and hands off the real access token (previously discarded — see the Marketing foundation note above) |
/api/social/publish/:platform |
Real publish attempt where the connection has write access (Pinterest); honest rejection otherwise (Instagram/TikTok/YouTube) |
/api/send-campaign |
Sends a real email campaign via Resend, one call per recipient, reporting per-recipient failures |
/api/send-invite |
Dispatches teammate invitation emails via Resend |
/api/send-vendor-email |
Sends a founder-reviewed outreach email to a vendor via Resend |
/health |
Liveness probe — exempt from rate limiting |
/api/design/ai-image |
AI Design Studio's transform endpoint (gpt-image-1) — { mode, prompt, images }, one of 10 modes (sketch-to-design, polish-design, ai-edit, bg-remove, recolor, fabric-swap, mockup, flat-sketch, view, variant), edits the given reference image, returns base64 |
/api/design/generate-element |
AI Design Studio's addition endpoint (gpt-image-1) — { mode, prompt, quality? }, mode is add-element, pattern or silhouette, no reference image, returns base64 with real alpha. quality (low/medium/high) applies to silhouettes and sets the credit price |
/api/design/color-palette |
Suggests a 5-color palette from a design image or a text brief |
/api/design/trend-inspiration |
Tavily-grounded design trend research for a garment category |
Before charging real money, work through LAUNCH.md — it's the operational checklist (Stripe webhook + live mode, Railway env vars, Supabase auth emails, Resend domain verification) with a verify step per item. The items below are product gaps rather than launch blockers.
- No automated tests anywhere —
npm testinapi/is still the placeholder that exits 1. Verification in this project has been manual, against a running dev server. - Etsy listings publish as text only — image upload is a separate multipart endpoint that isn't wired up, so photos get added in Etsy afterward. Etsy also requires a real numeric taxonomy id, which the form asks for rather than guessing on a live listing.
- TikTok Shop is an intentional stub — its Partner API isn't self-serve, so
/api/tiktokshop/authreturns "not available yet" rather than redirecting somewhere that might be wrong. - Instagram/TikTok publishing is read-scope only — real content-publish permissions need a business-verified app review this integration doesn't have. Pinterest publishes for real; YouTube has an upload scope but
content_postsstores no video file. - No engagement/reach data anywhere in Marketing — the tables have no such columns, so Campaign Analytics reports counts (scheduled/posted/failed, campaigns sent, recipients) and says plainly that click/open tracking isn't wired up.
- Phase 3: Sales Dashboard now pulls real Shopify orders where connected; break-even/product-performance math still assumes a connected store — brands without one see the dashboard shaped around what it looks like once they connect.
- Task 4.1: Inventory risk math engine based on sales velocity and brand risk profile — now unblocked by real Shopify order data, not yet built.
- Home dashboard: the AI suggestions widget is cached once per brand per calendar day (a manual "Refresh" re-runs it) so opening the dashboard doesn't silently spend AI usage on every visit.
- Never commit
node_modules. - Gemini Search grounding needs billing — which is why vendor search uses Tavily.
- Every image feature needs
OPENAI_API_KEY(gpt-image-1) — AI Design Studio's transform and addition tools, plus silhouette generation. A missing or unauthorized key surfaces as an inline error in that tool's card; text features use the SAME key now, so a bad key breaks everything, not just images. - Image generation is the only real cost centre — text calls run on
gpt-5.4-mini(orgemini-flash-lite-latestwhen failing over) at a fraction of a cent. If you change a prompt'ssize,qualityorbackgroundinapi/index.js, re-check the credit price for that feature inapi/config/aiCredits.jsagainst the $0.005-per-credit ceiling, and mirror any change intola-guia/src/data/aiCredits.js. The two files are kept in sync by hand — nothing enforces it. - A credit price change is not retroactive —
TIER_CREDITSis applied on the nextinvoice.paid, so existing subscribers keep their current allowance until their cycle renews. - Photopea resizing — the container doesn't reliably resize; use the capture/remount pattern in
DesignDetail.jsx. - Resend testing — on the free tier without a verified domain, Resend only allows sending emails to the address you signed up with; invites to any other address will silently fail to deliver (the
brand_membersrow is still created correctly). - RLS was off almost everywhere before
007_teams_and_rls.sql— if you forked this project earlier and skipped that migration, any authenticated client could read/write any brand's data. Run it. - "Continue where you left off" is tracked in
localStorage, per brand, per browser — it doesn't sync across devices since there's no server-side "last viewed" column. - Sticky notes' "active slot" (which of the 3 notes is shown large) is tracked in
localStoragetoo, per brand, per browser, for the same reason — the note content is real and synced viabrand_notes, only which one is currently "large" is local. - The canvas restore rule is recency-first, and it's load-bearing. The newest non-
PSD_VERSION_LABELrow wins; layers are preferred only within that row. If you ever reorder that logic to "prefer whichever row has a layered file," designs start reopening as their first generated version again (the legacy rolling working-file row is frozen at an old save and no longer updated). Display surfaces must keep skipping thePSD_VERSION_LABELrow, or previews and history show a working file instead of a picture. - Photopea resizing/duplication — content is pushed into the iframe at most once per document (
lastLoadedRef), so a fullscreen toggle or a prop identity change can't open a second document over the founder's work. If you touch that effect, verify a split-view toggle mid-edit doesn't reopen the canvas. Captures carry a token each, because autosave chains a PNG and a PSD capture back to back and a stale timeout could otherwise reject the newer one. - The backend's OAuth state is in-memory — the handoff codes and Etsy PKCE verifiers live in plain
Maps (this service has no database of its own). That assumes one API instance: scale it horizontally and a connect flow that starts on instance A and returns on instance B will fail with "expired or already used." A restart mid-connect does the same. Both are recoverable by connecting again; if you need real horizontal scaling, those two Maps are what has to move to shared storage. OAUTH_STATE_SECRETfalls back to a hardcoded dev default if unset, so local dev works out of the box — but that value is in this repo, meaning the CSRF signature on every connect flow is forgeable until you set a real one. Set it before deploying anywhere real.