subs-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 1ae4feb2bebf
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 5af1a249bbbef1db1b4c5ec8
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
src/Editor.tsx
// Adding or changing a subscription. The name field suggests popular services (with their website,
// category and, for some, a typical price to check), and shows right away when the OpenApps store has
// a free alternative. Everything else is a few taps: price, how often, the next date, free trial.
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useStore, saveSubs, getState } from "./lib/store";
import { suggest, serviceNamed, type Service } from "./lib/services";
import { useFreeApps, freeAlternative, type FreeApp } from "./lib/alternatives";
import { addDays, addMonths, dayKey, isDayKey } from "./lib/dates";
import { fromMinor, money, toMinor, minorDigits } from "./lib/money";
import { CATEGORIES, MAX_METHOD, MAX_NAME, MAX_NOTES, MAX_URL, blank, cycleLabel, isCategory, isUnit, problem, type Category, type Sub, type Unit } from "./lib/subs";
import { toast } from "./ui";
import { Out } from "./icons";

type Cycle = "week" | "month" | "year" | "custom";
const CYCLES: { v: Cycle; label: string }[] = [
  { v: "week", label: "Weekly" }, { v: "month", label: "Monthly" }, { v: "year", label: "Yearly" }, { v: "custom", label: "Custom" },
];

/** A new subscription filled in from a link (/add?name=Netflix&price=15.49&cycle=monthly&next=2026-11-03&trial=1). */
export function draftFromQuery(q: URLSearchParams, currency: string): Sub | null {
  const name = (q.get("name") || "").trim().slice(0, MAX_NAME);
  if (!name) return null;
  const today = dayKey();
  const s = blank(today);
  s.name = name;
  const sv = serviceNamed(name);
  if (sv) { s.url = sv.url; s.category = sv.category; }
  const price = toMinor(q.get("price") || "", currency);
  if (Number.isFinite(price)) s.amount = price;
  const cycle = (q.get("cycle") || "").toLowerCase();
  const every = Math.min(365, Math.max(1, Math.floor(Number(q.get("every")) || 1)));
  const unit = ({ weekly: "week", monthly: "month", yearly: "year", annual: "year", daily: "day", week: "week", month: "month", year: "year", day: "day" } as Record<string, Unit>)[cycle];
  if (isUnit(unit)) { s.unit = unit; s.every = every; }
  const cat = q.get("category");
  if (isCategory(cat)) s.category = cat;
  s.trial = q.get("trial") === "1" || q.get("trial") === "yes" || q.get("trial") === "true";
  const next = q.get("next") || "";
  s.next = isDayKey(next) ? next : s.trial ? addDays(today, 7) : nextFrom(today, s.unit, s.every);
  return s;
}

const nextFrom = (today: string, unit: Unit, every: number) =>
  unit === "day" ? addDays(today, every) : unit === "week" ? addDays(today, 7 * every) : addMonths(today, unit === "year" ? 12 * every : every);

const usd = (v: string) => (v.includes(".") ? v : `${v}.00`);

function typicalText(sv: Service, currency: string): string | null {
  if (!sv.typical || currency !== "USD") return null;
  return `${money(toMinor(sv.typical.usd, "USD"), "USD")} ${cycleLabel({ unit: sv.typical.unit, every: 1 })}`;
}

