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/SubPage.tsx
// One subscription: what it costs, when it renews, a calendar reminder, its free alternative, and the
// actions: edit, mark as barely used, mark as cancelled, delete.
import { useEffect, useState } from "react";
import { useStore, editSub, deleteSubs } from "./lib/store";
import { CATEGORIES, cycleLabel, nextCharge, onTrial, perMonth } from "./lib/subs";
import { useFreeApps, freeAlternative, STORE_URL } from "./lib/alternatives";
import { remindersIcs, googleLink, firstReminder } from "./lib/ics";
import { money } from "./lib/money";
import { dayKey, fmtDay, relDay } from "./lib/dates";
import { navigate } from "./lib/router";
import { A, Modal, download, toast } from "./ui";
import { Editor } from "./Editor";
import { LeadPicker } from "./Overview";
import { Back, Bell, Download, Leaf, Out, Pencil, Trash } from "./icons";

export function SubPage({ id, edit }: { id: string; edit: boolean }) {
  const s = useStore();
  const apps = useFreeApps();
  const [confirm, setConfirm] = useState(false);
  const x = Object.prototype.hasOwnProperty.call(s.subs, id) ? s.subs[id] : undefined;
  useEffect(() => { if (x && !x.deleted) document.title = `${x.name} | Subs`; }, [x]);
  if (!x || x.deleted) {
    return (
      <div className="page">
        <div className="empty"><p>This subscription is not on this device. It may have been deleted, or added on another device that has not synced yet.</p><A className="btn" href="/all">See all subscriptions</A></div>
      </div>
    );
  }
  if (edit) {
    return (
      <div className="page add">
        <A className="back-link" href={`/s/${id}`}><Back size={16} /> {x.name}</A>
        <header className="page-head"><div><p className="eyebrow">Edit</p><h1 className="h1">{x.name}</h1></div></header>
        <section className="card pad"><Editor initial={x} mode="edit" onDone={() => navigate(`/s/${id}`)} /></section>
      </div>
    );
  }
  const today = dayKey();
  const trial = onTrial(x, today);
  const next = nextCharge(x, today);
  const alt = freeAlternative(x.name, apps);
  const g = googleLink(x, s.lead, s.currency);
  const first = firstReminder(x, s.lead, s.currency);
  const host = x.url ? x.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/.*$/, "") : "";
  return (
    <div className="page sub">
      <A className="back-link" href="/all"><Back size={16} /> All subscriptions</A>
      <header className="page-head">
        <div>
          <p className="eyebrow">{CATEGORIES[x.category]}</p>
          <h1 className="h1 sub-title">{x.name}</h1>
          <p className="sub-price-big"><strong>{money(x.amount, s.currency)}</strong> {cycleLabel(x)}{trial ? " after the free trial" : ""}</p>
        </div>
        <A className="btn btn-line" href={`/s/${id}/edit`}><Pencil size={16} /> Edit</A>
      </header>

      <div className="sub-grid">
        <div className="stack col-main">
          <dl className="stats">
            <div><dt>{x.cancelled ? "Cancelled" : trial ? "Trial ends" : "Next renewal"}</dt><dd>{fmtDay(x.cancelled || (trial ? x.next : next), today, { month: "short", day: "numeric" })}<small> {x.cancelled ? "" : relDay(trial ? x.next : next, today)}</small></dd></div>
            <div><dt>Per month</dt><dd>{money(perMonth(x), s.currency)}</dd></div>
            <div><dt>Per year</dt><dd>{money(perMonth(x) * 12, s.currency)}</dd></div>
          </dl>

          {x.cancelled && <p className="notice notice-ok">Marked as cancelled on {fmtDay(x.cancelled, today)}. That saves {money(perMonth(x) * 12, s.currency)} a year. Check that the service confirmed it.</p>}

          <section className="card pad stack">
            <h2 className="h3">Details</h2>
            <dl className="details">
              {x.method && <><dt>Paid with</dt><dd>{x.method}</dd></>}
              {x.url && <><dt>Manage or cancel</dt><dd><a href={x.url} target="_blank" rel="noopener">{host} <Out size={13} className="inline-icon" /></a></dd></>}
              {x.notes && <><dt>Notes</dt><dd className="notes">{x.notes}</dd></>}
              {!x.method && !x.url && !x.notes && <dd className="muted">No payment label, website or notes. <A href={`/s/${id}/edit`}>Add them</A></dd>}
            </dl>
            <p className="hint">Subs does not cancel anything for you and never sees your bank. To cancel, use the service's website or your app store's subscription settings.</p>
          </section>

          {alt ? (
            <section className="card pad stack swaps">
              <div className="icon-title"><Leaf /><h2 className="h3">A free alternative</h2></div>
              <a className="swap-to" href={alt.app.url} target="_blank" rel="noopener">
                <strong>{alt.app.name} by OpenApps <Out size={13} className="inline-icon" /></strong>
                <span>{alt.app.tagline} Free instead of {alt.paid}.</span>
              </a>
              <p className="muted small">If it covers what you use {x.name} for, that is {money(perMonth(x) * 12, s.currency)} a year back. Try it before you cancel.</p>
            </section>
          ) : (
            <section className="card pad stack">
              <div className="icon-title"><Leaf /><h2 className="h3">No free alternative yet</h2></div>
              <p className="muted small">The OpenApps store has no free app for {x.name} yet. <a href={`${STORE_URL}/request`} target="_blank" rel="noopener">Ask for one</a>: the most requested get built next.</p>
            </section>
          )}
        </div>

        <div className="stack col-side">
          {!x.cancelled && (
            <section className="card pad stack">
              <div className="icon-title"><Bell /><h2 className="h3">{trial ? "Remind me before the trial ends" : "Remind me before it renews"}</h2></div>
              <LeadPicker />
              {first && <p className="muted small">First reminder: {first}{trial ? "." : ", then before every renewal."}</p>}
              <div className="row">
                <button type="button" className="btn btn-line btn-sm" onClick={() => download(`${x.name.replace(/[^\w-]+/g, "-").toLowerCase() || "subscription"}-reminder.ics`, remindersIcs([x], s.lead, s.currency), "text/calendar")}><Download size={16} /> Calendar file (.ics)</button>
                {g && <a className="btn btn-line btn-sm" href={g} target="_blank" rel="noopener">Google Calendar <Out size={13} /></a>}
              </div>
              <p className="hint">Subs cannot send reminders itself, so your calendar does it.</p>
            </section>
          )}

          <section className="card pad stack">
            <h2 className="h3">Still worth it?</h2>
            <label className="check">
              <input type="checkbox" checked={x.unused} disabled={Boolean(x.cancelled)} onChange={(e) => editSub(id, (y) => { y.unused = e.target.checked; })} />
              I barely use this
            </label>
            <p className="muted small">Things you barely use are listed under Review these on the overview, with what they cost you a year.</p>
            {x.cancelled ? (
              <button type="button" className="btn btn-line btn-sm self-start" onClick={() => { editSub(id, (y) => { y.cancelled = ""; }); toast("Back on your list"); }}>I still pay for it</button>
            ) : (
              <button type="button" className="btn btn-line btn-sm self-start" onClick={() => { editSub(id, (y) => { y.cancelled = today; y.unused = false; }); toast(`Marked ${x.name} as cancelled`, { label: "Undo", run: () => editSub(id, (y) => { y.cancelled = ""; }) }); }}>I cancelled it</button>
            )}
            <button type="button" className="btn btn-quiet btn-sm btn-danger self-start" onClick={() => setConfirm(true)}><Trash size={16} /> Delete</button>
          </section>
        </div>
      </div>

      {confirm && (
        <Modal title={`Delete ${x.name}?`} onClose={() => setConfirm(false)} footer={
          <>
            <button type="button" className="btn btn-danger btn-line" onClick={() => { deleteSubs([id]); toast(`Deleted ${x.name}`); navigate("/all"); }}>Delete</button>
            <button type="button" className="btn btn-quiet" onClick={() => setConfirm(false)}>Keep it</button>
          </>
        }>
          <p>This removes it from Subs on every device. To keep it in your history instead, use I cancelled it.</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