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/Settings.tsx
// Settings: currency, keeping subscriptions on all devices, CSV in and out, deleting everything, and
// adding Subs to the home screen.
import { useEffect, useRef, useState } from "react";
import { useStore, listSubs, getState, replace, fresh, saveSubs, setCurrency, deleteSubs } from "./lib/store";
import { cycleWord, fromCsv, toCsv, type ImportResult } from "./lib/subs";
import { serviceNamed } from "./lib/services";
import { CURRENCIES, money } from "./lib/money";
import { keepOnAllDevices, flush, startSync, useOffline, useUser } from "./lib/sync";
import { signOut, explain } from "./lib/bounded";
import { dayKey } from "./lib/dates";
import { Modal, download, toast } from "./ui";
import { Download, Upload } from "./icons";

function Account() {
  const user = useUser();
  const offline = useOffline();
  const [busy, setBusy] = useState(false);
  if (!user) {
    return (
      <section className="card pad stack">
        <h2 className="h3">Your account</h2>
        <p className="muted small">{offline ? "Subs cannot reach your account right now, so sign-in is not available. Your subscriptions are saved on this device and sync when it can connect." : "Connecting... Your subscriptions are saved on this device either way."}</p>
        {offline && <button type="button" className="btn btn-line btn-sm self-start" onClick={() => void startSync().catch(() => null)}>Try again</button>}
      </section>
    );
  }
  if (!user.guest) {
    return (
      <section className="card pad stack">
        <h2 className="h3">Your account</h2>
        <p className="muted small">Signed in{user.email ? ` as ${user.email}` : ""}. Your subscriptions are saved to your account and show up on every device you sign in on.</p>
        <button type="button" className="btn btn-line btn-sm self-start" disabled={busy} onClick={async () => {
          setBusy(true);
          try {
            await flush().catch(() => null);
            replace(fresh());
            // A hosted sign-out leaves through the sign-in service and comes back to this address.
            history.replaceState(null, "", "/home");
            await signOut();
            location.href = "/home";
          } catch (err) { toast(explain(err)); setBusy(false); }
        }}>{busy ? "Signing out..." : "Sign out"}</button>
      </section>
    );
  }
  return (
    <section className="card pad stack account-card">
      <h2 className="h3">Keep my subscriptions on all devices</h2>
      <p className="muted small">Right now they are saved in this browser and a guest account made for it. Sign in with your email to see them on your phone and laptop. No password, and it stays free.</p>
      <button type="button" className="btn btn-sm self-start" disabled={busy} onClick={async () => {
        setBusy(true);
        try { await keepOnAllDevices(); } catch (err) { toast(explain(err)); setBusy(false); }
      }}>{busy ? "Opening sign-in..." : "Continue with email"}</button>
    </section>
  );
}

function ImportCard() {
  const s = useStore();
  const input = useRef<HTMLInputElement>(null);
  const [result, setResult] = useState<(ImportResult & { dupes: number }) | null>(null);
  const read = async (file: File) => {
    const text = await file.text();
    const r = fromCsv(text, s.currency, (name) => { const sv = serviceNamed(name); return sv ? { category: sv.category, url: sv.url } : null; });
    const have = new Set(listSubs(getState()).map((x) => `${x.name.toLowerCase()}|${x.amount}`));
    const fresh = r.subs.filter((x) => !have.has(`${x.name.toLowerCase()}|${x.amount}`));
    setResult({ ...r, subs: fresh, dupes: r.subs.length - fresh.length });
  };
  return (
    <section className="card pad stack">
      <h2 className="h3">Import from a spreadsheet</h2>
      <p className="muted small">A CSV file with a column for the name and one for the price. Columns for billing (monthly, yearly, every 3 months), next renewal date, category, payment method and notes are read when they are there. Amounts are read as {s.currency}.</p>
      <input ref={input} type="file" accept=".csv,text/csv,text/plain" className="sr-only" aria-label="Choose a CSV file to import"
        onChange={(e) => { const f = e.target.files?.[0]; if (f) void read(f); e.target.value = ""; }} />
      <button type="button" className="btn btn-line btn-sm self-start" onClick={() => input.current?.click()}><Upload size={16} /> Choose a CSV file</button>
      {result && (
        <Modal title={result.subs.length ? `Import ${result.subs.length} subscription${result.subs.length === 1 ? "" : "s"}?` : "Nothing to import"} onClose={() => setResult(null)} footer={
          result.subs.length ? (
            <>
              <button type="button" className="btn" onClick={() => { saveSubs(result.subs); toast(`Imported ${result.subs.length}`); setResult(null); }}>Import</button>
              <button type="button" className="btn btn-quiet" onClick={() => setResult(null)}>Cancel</button>
            </>
          ) : <button type="button" className="btn" onClick={() => setResult(null)}>OK</button>
        }>
          {result.note && <p className="notice notice-error">{result.note}</p>}
          {result.subs.length > 0 && (
            <ul className="plain-list import-list">
              {result.subs.slice(0, 50).map((x) => <li key={x.id}><strong>{x.name}</strong><span className="muted">{money(x.amount, s.currency)}, {cycleWord(x).toLowerCase()}, next {x.next}</span></li>)}
              {result.subs.length > 50 && <li className="muted">And {result.subs.length - 50} more.</li>}
            </ul>
          )}
          <p className="hint">
            {result.dupes > 0 && `${result.dupes} already in Subs with the same name and price, left out. `}
            {result.skipped > 0 && `${result.skipped} row${result.skipped === 1 ? "" : "s"} without a name or a price Subs could read, left out. `}
            {result.undated > 0 && `${result.undated} had no renewal date, so it is set to one cycle from today: check those.`}
          </p>
        </Modal>
      )}
    </section>
  );
}