function NameField({ value, onChange, onPick, currency, apps, inputId }: {
  value: string; onChange: (v: string) => void; onPick: (sv: Service) => void; currency: string; apps: FreeApp[]; inputId: string;
}) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(-1);
  const list = useMemo(() => suggest(value), [value]);
  const id = useId();
  const show = open && list.length > 0 && !(list.length === 1 && list[0].name === value);
  const pick = (sv: Service) => { onPick(sv); setOpen(false); setActive(-1); };
  return (
    <div className="combo">
      <input id={inputId} className="input" value={value} maxLength={MAX_NAME} autoComplete="off" spellCheck={false}
        placeholder="Netflix, Spotify, a gym..." role="combobox" aria-autocomplete="list" aria-expanded={show}
        aria-controls={`${id}-list`} aria-activedescendant={show && active >= 0 ? `${id}-o${active}` : undefined}
        onChange={(e) => { onChange(e.target.value); setOpen(true); setActive(-1); }}
        onFocus={() => setOpen(true)}
        onBlur={() => window.setTimeout(() => setOpen(false), 120)}
        onKeyDown={(e) => {
          if (e.key === "ArrowDown" && list.length) { e.preventDefault(); setOpen(true); setActive((a) => (a + 1) % list.length); }
          else if (e.key === "ArrowUp" && list.length) { e.preventDefault(); setActive((a) => (a <= 0 ? list.length - 1 : a - 1)); }
          else if (e.key === "Enter" && show && active >= 0) { e.preventDefault(); pick(list[active]); }
          else if (e.key === "Escape" && show) { e.preventDefault(); setOpen(false); }
        }} />
      {show && (
        <ul className="combo-list" role="listbox" id={`${id}-list`} aria-label="Popular services">
          {list.map((sv, i) => {
            const alt = freeAlternative(sv.name, apps);
            const typical = typicalText(sv, currency);
            return (
              <li key={sv.name} id={`${id}-o${i}`} role="option" aria-selected={i === active} className={i === active ? "on" : ""}
                onMouseDown={(e) => { e.preventDefault(); pick(sv); }}>
                <strong>{sv.name}</strong>
                <span>{CATEGORIES[sv.category]}{typical ? ` · typical ${typical}` : ""}</span>
                {alt && <span className="chip chip-free">Free alternative: {alt.app.name}</span>}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

export function Editor({ initial, mode, fixedDate = false, onDone }: { initial: Sub; mode: "add" | "edit" | "start"; fixedDate?: boolean; onDone: (s: Sub, again: boolean) => void }) {
  const { currency } = useStore();
  const apps = useFreeApps();
  const ids = useId();
  const today = dayKey();
  // A new one named after a listed service starts with that service's typical price, marked as such.
  const sv0 = mode !== "edit" && !initial.amount ? serviceNamed(initial.name) : undefined;
  const t0 = sv0?.typical && currency === "USD" ? sv0.typical : null;
  const initialCycle: Cycle = t0 ? (t0.unit as Cycle) : initial.every === 1 && initial.unit !== "day" ? (initial.unit as Cycle) : "custom";
  const [name, setName] = useState(initial.name);
  const [price, setPrice] = useState(initial.amount || mode === "edit" ? fromMinor(initial.amount, currency) : t0 ? usd(t0.usd) : "");
  const [cycle, setCycle] = useState<Cycle>(initialCycle);
  const [every, setEvery] = useState(String(initial.every));
  const [unit, setUnit] = useState<Unit>(initial.unit);
  const [next, setNext] = useState(initial.next);
  const [dateTouched, setDateTouched] = useState(mode === "edit" || fixedDate);
  const [trial, setTrial] = useState(initial.trial);
  const [category, setCategory] = useState<Category>(initial.category);
  const [method, setMethod] = useState(initial.method);
  const [url, setUrl] = useState(initial.url);
  const [notes, setNotes] = useState(initial.notes);
  const [details, setDetails] = useState(mode === "edit" && Boolean(initial.method || initial.notes));
  const [typical, setTypical] = useState<string | null>(sv0 ? typicalText(sv0, currency) : null);
  const [error, setError] = useState<string | null>(null);
  const formRef = useRef<HTMLFormElement>(null);

  const realUnit: Unit = cycle === "custom" ? unit : cycle;
  const realEvery = cycle === "custom" ? Math.floor(Number(every)) : 1;
  const match = name.trim() ? freeAlternative(name, apps) : null;

  // Until the person picks a date, it follows the cycle: one cycle from today, or a week for a trial.
  useEffect(() => {
    if (dateTouched) return;
    setNext(trial ? addDays(today, 7) : nextFrom(today, realUnit, Math.max(1, realEvery || 1)));
  }, [trial, realUnit, realEvery, dateTouched, today]);

  const pick = (sv: Service) => {
    setName(sv.name);
    setCategory(sv.category);
    setUrl(sv.url);
    if (sv.typical && currency === "USD" && !price) {
      setPrice(usd(sv.typical.usd));
      setCycle(sv.typical.unit === "day" ? "custom" : sv.typical.unit);
      setTypical(typicalText(sv, currency));
    }
  };

  const build = (): Sub => {
    // A typed name that matches a listed service gets its website and category, as if picked.
    const known = serviceNamed(name);
    const site = url.trim();
    return {
      ...initial,
      name: name.trim(),
      amount: price.trim() === "" ? NaN : toMinor(price.replace(/^[^\d]+/, ""), currency),
      unit: realUnit,
      every: realEvery,
      next,
      trial,
      category: category === "other" && known ? known.category : category,
      method: method.trim(),
      url: !site ? known?.url ?? "" : /^https?:\/\//.test(site) ? site : `https://${site}`,
      notes: notes.trim(),
    };
  };

  const submit = (again: boolean) => {
    const s = build();
    const p = Number.isNaN(s.amount) ? `Enter the price${minorDigits(currency) ? ", like 9.99" : ""}.` : problem(s);
    if (p) {
      setError(p);
      window.setTimeout(() => formRef.current?.querySelector<HTMLElement>(".notice-error")?.scrollIntoView({ block: "nearest", behavior: "smooth" }), 0);
      return;
    }
    setError(null);
    const fresh = mode !== "edit" && getState().subs[s.id] ? { ...s, id: blank().id } : s;
    saveSubs([fresh]);
    toast(mode === "edit" ? "Saved" : `Added ${fresh.name}`);
    onDone(fresh, again);
  };

  const sv = serviceNamed(name);
  return (
    <form ref={formRef} className="editor" noValidate onSubmit={(e) => { e.preventDefault(); submit(false); }}>
      <div className="field">
        <label htmlFor={`${ids}-name`}>Name</label>
        <NameField inputId={`${ids}-name`} value={name} currency={currency} apps={apps}
          onChange={(v) => { setName(v); setTypical(null); }} onPick={pick} />
        {match && (
          <p className="alt-note">
            <span className="chip chip-free">Free alternative</span>{" "}
            <a href={match.app.url} target="_blank" rel="noopener">{match.app.name} by OpenApps <Out size={13} className="inline-icon" /></a>
            <span className="muted"> instead of {match.paid}</span>
          </p>
        )}
      </div>

      <div className="field">
        <label htmlFor={`${ids}-price`}>Price</label>
        <div className="price-row">
          <span className="price-cur" aria-hidden="true">{currency}</span>
          <input id={`${ids}-price`} className="input" inputMode="decimal" autoComplete="off" placeholder={minorDigits(currency) ? "9.99" : "1000"}
            value={price} onChange={(e) => { setPrice(e.target.value); setTypical(null); }} aria-describedby={typical ? `${ids}-typical` : undefined} />
        </div>
        {typical && <small id={`${ids}-typical`} className="typical">Typical price: {typical}. Check yours, plans and countries differ.</small>}
      </div>

      <div className="field">
        <span id={`${ids}-cycle`}>Billed</span>
        <div className="segmented four" role="radiogroup" aria-labelledby={`${ids}-cycle`}>
          {CYCLES.map((c) => (
            <button key={c.v} type="button" role="radio" aria-checked={cycle === c.v} onClick={() => setCycle(c.v)}>{c.label}</button>
          ))}
        </div>
        {cycle === "custom" && (
          <div className="inline-field">
            <label htmlFor={`${ids}-every`}>Every</label>
            <input id={`${ids}-every`} className="input every-input" inputMode="numeric" value={every} onChange={(e) => setEvery(e.target.value.replace(/\D/g, "").slice(0, 3))} />
            <select className="input unit-select" aria-label="Days, weeks, months or years" value={unit} onChange={(e) => setUnit(e.target.value as Unit)}>
              <option value="day">days</option><option value="week">weeks</option><option value="month">months</option><option value="year">years</option>
            </select>
          </div>
        )}
      </div>

      <label className="check trial-check">
        <input type="checkbox" checked={trial} onChange={(e) => setTrial(e.target.checked)} />
        It is a free trial
      </label>

      <div className="field">
        <label htmlFor={`${ids}-next`}>{trial ? "Trial ends on" : "Next renewal"}</label>
        <input id={`${ids}-next`} className="input date-input" type="date" value={next} min="2000-01-01" max="2100-12-31"
          onChange={(e) => { setNext(e.target.value); setDateTouched(true); }} />
        <small>{trial ? "The day the first charge happens unless you cancel." : "Check your last receipt or the app store's subscription list for the date."}</small>
      </div>

      <div className="field">
        <label htmlFor={`${ids}-cat`}>Category</label>
        <select id={`${ids}-cat`} className="input" value={category} onChange={(e) => setCategory(e.target.value as Category)}>
          {Object.entries(CATEGORIES).map(([k, label]) => <option key={k} value={k}>{label}</option>)}
        </select>
      </div>

      {details ? (
        <>
          <div className="field">
            <label htmlFor={`${ids}-method`}>Paid with</label>
            <input id={`${ids}-method`} className="input" value={method} maxLength={MAX_METHOD} placeholder="Visa ending 4242, PayPal..." onChange={(e) => setMethod(e.target.value)} />
            <small>A label to find it on your statement. Never your full card number.</small>
          </div>
          <div className="field">
            <label htmlFor={`${ids}-url`}>Website</label>
            <input id={`${ids}-url`} className="input" value={url} maxLength={MAX_URL} inputMode="url" placeholder="https://" onChange={(e) => setUrl(e.target.value)} />
            {sv && url === sv.url && <small>Where you can manage or cancel it.</small>}
          </div>
          <div className="field">
            <label htmlFor={`${ids}-notes`}>Notes</label>
            <textarea id={`${ids}-notes`} className="textarea" value={notes} maxLength={MAX_NOTES} placeholder="Shared with family, student price ends in June..." onChange={(e) => setNotes(e.target.value)} />
          </div>
        </>
      ) : (
        <button type="button" className="link-btn" onClick={() => setDetails(true)}>Add how you pay, the website and notes</button>
      )}

      {error && <p className="notice notice-error" role="alert">{error}</p>}

      <div className="editor-actions">
        <button type="submit" className="btn btn-lg">{mode === "edit" ? "Save changes" : "Add subscription"}</button>
        {mode === "add" && <button type="button" className="btn btn-line btn-lg" onClick={() => submit(true)}>Add and start another</button>}
      </div>
    </form>
  );
}

Change history

Point store links to openapps.free
1ae4feb2be · 18 hours ago
Bind agent address in policy
99bbab3f0c · 19 hours ago
Use OpenApps service URLs and add contribution strip
781c3c23e5 · 19 hours ago
Bounded source sync
048e3257a5 · 1 day ago
Bounded source sync
7a358a3be6 · 1 day ago
Bounded source sync
0c833fcb0e · 1 day ago