← back to Whatsmystyle

scripts/resale-guardrails.js

101 lines

/**
 * Resale guardrails — scaffold only, no resale UX shipped yet.
 *
 * Per the 2026-05-12 dream-team verdict on "should WhatsMyStyle help
 * users resell closet items": HOLD until three guardrails are in place.
 *
 *   1. Circularity check — an item is eligible for a resale nudge only if:
 *        - it's been in the closet ≥ 90 days, OR
 *        - the user has confirmed "I've worn this >5 times", OR
 *        - the user has confirmed "this no longer fits/I will not wear again"
 *      Goal: prevent the app from accelerating fast-fashion churn.
 *
 *   2. Repair / recycle FIRST — before showing any sell-this CTA, the user
 *      must dismiss two options:
 *        a) repair_link  — link to a local tailor/cobbler (Google Maps
 *           Places query "tailor near me" or "cobbler near me")
 *        b) recycle_link — link to a textile take-back program
 *           (Patagonia Worn Wear, Eileen Fisher Renew, For Days Take-Back,
 *           local Goodwill / textile recycle dropoff)
 *      Only after the user dismisses BOTH do we surface the sell CTA.
 *
 *   3. Privacy limits — resale events are EPHEMERAL. We don't write to
 *      outfit_picks, we don't write to duels, we don't update the taste
 *      vector. We log only the local-time + closet_id + action verb to
 *      data/resale-events.jsonl for the user's own export — never the
 *      server's analytical use.
 *
 * Until the UX ships, this module just exposes the eligibility check +
 * resource links so the rest of the codebase can reason about it.
 */

const fs = require('fs');
const path = require('path');

const DATA_DIR = path.join(__dirname, '..', 'data');
const EPHEMERAL_LOG = path.join(DATA_DIR, 'resale-events.jsonl');

// --- Guardrail 1: circularity ---------------------------------------------
function isEligibleForResaleNudge(closetItem, userConfirmation = {}) {
  const ageMs = Date.now() - Date.parse(closetItem.created_at || closetItem.acquired_at || 0);
  const ageDays = ageMs / (24 * 60 * 60 * 1000);
  if (ageDays >= 90) return { ok: true, reason: 'age>=90d' };
  if (userConfirmation.worn_more_than_5) return { ok: true, reason: 'user-confirmed worn 5+' };
  if (userConfirmation.no_longer_fits) return { ok: true, reason: 'user-confirmed no longer fits' };
  if (userConfirmation.never_will_wear) return { ok: true, reason: 'user-confirmed will not wear' };
  return { ok: false, reason: `${Math.round(ageDays)}d old + no user confirmation; nudge blocked` };
}

// --- Guardrail 2: repair / recycle ----------------------------------------
// Hand-curated take-back programs. Add to this list as we vet new ones —
// each one MUST be a real program that accepts garments from the public.
const TAKEBACK_PROGRAMS = [
  { name: 'Patagonia Worn Wear', url: 'https://wornwear.patagonia.com/', accepts: 'Patagonia only', kind: 'repair+resell' },
  { name: 'Eileen Fisher Renew',  url: 'https://www.eileenfisherrenew.com/', accepts: 'Eileen Fisher only', kind: 'repair+resell' },
  { name: 'Levi\'s SecondHand',   url: 'https://secondhand.levi.com/', accepts: "Levi's only", kind: 'resell' },
  { name: 'For Days Take Back',   url: 'https://fordays.com/pages/take-back-bag', accepts: 'any brand, any condition', kind: 'recycle' },
  { name: 'TerraCycle (varies)',  url: 'https://www.terracycle.com/en-US/', accepts: 'depends on local program', kind: 'recycle' },
  { name: 'Local Goodwill',       url: 'https://www.goodwill.org/locator/', accepts: 'any wearable garment', kind: 'donate' },
  // ---- v12 expansion 2026-05-12 — +6 vetted programs ----
  { name: 'Madewell Denim Recycling', url: 'https://www.madewell.com/inspo-do-well-denim-recycling-landing.html', accepts: 'any-brand denim → insulation', kind: 'recycle' },
  { name: 'REI Used Gear',        url: 'https://www.rei.com/used',                                  accepts: 'REI-purchased outdoor/active', kind: 'resell' },
  { name: 'ThredUp Cleanout Kit', url: 'https://www.thredup.com/cleanout',                          accepts: 'any women\'s+kids brand', kind: 'resell' },
  { name: 'The North Face Renewed', url: 'https://www.thenorthfacerenewed.com/',                    accepts: 'TNF only', kind: 'repair+resell' },
  { name: 'Nike Reuse-A-Shoe',    url: 'https://www.nike.com/sustainability/reuse-a-shoe',           accepts: 'any athletic shoe → court material', kind: 'recycle' },
  { name: 'H&M Garment Collecting', url: 'https://www2.hm.com/en_us/sustainability-at-hm/our-work/close-the-loop.html', accepts: 'any brand, any condition (in-store bins)', kind: 'recycle' },
];

function repairAndRecycleOptions({ brand, city } = {}) {
  const brandLower = (brand || '').toLowerCase();
  const brandMatch = TAKEBACK_PROGRAMS.filter(p => p.accepts.toLowerCase().includes(brandLower) && brandLower);
  const general = TAKEBACK_PROGRAMS.filter(p => p.kind === 'recycle' || p.kind === 'donate' || /any brand/.test(p.accepts));
  const tailorSearch = city
    ? `https://www.google.com/maps/search/tailor+near+${encodeURIComponent(city)}`
    : 'https://www.google.com/maps/search/tailor+near+me';
  const cobblerSearch = city
    ? `https://www.google.com/maps/search/shoe+repair+near+${encodeURIComponent(city)}`
    : 'https://www.google.com/maps/search/shoe+repair+near+me';
  return {
    repair: { tailor: tailorSearch, cobbler: cobblerSearch },
    takeback: [...brandMatch, ...general].slice(0, 5),
  };
}

// --- Guardrail 3: ephemeral logging ---------------------------------------
function logResaleEventEphemeral({ user_id, closet_id, action }) {
  try {
    fs.mkdirSync(DATA_DIR, { recursive: true });
    fs.appendFileSync(EPHEMERAL_LOG, JSON.stringify({
      ts: new Date().toISOString(),
      user_id, closet_id, action,
    }) + '\n');
  } catch {}
}

module.exports = {
  isEligibleForResaleNudge,
  repairAndRecycleOptions,
  logResaleEventEphemeral,
  TAKEBACK_PROGRAMS,
};