type InstallPrompt = Event & { prompt(): Promise<void>; userChoice: Promise<{ outcome: string }> };
let early: InstallPrompt | null = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); early = e as InstallPrompt; });

function Install() {
  const [prompt, setPrompt] = useState<InstallPrompt | null>(early);
  const [installed, setInstalled] = useState(() => matchMedia("(display-mode: standalone)").matches);
  useEffect(() => {
    const offer = (e: Event) => { e.preventDefault(); setPrompt(e as InstallPrompt); };
    const done = () => { setInstalled(true); setPrompt(null); };
    window.addEventListener("beforeinstallprompt", offer);
    window.addEventListener("appinstalled", done);
    return () => { window.removeEventListener("beforeinstallprompt", offer); window.removeEventListener("appinstalled", done); };
  }, []);
  if (installed) return <p className="muted small">Subs is on your home screen.</p>;
  if (prompt) {
    return <button type="button" className="btn btn-line btn-sm self-start" onClick={async () => { await prompt.prompt(); const { outcome } = await prompt.userChoice; if (outcome === "accepted") setInstalled(true); setPrompt(null); }}>Add Subs to your home screen</button>;
  }
  return <p className="muted small">On iPhone or iPad, tap Share, then Add to Home Screen. In Chrome or Edge, use Install app in the menu.</p>;
}

export function SettingsPage() {
  const s = useStore();
  const [wipe, setWipe] = useState(false);
  const all = listSubs(s);
  return (
    <div className="page settings">
      <header className="page-head"><div><p className="eyebrow">Subs</p><h1 className="h1">Settings</h1></div></header>
      <div className="settings-grid">
        <section className="card pad stack">
          <h2 className="h3">Currency</h2>
          <label className="field">
            <span>Prices are in</span>
            <select className="input" value={s.currency} onChange={(e) => { setCurrency(e.target.value); toast(`Prices are now in ${e.target.value}`); }}>
              {(CURRENCIES.includes(s.currency) ? CURRENCIES : [s.currency, ...CURRENCIES]).map((c) => <option key={c} value={c}>{c}</option>)}
            </select>
          </label>
          <p className="muted small">One currency for everything, so the totals add up. Changing it keeps the numbers you typed and does not convert them.</p>
        </section>
        <Account />
        <section className="card pad stack">
          <h2 className="h3">Your data</h2>
          <p className="muted small">Download every subscription as a CSV file that opens in any spreadsheet. Your list is yours.</p>
          <button type="button" className="btn btn-line btn-sm self-start" disabled={!all.length} onClick={() => download(`subscriptions-${dayKey()}.csv`, toCsv(all, s.currency), "text/csv")}><Download size={16} /> Download CSV</button>
          <button type="button" className="btn btn-quiet btn-sm btn-danger self-start" disabled={!all.length} onClick={() => setWipe(true)}>Delete all subscriptions</button>
        </section>
        <ImportCard />
        <section className="card pad stack">
          <h2 className="h3">Why there is no bank connection</h2>
          <p className="muted small">Trackers that find subscriptions for you need to read your bank or card transactions. Subs never asks for your bank login, so it holds nothing about your accounts that could leak, and it works with any bank, card or country. The trade: you add each subscription yourself, which takes about ten seconds each.</p>
        </section>
        <section className="card pad stack">
          <h2 className="h3">On your phone</h2>
          <Install />
          <p className="muted small">Once opened, Subs works offline. Changes sync when you are back online.</p>
        </section>
      </div>
      <p className="hint fine">Subs is free, with no limit, no ads, no third-party trackers and nothing sold; the hosting counts visits. Your subscriptions are private: only you can see them.</p>
      {wipe && (
        <Modal title="Delete all subscriptions?" onClose={() => setWipe(false)} footer={
          <>
            <button type="button" className="btn btn-danger btn-line" onClick={() => { deleteSubs(all.map((x) => x.id)); toast("Deleted"); setWipe(false); }}>Delete all {all.length}</button>
            <button type="button" className="btn btn-quiet" onClick={() => setWipe(false)}>Keep them</button>
          </>
        }>
          <p>This removes every subscription from Subs, on this device and in your account. Download the CSV first if you might want them back.</p>
        </Modal>
      )}
    </div>
  );
}

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