A member-owned crypto asset pool: deposit for a token representing your share, redeemable for that share anytime.
DETF stands for Decentralised Exchange Traded Fund. Redeem the token for its share of everything in the pool — its NAV. Smart contracts hold the value; members decide the group's actions.
Tiers define roles, weighted seats split votes, exit at NAV is guaranteed, and tokens can cross chains.
Four ideas address failures of tokens and DAOs:
Members are organised in tiers, each with its own role and voting weight. Tier names, count and weights are set per project, so a group can be as centralised or as open as it wants.
Example
Drag a bar to re-weight that tier — the others rebalance. Token holders stay at the bottom; every tier you add stacks above them and splits the vote further. This board carries over to the next card.
Each tier holds a fixed % of the total votes, and no seat can pass anything alone. Buying tokens only buys a share of the token tier, and starting proposals can require a higher tier.
Example
Distributed — it takes 50 of 100 members to control half the vote. A 50%+ seat held by a wide tier isn't centralization; that's power spread across many hands.
Drag the white dividers to move weight between seats — the members stay the same. Drag the ⇕ handle next to a member count up or down (or drag on a block) to grow or shrink that tier: widen token holders and even a majority seat is spread thin.
Your deposit is a share of the treasury. Burn your tokens and reclaim your share of the NAV at any time. Even after a proposal has been approved you have 7 days to withdraw your funds before it's enacted.
Example
Your 5 ETH is in — the teal half of the pot is yours, never locked. Then a proposal opens: spend 3 ETH. Cast your vote…
An abandoned project on one chain can restart on another. Over Chainlink CCIP, a migration moves the token itself between Solana and Ethereum — with a new name and a funded treasury behind it.
Example
1 · Deposit deadline
open
2 · Transfer date
pending
3 · CCIP delivery
pending
Ethereum
900 OLD sold → 4.5 ETH · yours to go
Solana
waiting for transfer day
An ETF's redeemable share and a DAO's open books, without a fund manager or the biggest wallet deciding for you.
A DETF compared with a traditional ETF and typical DAO:
| DETF | Traditional ETF | Typical DAO | |
|---|---|---|---|
| Your money | Yours — a share of the shared treasury, redeemable at NAV any time | Held by a fund company; out through a broker | A treasury insiders unlock |
| Who decides | Weighted seats splitting 100% — buying tokens only buys the token seat | The fund manager | The biggest wallet |
| What it costs you | 1% of the supply once, at launch — no management or exit fee | A management fee, every year you hold | Whatever the team pays itself |
| If it goes wrong | Any member can put the kill switch to a vote — withdrawals stay open forever | The issuer decides when to wind it down | The token goes to zero; the treasury stays with insiders |
| Who can start one | Anyone — one transaction deploys the whole set | A licensed issuer | Anyone — then improvise the governance |
Every proposal, vote, salary, treasury movement, member profile and group transaction ledger is public: check who does what and at what cost, without relying on a report.
A tier defines rights, entry and voting weight, preventing anyone from buying control outright.
A DETF starts with four tiers: commonly founders, Developers building the product, Moderators running the community, and token holders at the bottom.
The default four tiers, renamed to fit (rename, re-weight, add or remove tiers at creation — or later, by vote)
voted-in · every founder votes directly at their own weight · treasury keys
voted-in here — admission is itself a vote; deposit or hold-an-asset gates work too
hold 1,000 tokens — a stake-to-moderate rule; any tier can gate on votes, deposits, or holdings
automatic — hold the token or click Join DETF; the seat votes by balance
Votes count by weight, not headcount, with the pass mark above the heaviest seat so none decides alone.
Each founder has an individual weight; each tier can hold one shared weighted seat; token holders vote by balance through their seat. Percentage weights are set at creation, changed only by public Tiers vote, and may be split any way, including token holders outweighing founders.
Example: founder 30%, Developers 25%, Moderators 15%, token holders 30%; on-chain pass mark 60%. The founder needs token holders (30 + 30 = 60) or both work tiers (30 + 25 + 15 = 70). The other three seats total 70%, enough to outvote the founder.
Try it — a live board
The same board with five founders instead of one. Drag the dividers to re-weight the blocs, drag the white pass mark, flip the seat votes — and note the founders are five individual voters, not one seat.
pass mark 60%
Approving weight is 60% against a pass mark of 60% — the proposal passes and moves to execution.
This is the on-chain board, which is why there is a pass mark at all. The purple slices are five founders voting individually at their own weight — which is why a founders majority above the pass mark is legal, and why a brand-new one-founder DETF works from day one. Each tier seat casts its whole weight by the majority of its members; the token-holder seat does the same, weighted by balance. Buying the entire token supply only wins the amber segment, not the board. Off-chain groups have no pass mark and share each seat's weight out among the members who vote instead — see the weighted board.
On-chain, approving seats must reach the pass mark: just above half the total weight and always above the largest seat. Off-chain, the heavier side wins without a pass mark. Buying most tokens gains only the token seat's share. A separate on-chain token-holder vote starts at 50% approval and 10% turnout, changeable only by Tiers proposal.
Each tier can hold one seat, sharing its weight among actual voters off-chain and voting as one bloc on-chain.
The board's seats approve group actions: contracts vote on-chain; off-chain, the board must publicly follow the count.
A board split 30 / 25 / 15 / 30 — on-chain it passes at the 60% mark; off-chain the heavier side wins
The founder (30%) can't outvote the rest — they need the token holders (60%) or both worker seats (70%), and the Developers, Moderators and token holders together reach 70% without them. On-chain, any seat combination reaching the 60% mark executes; off-chain, whichever side carries more of the weight that actually voted wins.
Set up seats at creation. The create wizard can enable a seat for any tier and shows each percentage. Off-chain weights apply immediately; on-chain seats need two extra transactions after launch, from Queued actions.
Both modes share tiers, weights and proposal types; only who executes results differs — people or contracts.
Actions start as typed proposals; except for plain discussion, titles are generated from what they do.
Types share a lifecycle but request different details. Except for General, titles are generated from those details and uneditable, preventing misleading promises. Entry to a closed tier requires Vote In / Out: the vote is the invite.
| Type | What it does | When it passes |
|---|---|---|
| General | Record a decision or ask the group; never moves money. | Saved as the group's position. |
| Treasury | Send treasury money. The app generates the title from your details (“Send X to 0x1234…”), preventing a misleading title. | The board runs it on-chain after the queue. |
| Sell Tokens | Open, reprice, cap or close the token sale; buyers pay ETH into the treasury for new tokens. | The board sets the price or cap on-chain. |
| NAV Sale | Sell group-held tokens at current worth plus a markup, or close the sale. | The board lists it; buyers' ETH enters the treasury. |
| Sell Asset | Auction a treasury token amount or NFT, optionally with a minimum price; never the group's own token. | The board starts one public falling-price auction; the buyer's ETH enters the treasury. |
| Vote In / Out | Move a member between tiers or remove them, letting lower tiers hold leaders accountable. | Membership changes; Tier-1 changes also update the board. |
| Tiers | Change tiers, salaries or token-holder voting percentages, showing actual before and after values. | The board applies the settings on-chain. |
| Hiring | A job or bounty specifying work and pay: “Hiring: redesign the logo — 0.5 ETH”. | The job opens on the group's Jobs board. |
| Advertise | Fund a watch-to-earn ad campaign from the treasury. | Admins fund the campaign and upload the ad in Ad Manager. |
| Execute | On-chain actions not covered elsewhere: swaps, staking, listing treasury tokens or voting the group's tokens in another project. Titles start “Execute:”. | The call runs on-chain through the board and queue. |
| Governance | Switch between off-chain and on-chain voting. | 14 days to vote, then 14 before it applies, allowing dissenters to leave first. |
| Kill | Shut down with withdrawals open forever. Fixed title, one kill vote at a time, lasting the group's queue delay (7 days on mainnet). | Either bar suffices (see the kill switch); runs immediately because a kill takes nothing. |
| AI seat | Seat a platform AI agent in a tier or dismiss one. The title names both agent and tier, identifying the AI voter without opening the body. | The platform creates or ends the seat. Seated agents vote and comment; each proposal reviewed costs the group's credit pool. Already-seated agents get no ballot on this vote. |
| AI policy | Changes either or both AI rules the founders picked in the create wizard: seating by any admin at any time, or only after an “AI seat” vote; ballots with full control (except their own seat), half control (also neutral on membership and tier votes), or none (reviews and comments only). | Rules change; existing seats remain unchanged. Runs at least as long as a Tiers vote; seated agents get no ballot on it. At any level, proposers can toggle agent ballots per proposal; agents vote only if the group's credit pool can pay for review. |
Proposers choose a window from a few minutes on testnet to 30 days. Eligible voters may vote or change votes until close; each vote records their tier at voting time. Seats use weighted majority, no seats use simple majority, and on-chain contracts count. Passed money actions enter the public queue, where any board member can cancel before execution.
“Hiring: build the mobile app — 0.5 ETH”: a 7-day vote passes with Developers, Moderators and token holders backing it — 70% of board weight. The job opens on the Jobs board; the treasury pays when work is done.
One lifecycle for every proposal type
The white line tracks a proposal over time. While it's in the amber zone, the queued action sits in public — board members can veto it, and anyone who disagrees can still withdraw their treasury share. Funds move only at the end.
Tokens represent your treasury share, withdrawable anytime — even after a spend you opposed passes.
The treasury holds ETH and voted-in tokens, owned proportionally: 2% of tokens means 2% of every listed asset. Spending reduces each share's worth; exit protects dissenters:
Try it — the exit maths
A 100 ETH treasury. Set your share, then a spend you'd vote against.
Exit before it executes
10.0 ETH
your full share at NAV — the spend then falls only on those who stayed
Stay while it executes
6.0 ETH
your share of what's left — you funded 4.0 ETH of the spend
The vote and the timelock give you time to react; the burn-for-share exit lets you leave. No permission needed — the contract pays out whoever burns their tokens.
Oppose a 30 ETH spend? Withdraw while it waits, as above. It either loses funding to exits or falls on supporters who stay. The contract, not a person, holds the money, so nobody can remove your exit.
Token sale: anyone pays ETH straight into the treasury for new tokens at the voted price. Sales may be capped; over-cap purchases receive remaining tokens and a refund in one transaction. No open sale means no buy-in. NAV sale: group-held tokens sell at current worth plus a non-negative markup, only from an ETH-only treasury and never alongside a fixed-price sale, preventing dilution. Migration proceeds arrive when deposits close (Deposit & Sell). Assets enter the shared pot by vote; NFTs and unlisted tokens become ETH through auction.
Execute covers other on-chain calls — swap, stake, add liquidity, list or delist tokens — through vote, queue, cancellation window and execution. Only voting with another project's token skips the queue, since it moves no money; approving that project and delegating power still wait.
Assets labelled “shown, not withdrawable” on the Assets tab are outside the shared pot and excluded from withdrawals. If a broken token blocks payouts, the withdraw window offers everything else, preventing it from trapping ETH.
Five routes let members shut a group down without touching funds; withdrawals stay open forever.
A kill ends the group but never its money: spending stops for good, withdrawals stay open forever. Five routes:
A kill vote passes at either threshold: yes voters hold half the tokens or half the group's total voting weight. Withdrawals during voting count as yes. Passed kills execute immediately because they take nothing.
Irreversibly stops deposits and token sales, blocks queued and new actions, destroys treasury-held group tokens so outside holders own everything remaining, and closes any running migration's quote book with buyer refunds. Withdrawals stay open forever under “shut down — withdrawals only”. Irreversibility prevents hostage-taking; full withdrawal remains possible, so a mistaken kill requires only redeployment.
Choose everything on one page before spending gas; one transaction deploys and connects the complete set.
Set tier names, board weights and entry rules in the create wizard before spending gas. Almost every field has a ? explanation linking here. First, bring an existing token or create one.
Two starting points:
Name the new token; migrations reuse the old ticker unless changed. For new tokens, set total supply, your allocation (blank takes the remainder) and the treasury allocation. The 1% platform fee comes from these amounts, preserving your specified total. It funds DETF's own group, governed by the same rules. This card also lets you:
Migrations offer these extras and extra tokens for you or the treasury beyond the one-for-one swap. Both paths show a running allocation breakdown.
Add a description, logo, category, social links and website, then a profile: an @handle for your web address (availability checked as you type), location, goal and up to three topics for discovery through Tune your feed. Choose public or private, and whether to appear in the group list.
Set the deadline to hand in old tokens: 100 days by default. Afterwards, anyone can close the migration, automatically moving proceeds to the treasury.
Rename the four tiers or add more. Set each entry rule (vote, deposit or token holding; open “Join DETF” belongs to the bottom tier), whether it can start proposals, and its board seat and weight, if any. A bar shows each seat's percentage as you edit.
Add friends as members upfront and plan your own tier: you launch as the top-tier founder but can immediately step down on the group page. The saved plan costs nothing extra except on-chain seats, which require two post-launch transactions.
Before spending gas, the wizard checks for missing names, overallocated tokens, unfinished airdrops and taken handles. Unfinished work is saved for your return. One transaction then sets up everything and opens your new group page.
Deposit old tokens for instant sale into the new treasury and receive new tokens one for one.
From old tokens to new:
The only migration path: sell deposited old tokens immediately at the best offered price to fund the group treasury, receiving new tokens one for one. An unbacked token gains a treasury and governance.
Deposit during the 100-day window; afterwards, anyone can finish the migration and automatically move proceeds to the treasury. Holders retain relative holdings, now backed by a treasury and board. Each claim also mints a 1% platform fee on top for DETF's own group.
Each deposit sells in its own transaction, first to the quote book, then to the old token's trading pool on a DETF-selected exchange. A failed or underpaying pool trade is undone, leaving those tokens unsold. Set a minimum ETH amount when depositing: if the sale falls short, the whole transaction fails, preventing an unaccepted price.
The launch-fixed leftover rule defaults to give it back: unsold tokens are allocated proportionally to deposits and claimable on the group page after migration. If nobody claims within 30 days, anyone can destroy the remainder. Alternatively, destroy it suits an abandoned token. Deposit order never matters; everyone gets all their new tokens. An empty quote book cannot stop migration.
Deposits are final sales: no undo or refund. Before depositing, check buyers and expected proceeds; afterwards, withdraw your treasury share any time. Transfer-blocking old tokens fail safely; a scam token cannot take a buyer's money.
Buyers pre-fund ETH bids, paying each deposit in the same transaction at a price visible beforehand.
Migration starts with a quote book, not a dump into a small trading pool. Buyers pre-fund ETH and quote old-token prices; the best offer pays in the same transaction. No auction, waiting or later pricing: you get the displayed price.
Example: with a pool price around 0.0010 ETH and book offers around 0.00097, depositing 10,000 tokens receives about 9.7 ETH from the best buyer in the same transaction. The buyer takes tokens to resell; a month of such deposits funds the treasury.
A bot updates quotes, keeping the book rarely empty. Unbought tokens go to their own pool in the same transaction, then to the leftover rule. Buyers first, open market second; you receive all your new tokens either way.
Anyone can bid below market, receive automatic fills, then collect and resell after deposits close.
Anyone can quote on an open migration without appointment: buy below market, resell at market and keep the difference.
Worked example: the old token trades at 0.0010 ETH. You quote 0.00097 with 1 ETH escrowed. Holders deposit 1,000 tokens against your quote → 0.97 ETH of your escrow pays them, the 1,000 tokens land in your wallet instantly, and you resell them at market for ~1.0 ETH → ≈3% gross margin, minus gas and price movement.
Steps:
GET /api/migrations) includes every open migration's address, old token and chain. Read live bids and deposit status directly from the chain.placeQuote(price) and lock at least 0.001 ETH. One offer per wallet; up to 32 buyers at once. Wizard-created groups set no minimum price: you compete with other buyers and the token's pool.claimPurchased() and resell wherever the tokens trade. Buying at 0.97 and selling at 1.00 gives about 3% per fill. Cancel anytime for unspent ETH; cancel at close because no further purchases remain.Why it pays: compensation for holding risk. Collection waits until deposits close, also preventing self-sales for free new tokens. Price and liquidity may deteriorate; price that risk in. Best-price-first fills make buyers undercut each other, while leftovers reach the token's pool. Offer below the pool and you won't trade.
Our open-source bot quotes market − your margin, updates with the market, then cancels, collects and resells at close. Run it with your key and margin, or beat its price for its trades. Similar opportunities exist on the Solana side of cross-chain migrations and at asset auctions.
Indivisible treasury assets sell through public falling-price auctions ending with a 24-hour challenge period.
Non-ETH treasury assets — NFTs, other projects' tokens, unsolicited airdrops — cannot be split on withdrawal and must be sold. Public auctions let anyone buy. Two triggers use the same auction house:
Prices start deliberately high at 100,000 ETH, halve about every six hours and reach zero after a week, passing every possible valuation. Buyers wait for an acceptable price. Shut-down sales reach zero, allowing purchase for gas alone; voted sales stop at the group's floor and return unsold items after their week.
The first purchase stops the falling price, holds the payment as a bid and opens a 24-hour challenge period. Anyone can outbid by any amount, with no minimum increment or extension. Closing is exactly 24 hours after the first bid; outbid buyers can reclaim ETH anytime.
After 24 hours, anyone can close. Token sales first attempt a market sale for at least 1% above the winning bid. Success sends market proceeds to the treasury and fully refunds the bidder; otherwise, the bidder receives the tokens at their price. The treasury never receives less than the winning bid. This is a real trade, not a manipulable price check. The closer earns 5% of extra proceeds, incentivising timely closure without a request.
Active sales appear on the platform-wide auctions page, including shut-down groups removed from discovery, and on each group's Assets tab: falling prices and controls to bid, outbid, close and reclaim refunds.
Chainlink CCIP connects Ethereum and Solana, moving real tokens rather than wrapped claims.
Migrations can span a community's chains.
CCIP delivery → mints the new SPL token natively
← Solana deposits relay back as EVM mints
For Solana-only holders, Ethereum sends a migration message that creates the real new token in their Solana wallets without requiring Ethereum use. The same Solana program handles airdrops and a quote book, letting Solana communities deposit there for new tokens on Ethereum. Solana treasuries hold SOL and other tokens under Ethereum's same withdraw-your-share rule.
Join as a person, not a wallet, under the tier's rule: open entry, deposit, holding or vote.
At creation, groups can blacklist wallet addresses from joining or requesting access. Direct top-tier invitations override the list. It controls app membership only, never on-chain holding or trading.
One person, multiple wallets. Link unlimited wallets across both chains, choose a main wallet per chain and switch acting wallets in one click. You remain one member; balances combine for the best tier any wallet qualifies for. Leaving takes one click and preserves tokens; withdraw money through the treasury.
The Assets tab shows treasury holdings and your share, with buying, bidding, sending and cash-out controls.
Chat, discussions, jobs and admin tools support the group's work between votes.
Posts and open votes share a feed with inline voting and tuning by topic and proposal type.
Filter by everything, groups, people or votes. On the Tune page, prioritise topics, hide vote types (for example, hiring but not money), or restrict votes to your groups. Browser-saved settings apply everywhere immediately.
Advertisers pay per verified view, viewers earn for watching, and groups must vote to run an ad inside them.
DETF funds itself through paid-to-watch ads. Advertisers fund campaigns with at least 0.1 ETH and set payment per view. Viewers choose a picture, timed video, text card or promoted post to earn that amount. Funding comes from a personal wallet, a treasury Advertise vote, or a member reimbursed by vote. Groups must approve ads shown inside them: the community votes and gets paid.
Track accumulated earnings as you watch. Verified views pay in batches directly to your wallet, without gas or signatures. DETF's 1% outgoing-payment fee funds the same DETF group as launch and migration fees.
A group can sell from any web page, with card payments going directly to its shared credit balance.
Sell from any page — the group's, a member's or a sponsor's — with card payments funding Group credits, the shared balance for agent reviews, drafts, builds and Auto-mode. No shop operation, buyer account or payout: the group spends its earnings under these current rules.
/pay/<group>/<item>, for READMEs, newsletters and script-free pages./pay/<group> with all items and the same cart for groups without sites; and the group's automatic Shop tab once items are listed. Price changes reach all three together.Two keyless endpoints power the restylable HTML widget. GET /api/v1/store/items?group=<id> returns the group, items (id, name, description, price in cents and formatted) and pay links, cached one minute; hidden and unknown groups return 404.POST /api/v1/store/checkout accepts { group, item, quantity?, variant?, returnUrl? } or a cart, { group, lines: [{ item, quantity?, variant? }], returnUrl? }, and returns { url } for browser navigation. Items with options require a listed variant. Custom product pages and carts can use the widget's window.groupStoreCheckout(lines, { returnUrl }). For a custom count using its cart, read window.groupStoreCart.lines() and listen for group-store:cart on document. Any origin, no cookie or key; ten calls per minute per address. Send item ids, never prices: the server does not take one.503 means payments are disabled; fall back to the item's payUrl for a buyer-facing explanation. HTTP(S)-only returnUrl supplies the receipt's “Back to” link and ten-second redirect after payment confirmation. Only the paid session's copy redirects; the URL copy remains a link.
Restyle without forking. Colours, radii and fonts use CSS custom properties with original-value fallbacks; every element has a gs- class (gs-row, gs-buy, gs-cart, gs-checkout, gs-total…). Set properties on #group-store or an ancestor — #group-store { --gs-accent: #111; --gs-radius: 0; --gs-font: 15px/1.5 Inter, sans-serif } — or style the classes. Supported properties: --gs-accent, --gs-on-accent, --gs-muted, --gs-faint, --gs-line, --gs-field, --gs-bad, --gs-good, --gs-radius, --gs-button-radius, --gs-font, --gs-font-small and --gs-font-tiny. Direct block edits work but are lost when copying a later version.
One keyless check; no server required:
?d8a_order=<id>. The widget verifies through GET /api/v1/store/orders/<id>?group=<group>, never the URL alone. A { paid: true, order } response fires group-store:paid on document with the order, sets window.groupStorePaid, and shows a one-line receipt above the list. Release products in that handler: document.addEventListener("group-store:paid", (e) => unlock(e.detail.itemId)). window.groupStoreVerify(id) also checks manually entered ids. Unguessable ids exist only for paid orders; possession is the receipt. Public verification reveals purchases, never buyers.GET /api/v1/store/orders/<id>?group=<group> supports the same public, keyless check anywhere without a browser. Buyer identity stays in Admin's Orders list.Byte-for-byte Aragon OSx runs the DAO and voting; our own code is deliberately small.
Every DETF uses the same contract set, deployed and connected in one transaction:
Aragon OSx coreThe DAO and permissions; an exact Aragon OSx copy, checked byte for byte.
TokenVotingToken-holder voting: initially 50% approval and 10% turnout, changeable by a Tiers vote.
DETFGovernanceERC20The group token: carries votes, records past balances and burns to redeem your share.
DETFTreasuryPluginHolds funds, sells tokens for ETH, pays exit shares, runs NAV and auction sales, and holds the kill switch.
DETFBoardPluginBoard membership, seat weights and the vote's pass mark.
DETFTimelockPluginPublic queue for approved actions; board members can cancel them before execution.
DETFTierBadge + DETFSeatsTier-membership badges and shared seats for tiers and token holders.
DETFSeatFactoryCreates badges and seats for the group's plan.
DETFGroupFactoryDeploys and connects the whole set in one transaction.
DETFMigrationV2Deposit & Sell: quote book (up to 32 makers), DEX fallback and leftovers.
DETFAuctionModulePlatform-wide auction house: falling-price sales of voted assets and shut-down groups' leftovers.
DETFAirdropList-based airdrop claims by transfer or minting.
DETFCCIPSender + DistributorV2Ethereum-side token transfers to Solana over Chainlink CCIP.
detf-distributor (Solana)Solana program: CCIP messages, airdrop claims, quote book and withdrawable group treasuries.
DAO and voting code copies Aragon OSx word for word, verified byte for byte, retaining its existing scrutiny. DETF's deliberately small custom code — treasury, queue, board and seats, factory, migration and auctions — is the audit scope, covered by 110 tests.
Rebuilt in July 2026, our contracts close every internal-review finding. An independent professional audit will precede real-money use. Until then, queue delays, voting windows and auction price changes are set to run in minutes rather than days for testing; rules remain unchanged.
Test networks only — Ethereum Sepolia and Solana devnet — never send real money to a DETF contract.
Running on Sepolia testnet
Groups, voting, board and seats, treasury, kill switch, quote-book migrations and auctions
Running on devnet
Ethereum token arrivals, airdrop claims, quote book and withdrawable group treasuries
Being evaluated
Next network considered; not running yet.
A short repository-root file identifies its governing group, run instructions and sales.
Governed repositories carry .d8a at the root. Its five comment lines and link here keep it short because the platform does not own the repository. Two halves share one file:
# .d8a — who governs this repository, how to run it, and how it earns.
# Code lands on this branch only by the named group's passed vote; delete the
# file to withdraw consent. The three lines below, tools: and payments: are
# generated, never hand-edited. Everything else: https://detf.com/docs?group=your-group#d8a-file
version: 1
group: web:your-group
proof: <signed by the platform>
# tools: — the group's own doors, for a person or an agent working here.
tools:
group = https://detf.com/g/your-group
admin = https://detf.com/g/your-group?section=admin
items = https://detf.com/api/v1/store/items?group=your-group
checkout = https://detf.com/api/v1/store/checkout
verify = https://detf.com/api/v1/store/orders/ORDER?group=your-group
# payments: — the group's shop. Paste this block where it should appear on the
# site; card payments land in the group's shared credit balance. Items and
# prices are set on the group's Admin tab, never here. A paid buyer comes back
# with ?d8a_order=<id>; the block verifies it (tools: verify) and fires the
# "group-store:paid" event — release the product there.
payments:
<!-- Sell on your site: your items, a Buy button each. -->
<div id="group-store"></div>
<script> …the widget, printed in full below… </script>
# url: https://example.com — where it runs live; shown as a globe once set.
# run: — how to run it. Yours to edit; VS Code's ▶ (the VSD8A extension) launches each entry.
run:
{
"// dev": "npm run dev",
"// api": { "cmd": "npm start", "cwd": "packages/api", "port": 4000 }
}Three steps for a person or agent working from .d8a; the third implements the shop on this repository's site:
items: within .d8a, one line each: Everyday Tee = $38.00 — soft combed cotton; up to 50 items, minimum $1.00. Passed Code proposals change them; Admin displays them read-only. The platform reads every merge, requiring no site redeploy. Groups without repositories list items in Admin's Sell on your site.https://detf.com/api/v1/store/items?group=<id>: group, items and pay links. Empty items means no listings; the widget renders nothing until an admin lists something..d8a under payments:. Dependency-free HTML and script work on any page or framework: load items, show Buy buttons, open card checkout. Restyle freely but retain data-item and the checkout call; never put prices in the page, since the server sets them. A size, colour or note travels as one line via data-variant on the buy link or variant in checkout. It appears at card checkout, on the receipt and in Admin's order. Higher-priced variants need separate items. On paid return, the block verifies with the platform and fires group-store:paid; release the product there (see the handshake).Replace YOUR-GROUP-ID with the id after the colon in your .d8a file's group: line, or open its docs link to fill it automatically.
<!-- Sell on your site: your items, a selector where one comes in sizes or
colours, and a CART — Add on every row, then one Checkout for the lot. The
basket rides in a pill stuck to the bottom of THIS box, never to your page:
its right half IS the Checkout, its left half opens an optional review
panel. A single-item shop keeps its single Buy button and no basket at all.
Paste ONE of these per page.
STYLING: every colour, radius and font is a CSS custom property with the old
literal as its fallback, and every node carries a gs-* class. Restyle it from
your own stylesheet — #group-store { --gs-accent:#111; --gs-radius:0 } — or
beat the block's own rules from an id: #group-store .gs-row { … }. On a DARK
site set the one thing a sticky control cannot guess:
#group-store { --gs-panel:#111827; --gs-hover:#1f2937 }. --gs-dock-bottom
lifts the bar off the viewport bottom; :auto makes it a plain bar again.
After a payment the buyer
returns here with ?d8a_order=<id>; the widget verifies it with the platform and
fires "group-store:paid" (window.groupStorePaid) — release the product there.
The order id alone is a link anyone could forward, so an order also proves
WHO paid: set window.groupStoreBuyerEmail (ask the returning buyer, or read
your own session) before this block, or call
window.groupStoreVerify(id, email) yourself. The email is hashed here and
never sent.
YOUR OWN CART: window.groupStoreCheckout([{ item, quantity, variant }, …])
opens ONE checkout for everything a cart holds — item ids from the list this
block loads (or tools: items), one line per item and choice. Keep your own
product pages; this is the only call they need. To draw your own basket
count off THIS cart instead, read window.groupStoreCart.lines() and listen
for "group-store:cart" on document. A multi-item shop draws no per-row
quantity box — the basket owns quantity — so a page that scripted one calls
window.groupStoreCart.add({ item, quantity }) instead. -->
<div id="group-store"></div>
<noscript><p><a href="https://detf.com/pay/YOUR-GROUP-ID">Shop</a></p></noscript>
<style>#group-store *{box-sizing:border-box}#group-store :focus-visible{outline:2px solid var(--gs-accent,#7c5cff);outline-offset:2px}#group-store{font:var(--gs-font,14px system-ui,sans-serif)}.gs-t{font:var(--gs-font-tiny,11px system-ui,sans-serif);color:var(--gs-faint,#9ca3af)}.gs-m{color:var(--gs-muted,#6b7280)}.gs-b{color:var(--gs-bad,#dc2626)}.gs-n{font-variant-numeric:tabular-nums}.gs-a{color:var(--gs-accent,#7c5cff)}.gs-r{margin-left:auto}.gs-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:10px 0}.gs-row[data-sold]{opacity:.6}.gs-main{flex:1 1 11rem;min-width:0}.gs-name{font-weight:600}.gs-box{font:inherit;padding:5px 8px;width:76px;border-radius:var(--gs-radius,8px)}.gs-select{width:auto}.gs-opts{display:flex;flex-wrap:wrap;gap:6px}.gs-chip{font:var(--gs-font-small,13px system-ui,sans-serif);line-height:1;padding:7px 11px;cursor:pointer}.gs-chip[aria-pressed=true]{background:var(--gs-accent,#7c5cff);border-color:var(--gs-accent,#7c5cff);color:var(--gs-on-accent,#ffffff)}.gs-opts[data-need] .gs-chip{border-color:var(--gs-bad,#dc2626)}.gs-buy,.gs-checkout{font:inherit;cursor:pointer;text-decoration:none;white-space:nowrap;border:0;padding:9px 18px;background:var(--gs-accent,#7c5cff);color:var(--gs-on-accent,#ffffff)}.gs-buy{display:inline-block;padding:7px 16px}.gs-buy[hidden]{display:none}.gs-checkout{flex:none;min-width:8.5rem;font-weight:600}.gs-bar .gs-checkout{border-radius:0 var(--gs-button-radius,999px) var(--gs-button-radius,999px) 0}.gs-checkout[disabled]{opacity:.6;cursor:default}.gs-stepper{display:inline-flex;align-items:center;padding:2px}.gs-rowstep:empty{display:none}.gs-step{font:inherit;line-height:1;min-width:30px;padding:5px 0;cursor:pointer;border:0}.gs-step[disabled]{opacity:.35;cursor:default}.gs-qty{width:3ch;text-align:center;font:inherit;font-variant-numeric:tabular-nums;border:0}.gs-dock{position:sticky;bottom:var(--gs-dock-bottom,0px);z-index:1;display:flex;flex-direction:column-reverse;align-items:flex-end;margin-top:12px}.gs-bar,.gs-panel{width:min(100%,26rem);background:var(--gs-panel,#ffffff);border:1px solid var(--gs-line,#e5e7eb)}.gs-bar{display:flex;align-items:stretch;margin:8px 0}.gs-toggle{flex:1;display:flex;align-items:center;gap:8px;min-width:0;font:inherit;text-align:left;border:0;padding:10px 14px;cursor:pointer;border-radius:var(--gs-button-radius,999px) 0 0 var(--gs-button-radius,999px)}.gs-toggle:hover{background:var(--gs-hover,#f3f4f6)}.gs-count{flex:none;min-width:22px;padding:0 6px;text-align:center;line-height:20px;font:var(--gs-font-tiny,11px system-ui,sans-serif);background:var(--gs-accent,#7c5cff);color:var(--gs-on-accent,#ffffff)}.gs-count[data-bad]{background:var(--gs-bad,#dc2626)}.gs-sum{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gs-caret{margin-left:auto;transition:transform .15s}[aria-expanded=true] .gs-caret{transform:rotate(180deg)}.gs-panel[hidden]{display:none}.gs-panel{max-height:min(70vh,520px);overflow:auto;border-radius:var(--gs-radius,8px);font:var(--gs-font-small,13px system-ui,sans-serif)}.gs-head,.gs-seller,.gs-cart-row,.gs-tot,.gs-foot{display:flex;align-items:center;gap:8px}.gs-head{padding:10px 12px 4px;font-weight:600}.gs-seller,.gs-why{padding:0 12px 9px}.gs-lines{max-height:min(32vh,220px);overflow:auto;padding:0 12px}.gs-cart-row{flex-wrap:wrap;padding:8px 0}.gs-cart-name{flex:1 1 7rem;min-width:0}.gs-line-total{min-width:68px;text-align:right}.gs-remove{font:inherit;border:0;padding:2px 4px;cursor:pointer;color:var(--gs-faint,#9ca3af)}.gs-sub{flex:1 0 100%}.gs-link{font:inherit;color:var(--gs-accent,#7c5cff);border:0;padding:0 0 0 4px;cursor:pointer;text-decoration:underline}.gs-sums{padding:9px 12px}.gs-tot{padding:2px 0}.gs-tot b{margin-left:auto;font-weight:inherit}.gs-big{font-weight:700}.gs-foot{padding:9px 12px 10px}.gs-foot .gs-checkout{flex:1;min-width:0}.gs-status{margin:6px 0 0;min-height:1.2em;font:var(--gs-font-small,13px system-ui,sans-serif);color:var(--gs-faint,#9ca3af)}.gs-status[data-bad]{color:var(--gs-bad,#dc2626)}[data-option-for][data-need]{border-color:var(--gs-bad,#dc2626)}.gs-row,.gs-lines,.gs-cart-row+.gs-cart-row,.gs-sums,.gs-foot{border-top:1px solid var(--gs-line,#e5e7eb)}.gs-buy,.gs-chip,.gs-bar,.gs-count,.gs-stepper,.gs-foot .gs-checkout{border-radius:var(--gs-button-radius,999px)}.gs-box,.gs-chip,.gs-stepper{border:1px solid var(--gs-field,#d1d5db)}.gs-box,.gs-chip,.gs-toggle,.gs-step,.gs-qty{background:none;color:inherit}.gs-remove,.gs-link{background:none}@media (max-width:520px){.gs-dock{align-items:stretch}.gs-main{flex-basis:100%}.gs-buy,.gs-stepper,.gs-rowstep{margin-left:auto}}@media (prefers-reduced-motion:reduce){.gs-caret{transition:none}}</style>
<script>
(function () {
var BASE = "https://detf.com";
var GROUP = "YOUR-GROUP-ID";
var esc = function (s) {
return String(s).replace(/[&<>"']/g, function (c) { return "&#" + c.charCodeAt(0) + ";"; });
};
var el = document.getElementById("group-store");
// Hashed here, so the address never leaves the page. The split name is ON PURPOSE.
var sha256Hex = function (s) {
try {
var g = window, k = "cr" + "ypto";
var c = g[k] && g[k].subtle;
if (!c) return Promise.resolve("");
return c.digest("SHA-256", new TextEncoder().encode(String(s).trim().toLowerCase())).then(function (b) {
return Array.prototype.map.call(new Uint8Array(b), function (x) { return ("0" + x.toString(16)).slice(-2); }).join("");
});
} catch (e) { return Promise.resolve(""); }
};
var bearer = function () {
try { var l = window.d8aLogin; return l && l.bearer ? { Authorization: "Bearer " + String(l.bearer) } : {}; } catch (e) { return {}; }
};
var verify = function (id, email) {
return (email ? sha256Hex(email) : Promise.resolve("")).then(function (h) {
return fetch(BASE + "/api/v1/store/orders/" + encodeURIComponent(id) + "?group=" + encodeURIComponent(GROUP) + (h ? "&buyer=" + h : ""), { headers: bearer() })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { return d && d.paid ? d.order : null; })
.catch(function () { return null; });
});
};
window.groupStoreVerify = verify;
var here = function () { return location.href.replace(/([?&])d8a_order=[^&#]*&?/, "$1").replace(/[?&](#|$)/, "$1"); };
var checkout = function (lines, opts) {
opts = opts || {};
var headers = bearer(); headers["Content-Type"] = "application/json";
return fetch(BASE + "/api/v1/store/checkout", { method: "POST", headers: headers,
body: JSON.stringify({ group: GROUP, lines: lines, returnUrl: opts.returnUrl || here() }) })
.then(function (r) { return r.json().then(function (d) { if (!r.ok || !d.url) throw new Error(d.error || "Checkout failed"); return d; }); })
.then(function (d) { if (opts.navigate !== false) location.href = d.url; return d; });
};
window.groupStoreCheckout = checkout;
var CART_KEY = "d8a-cart:" + GROUP;
var cart = [];
try {
var saved = JSON.parse(window.localStorage.getItem(CART_KEY) || "[]");
if (Object.prototype.toString.call(saved) === "[object Array]") {
cart = saved.filter(function (l) { return l && typeof l.item === "string" && l.quantity > 0; });
}
} catch (e) { cart = []; }
var cartChanged = function () {
try { window.localStorage.setItem(CART_KEY, JSON.stringify(cart)); } catch (e) { /* memory only */ }
try { document.dispatchEvent(new CustomEvent("group-store:cart", { detail: { lines: cart.slice(0) } })); } catch (e) {}
if (window.groupStoreRenderCart) window.groupStoreRenderCart();
};
var clearCart = function () {
cart = [];
try { window.localStorage.removeItem(CART_KEY); } catch (e) {}
cartChanged();
};
// One line per item AND choice; a second Add of the same pair adds quantity.
var addToCart = function (line) {
for (var i = 0; i < cart.length; i++) {
if (cart[i].item === line.item && (cart[i].variant || "") === (line.variant || "")) {
cart[i].quantity = Math.min(100, cart[i].quantity + line.quantity);
if (line.amountCents) cart[i].amountCents = line.amountCents;
cartChanged();
return true;
}
}
if (cart.length >= 20) return false; // STORE_MAX_LINES: the checkout refuses more
cart.push(line);
cartChanged();
return true;
};
window.groupStoreCart = {
lines: function () { return cart.slice(0); },
add: function (line) { return addToCart({ item: String(line.item), quantity: Math.max(1, Math.min(100, Math.round(line.quantity || 1))), variant: line.variant || "", amountCents: line.amountCents || undefined }); },
clear: clearCart,
checkout: function (opts) { return checkout(cart.slice(0), opts); }
};
var back = (location.search.match(/[?&]d8a_order=([A-Za-z0-9_-]+)/) || [])[1];
if (back) verify(back, window.groupStoreBuyerEmail).then(function (o) {
if (!o) return;
window.groupStorePaid = o;
clearCart(); // it is bought: the basket that opened this checkout is spent
if (el && el.parentNode) {
var p = document.createElement("p");
p.setAttribute("data-paid", o.id);
p.style.cssText = "font:var(--gs-font-small,13px system-ui,sans-serif);color:var(--gs-good,#059669)";
// One line for the whole order — a cart reads "Everyday Tee (M) + 2 more".
p.innerHTML = "Paid: " + esc(o.summary || (o.itemName + (o.quantity > 1 ? " \u00d7" + o.quantity : ""))) + " \u2014 order " + esc(o.id);
el.parentNode.insertBefore(p, el);
}
document.dispatchEvent(new CustomEvent("group-store:paid", { detail: o }));
});
if (!el) return;
var store = null; // what is on sale, so the handler below sees the latest load
var amending = -1; // which line has its named amount open for editing
var money = function (c) { return "$" + (Math.max(0, Math.round(c)) / 100).toFixed(2); };
var itemById = function (id) {
var xs = (store && store.items) || [];
for (var i = 0; i < xs.length; i++) { if (xs[i].id === id) return xs[i]; }
return null;
};
// What one line may hold: the seller's stock, else the cap the route enforces.
var capOf = function (it) { return (it && typeof it.stock === "number" && it.stock > 0) ? Math.min(100, it.stock) : 100; };
var status = function (msg, bad) {
var p = el.querySelector("[data-cart-status]");
if (!p) return;
p.textContent = msg || "";
if (bad) p.setAttribute("data-bad", "1"); else p.removeAttribute("data-bad");
if (bad && msg && p.scrollIntoView) p.scrollIntoView({ block: "nearest" });
};
// Written once: a host page may paste this inside a form of its own, where a
// button with no type submits it. Every button below opens with these bytes.
var BTN = '<button type="button" class="gs-';
var loadFailed = function () {
el.innerHTML = '<p role="status" class="gs-error gs-t">The shop could not be reached. ' + BTN + 'retry gs-link" data-store-retry>Try again</button></p>';
var retry = el.querySelector("[data-store-retry]");
if (retry) retry.addEventListener("click", function () { load(); });
};
var openPanel = function (want) {
var tog = el.querySelector("[data-cart-toggle]"), pan = el.querySelector("[data-cart-panel]");
if (!tog || !pan) return;
tog.setAttribute("aria-expanded", want ? "true" : "false");
if (want) pan.removeAttribute("hidden"); else pan.setAttribute("hidden", "hidden");
};
// One shape, twice: panel (typable) and row. Both carry the cart INDEX.
var stepper = function (at, qty, cap, typable, name) {
return '<span class="gs-stepper">' + BTN + 'step" data-dec="' + at + '" aria-label="One fewer">\u2212</button>' +
(typable ? '<input class="gs-qty" data-cart-qty="' + at + '" type="text" inputmode="numeric" value="' + qty + '" aria-label="Quantity of ' + name + '">' : '<span class="gs-qty">' + qty + '</span>') +
BTN + 'step" data-inc="' + at + '"' + (qty >= cap ? ' disabled title="Only ' + cap + ' left"' : '') + ' aria-label="One more">+</button></span>';
};
// A rewritten or hidden control cannot keep focus: name it, stand there again.
var FK = ["data-inc", "data-dec", "data-del", "data-cart-qty"];
var keyOf = function (f) {
for (var i = 0; i < 4; i++) { var v = f && f.getAttribute && f.getAttribute(FK[i]); if (v != null) return "[" + FK[i] + '="' + v + '"]'; }
return "";
};
var refocus = function (r, k) { var f = k && r.querySelector(k); if (f && f.disabled) f = f.parentNode.querySelector("[data-dec]"); if (f) f.focus(); };
// The Add, and the amount box beside it, are hidden for that line's stepper.
var renderRowState = function () {
var as = el.querySelectorAll("a[data-item]");
for (var i = 0; i < as.length; i++) {
var a = as[i], id = a.getAttribute("data-item");
var slot = el.querySelector('[data-rowstep-for="' + id + '"]');
if (!slot) continue;
var ab = el.querySelector('[data-amount-for="' + id + '"]');
var sel = el.querySelector('[data-option-for="' + id + '"]');
var v = sel ? sel.value : (a.getAttribute("data-variant") || "");
var at = -1;
for (var j = 0; j < cart.length; j++) { if (cart[j].item === id && (cart[j].variant || "") === v) { at = j; break; } }
var act = document.activeElement, held = slot.contains(act), h = at < 0 ? "removeAttribute" : "setAttribute";
a[h]("hidden", "hidden");
if (ab) ab[h]("hidden", "hidden");
if (at < 0) { slot.innerHTML = ""; if (held) a.focus(); continue; }
var k = act === a ? '[data-inc="' + at + '"]' : (held ? keyOf(act) : "");
slot.innerHTML = stepper(at, cart[at].quantity, capOf(itemById(id)), false, "");
refocus(slot, k);
}
};
// Redrawn whole, so the button indices are the ones on screen.
var renderCart = function () {
var box = el.querySelector("[data-cart]");
if (!box) return;
var s = store;
// A one-item shop draws no dock; its stored lines are unseen, NOT deleted.
if (!s || !s.items || s.items.length < 2 || !cart.length) { box.innerHTML = ""; renderRowState(); return; }
var prev = box.querySelector("[data-cart-toggle]");
var wasOpen = !!(prev && prev.getAttribute("aria-expanded") === "true");
var ae = document.activeElement, fk = box.contains(ae) ? keyOf(ae) : "";
var total = 0, units = 0, needsFix = false;
var rows = cart.map(function (l, i) {
var it = itemById(l.item);
var unit = l.amountCents || (it ? it.priceCents : 0);
// Inherited from an earlier visit: over the stock, or under the floor.
var over = (it && typeof it.stock === "number" && it.stock > 0 && l.quantity > it.stock) ? it.stock : 0;
var under = it && it.payWhatYouWant && unit < it.priceCents;
if (over || under) needsFix = true;
total += unit * l.quantity;
units += l.quantity;
var nm = esc(it ? it.name : l.item), sub = "";
if (over) sub = '<span class="gs-sub gs-t gs-b">Only ' + over + ' left \u2014 you asked for ' + l.quantity + ' \u00b7 ' + BTN + 'link" data-clamp="' + i + '">use ' + over + '</button></span>';
else if (it && it.payWhatYouWant) sub = amending === i
? '<span class="gs-sub gs-t"><input class="gs-box gs-n" data-amend-for="' + i + '" type="text" inputmode="decimal" value="' + (Math.max(0, Math.round(unit)) / 100).toFixed(2) + '" aria-label="Amount for ' + nm + '"></span>'
: '<span class="gs-sub gs-t' + (under ? ' gs-b' : '') + '">You named ' + money(unit) + (under ? ' \u2014' : ' \u00b7') + ' minimum ' + money(it.priceCents) + ' \u00b7 ' + BTN + 'link" data-' + (under ? 'floor' : 'amend') + '="' + i + '">' + (under ? 'use ' + money(it.priceCents) : 'change') + '</button></span>';
else if (l.quantity > 1) sub = '<span class="gs-sub gs-t">' + money(unit) + ' each</span>';
return '<div class="gs-cart-row"><span class="gs-cart-name">' + nm + (l.variant ? ' <span class="gs-m">(' + esc(l.variant) + ')</span>' : '') + '</span>' +
stepper(i, l.quantity, capOf(it), true, nm) +
'<span class="gs-line-total gs-n">' + money(unit * l.quantity) + '</span>' +
BTN + 'remove" data-del="' + i + '" aria-label="Remove">\u00d7</button>' + sub + '</div>';
}).join("");
var ck = s.checkout || {}, off = ck.enabled === false, ship = !!ck.shipping, promo = !!ck.promoCodes;
var g = s.group || {};
var gn = '<b>' + esc(g.name || "") + '</b>';
var seller = 'Sold by ' + (g.url ? '<a class="gs-a" href="' + esc(g.url) + '">' + gn + '</a>' : gn);
// The second sentence is only honest because checkout() sends a returnUrl.
var signedIn = false;
try { signedIn = !!bearer().Authorization; } catch (e) {}
var why = (signedIn ? "Paying as your signed-in account \u2014 this order shows under your purchases."
: "You'll enter your email at the card checkout \u2014 it's what proves the receipt is yours.") +
" After paying you come back here and this basket empties.";
// The resting button IS the checkout, until the line it would send is doomed.
var barBtn = BTN + 'checkout" data-checkout' + (needsFix ? ' data-fix' : (off ? ' disabled' : '')) + '>' + (needsFix ? "Fix" : (off ? "Payments not set up" : "Checkout")) + '</button>';
var footBtn = BTN + 'checkout" data-checkout' + (needsFix || off ? ' disabled' : '') + '>' + (needsFix ? "Fix the line above" : (off ? "Payments not set up" : "Checkout \u00b7 " + money(total))) + '</button>';
box.innerHTML = '<div class="gs-bar">' + BTN + 'toggle" data-cart-toggle aria-expanded="false">' +
'<span class="gs-count"' + (needsFix ? ' data-bad' : '') + '>' + units + '</span>' +
'<span class="gs-sum">Basket \u00b7 ' + money(total) + '</span>' +
'<span class="gs-caret" aria-hidden="true">\u25b4</span></button>' + barBtn + '</div>' +
'<div class="gs-panel" role="region" aria-label="Basket" data-cart-panel hidden>' +
'<div class="gs-head">Basket<span class="gs-r gs-t">' + cart.length + ' of 20 things</span></div>' +
'<div class="gs-seller gs-m"><span>' + seller + '</span><span class="gs-r gs-t">Paid by card</span></div>' +
'<div class="gs-lines">' + rows + '</div><div class="gs-sums">' +
(ship ? '<div class="gs-tot"><span>Subtotal</span><b class="gs-n">' + money(total) + '</b></div><div class="gs-tot gs-m"><span>Shipping</span><b>Address collected at checkout</b></div>' : '') +
(promo ? '<div class="gs-tot gs-m"><span>Promo code</span><b>Enter it at the card checkout</b></div>' : '') +
'<div class="gs-tot gs-big"><span>Total today</span><b class="gs-n">' + money(total) + (ship ? ' + shipping' : '') + '</b></div></div>' +
'<div class="gs-why gs-t">' + why + '</div>' +
'<div class="gs-foot">' + footBtn + BTN + 'clear gs-link gs-t" data-clear>Empty basket</button></div></div>';
if (wasOpen) openPanel(true); // an open panel survives a step or a remove
renderRowState();
refocus(box, fk); // the + you just pressed is a new node; stand there again
};
window.groupStoreRenderCart = renderCart;
var load = function () {
el.innerHTML = '<p role="status" class="gs-loading gs-t">Loading what's on sale\u2026</p>';
// A request that never answers never rejects; without this it loads for ever.
var done = false, timedOut = false;
var ctrl = window.AbortController ? new AbortController() : null;
var timer = setTimeout(function () {
if (done) return;
timedOut = true;
if (ctrl) { try { ctrl.abort(); } catch (err) { loadFailed(); } } // rejects -> .catch below
else { loadFailed(); } // no AbortController: draw it here
}, 12000);
fetch(BASE + "/api/v1/store/items?group=" + encodeURIComponent(GROUP), ctrl ? { signal: ctrl.signal } : undefined)
.then(function (r) {
if (timedOut) return null; // late answer: the failure line stands
done = true;
clearTimeout(timer); // a slow but good render is never killed
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (s) {
if (!s) return;
if (!s.items) throw new Error("bad payload");
store = s;
var ck = s.checkout || {};
if (!s.items.length) { el.innerHTML = '<p class="gs-empty gs-t">Nothing for sale right now.</p>'; return; }
// More than one thing to buy = a basket is worth it; one thing is not.
var multi = s.items.length > 1;
el.innerHTML = s.items.map(function (it) {
// Sold out gets no Buy; a cap prints "3 left"; PWYW prefills its box.
var sold = it.soldOut === true, iid = esc(it.id), nm = esc(it.name);
var left = (typeof it.stock === "number" && it.stock > 0) ? '<span class="gs-left gs-t gs-m">' + it.stock + ' left</span>' : '';
var priceText = esc(it.price) + (it.payWhatYouWant ? '+' : '');
var amountBox = it.payWhatYouWant && !sold
? '<input type="number" class="gs-box gs-n" data-amount-for="' + iid + '" min="' + (it.priceCents / 100) + '" step="0.01" value="' + (it.priceCents / 100).toFixed(2) + '" onfocus="this.select()" aria-label="Amount for ' + nm + '">'
: '';
var options = '';
if (Array.isArray(it.options) && it.options.length && !sold) {
options = (it.options.length <= 6 && it.options.join("").length <= 40)
? '<span class="gs-opts" role="group" aria-label="Choose one for ' + nm + '" data-opts-for="' + iid + '">' + it.options.map(function (o) { return BTN + 'chip" data-chip="' + iid + '" data-value="' + esc(o) + '" aria-pressed="false">' + esc(o) + '</button>'; }).join("") + '</span>'
: '<select data-option-for="' + iid + '" aria-label="Choose one for ' + nm + '" class="gs-box gs-select"><option value="">Choose\u2026</option>' + it.options.map(function (o) { return '<option value="' + esc(o) + '">' + esc(o) + '</option>'; }).join("") + '</select>';
}
var qty = (sold || multi) ? '' : '<input type="number" class="gs-box gs-n" data-qty-for="' + iid + '" min="1" max="' + capOf(it) + '" value="1" aria-label="Quantity of ' + nm + '">';
// The same <a> in both modes: no-script href, data-variant hook, and
// data-add is what routes it into the basket.
var action = sold
? '<span class="gs-soldout gs-t">Sold out</span>'
: '<a href="' + esc(it.payUrl) + '" data-item="' + iid + '"' + (multi ? ' data-add="1"' : '') + ' class="gs-buy">' + (multi ? 'Add' : 'Buy') + '</a>' +
(multi ? '<span class="gs-rowstep" data-rowstep-for="' + iid + '"></span>' : '');
return '<div class="gs-row"' + (sold ? ' data-sold' : '') + '>' +
'<div class="gs-main"><b class="gs-name">' + nm + '</b>' +
(it.description ? '<div class="gs-desc gs-t">' + esc(it.description) + '</div>' : '') + '</div>' +
left + options + qty + amountBox +
'<span class="gs-price">' + priceText + '</span>' + action + '</div>';
}).join("") +
'<div class="gs-dock" data-cart></div>' +
'<p class="gs-status" data-cart-status role="status"></p>' +
'<p class="gs-note gs-t">Sold by <a class="gs-a" href="' + esc(s.group.url) + '">' + esc(s.group.name) + '</a>' +
(!multi && ck.shipping ? ' \u00b7 Shipping address collected at checkout' : '') +
(!multi && ck.promoCodes ? ' \u00b7 Have a promo code? Enter it at checkout' : '') + '</p>';
// A stored basket can name an item the shop has withdrawn: drop, say so.
var before = cart.length, gone = [];
cart = cart.filter(function (l) {
var it = itemById(l.item);
if (!it || it.soldOut === true) { gone.push(it ? it.name : l.item); return false; }
return true;
});
if (gone.length) status("Sold out and removed: " + gone.join(", ") + ".", true);
if (cart.length !== before) cartChanged(); else renderCart();
})
.catch(function () {
clearTimeout(timer);
loadFailed();
});
};
el.addEventListener("click", function (e) {
var t = e.target;
var near = function (q) { return t && t.closest ? t.closest(q) : null; };
var tog = near("[data-cart-toggle]");
if (tog) { openPanel(tog.getAttribute("aria-expanded") !== "true"); return; }
var btn = near("button[data-inc],button[data-dec],button[data-del],button[data-checkout]");
if (btn) {
var inc = btn.getAttribute("data-inc"), dec = btn.getAttribute("data-dec"), del = btn.getAttribute("data-del");
if (del !== null) { cart.splice(Number(del), 1); cartChanged(); return; }
if (inc !== null || dec !== null) {
var at = Number(inc !== null ? inc : dec);
var line = cart[at];
if (!line) return;
// One fewer than one is no line at all — the same thing the \u00d7 does.
if (inc === null && line.quantity <= 1) { cart.splice(at, 1); cartChanged(); return; }
var cap = capOf(itemById(line.item));
line.quantity = Math.max(1, Math.min(cap, line.quantity + (inc !== null ? 1 : -1)));
cartChanged();
return;
}
if (btn.getAttribute("data-fix") !== null) { openPanel(true); return; }
status("");
var all = el.querySelectorAll("[data-checkout]");
for (var k = 0; k < all.length; k++) all[k].disabled = true;
btn.textContent = "Opening\u2026";
checkout(cart.slice(0)).catch(function (err) {
renderCart();
// The platform's own words, so the buyer is told what to change.
status((err && err.message) || "Checkout failed", true);
});
return;
}
// A chip only selects.
var chip = near(".gs-chip");
if (chip) {
var grp = chip.parentNode, cs = grp.querySelectorAll(".gs-chip");
for (var ci = 0; ci < cs.length; ci++) cs[ci].setAttribute("aria-pressed", cs[ci] === chip ? "true" : "false");
grp.removeAttribute("data-need");
var link = el.querySelector('a[data-item="' + chip.getAttribute("data-chip") + '"]');
if (link) link.setAttribute("data-variant", chip.getAttribute("data-value"));
renderRowState();
return;
}
var edit = near("[data-clamp],[data-floor],[data-amend],[data-clear]");
if (edit) {
if (edit.getAttribute("data-clear") !== null) { clearCart(); status("Basket emptied."); return; }
var cl = edit.getAttribute("data-clamp"), fl = edit.getAttribute("data-floor");
var ln = cart[Number(cl !== null ? cl : (fl !== null ? fl : edit.getAttribute("data-amend")))];
if (!ln) return;
if (cl !== null) { ln.quantity = Math.max(1, capOf(itemById(ln.item))); cartChanged(); return; }
if (fl !== null) { var itf = itemById(ln.item); if (itf) ln.amountCents = itf.priceCents; cartChanged(); return; }
amending = Number(edit.getAttribute("data-amend"));
renderCart();
var open = el.querySelector("[data-amend-for]");
if (open) open.focus();
return;
}
var a = near("a[data-item]");
var s = store;
if (!a || !s || !s.checkout.enabled) return;
e.preventDefault();
var itemId = a.getAttribute("data-item");
// WHICH ONE: the selector, else the pressed chip or your own data-variant.
var sel = el.querySelector('[data-option-for="' + itemId + '"]');
var variant = sel ? sel.value : (a.getAttribute("data-variant") || "");
if (sel && !variant) { sel.focus(); sel.setAttribute("data-need", "1"); setTimeout(function () { sel.removeAttribute("data-need"); }, 1200); return; }
var opts = el.querySelector('[data-opts-for="' + itemId + '"]');
if (opts && !variant) {
opts.setAttribute("data-need", "1");
var first = opts.querySelector(".gs-chip");
if (first) first.focus();
setTimeout(function () { opts.removeAttribute("data-need"); }, 1200);
return;
}
var qtyBox = el.querySelector('[data-qty-for="' + itemId + '"]');
var quantity = qtyBox ? Math.max(1, Math.min(100, Math.round(parseFloat(qtyBox.value) || 1))) : 1;
// The amount the buyer named, in cents; only sent when there is a box. It
// is NOT raised to the minimum here — the basket says so instead.
var box = el.querySelector('[data-amount-for="' + itemId + '"]');
var amountCents = box && box.value ? Math.round(parseFloat(box.value) * 100) : null;
// ADD: one unit, the page stays put, the row's Add becomes its stepper.
if (a.getAttribute("data-add")) {
var added = addToCart({ item: itemId, quantity: 1, variant: variant, amountCents: (amountCents && amountCents > 0) ? amountCents : undefined });
var itn = itemById(itemId);
status(added ? ((itn ? itn.name : itemId) + (variant ? " (" + variant + ")" : "") + " added.")
: "That is as many different things as one order can hold.", !added);
return;
}
a.textContent = "Opening\u2026";
var headers = bearer(); headers["Content-Type"] = "application/json";
fetch(s.checkout.url, { method: "POST", headers: headers,
body: JSON.stringify({ group: GROUP, item: itemId, quantity: quantity, variant: variant, returnUrl: here(),
amountCents: (amountCents && amountCents > 0) ? amountCents : undefined }) })
.then(function (r) { return r.json(); })
.then(function (d) { if (d.url) { location.href = d.url; } else { a.textContent = "Buy"; location.href = a.href; } })
.catch(function () { location.href = a.href; });
});
// Bound to the widget's box, NEVER document, and only when the panel is open.
el.addEventListener("keydown", function (e) {
var t = e.target, k = e.key;
if (k === "Enter" && t && t.getAttribute && (t.getAttribute("data-cart-qty") !== null || t.getAttribute("data-amend-for") !== null)) { e.preventDefault(); t.blur(); return; }
if (k !== "Escape") return;
var tog = el.querySelector("[data-cart-toggle]");
if (!tog || tog.getAttribute("aria-expanded") !== "true") return;
openPanel(false);
tog.focus();
});
// The prefilled minimum is selected on touch, even where onfocus is blocked.
el.addEventListener("focusin", function (e) {
var t = e.target;
if (t && t.getAttribute && t.getAttribute("data-amount-for") !== null && t.select) { try { t.select(); } catch (err) {} }
});
// The ONE typable field, and a named amount: raw text, clamped on blur/Enter.
el.addEventListener("change", function (e) {
var t = e.target;
if (!t || !t.getAttribute) return;
// A different choice is a different line, so the row goes back to Add.
if (t.getAttribute("data-option-for") !== null) { renderRowState(); return; }
var q = t.getAttribute("data-cart-qty");
if (q !== null) {
var lq = cart[Number(q)];
if (!lq) return;
// The commit rule, written to match the React cart case for case: the two
// share ONE basket (the same storage key), so a rule that differs between
// them is a real divergence, not a cosmetic one. Blank or non-numeric
// leaves the stored quantity alone and puts it back in the box - Number,
// not parseFloat, so "12abc" reads as a typo rather than as twelve. Below
// one is not a line at all, which is what the minus at one already does.
var raw = String(t.value).trim();
var n = raw ? Math.round(Number(raw)) : NaN;
if (!isFinite(n)) { t.value = lq.quantity; return; }
if (n < 1) { cart.splice(Number(q), 1); cartChanged(); return; }
lq.quantity = Math.min(capOf(itemById(lq.item)), n);
cartChanged();
return;
}
var m = t.getAttribute("data-amend-for");
if (m !== null) {
var lm = cart[Number(m)];
if (!lm) return;
var itm = itemById(lm.item);
var c = Math.round(parseFloat(t.value) * 100);
lm.amountCents = c > 0 ? c : (itm ? itm.priceCents : 1);
amending = -1;
cartChanged();
}
});
load();
})();
</script>version, group and proof come from Connect with GitHub (bot-committed) or Manage group. group binds the default-branch merge bot to one group's passed Code votes; it refuses others. Platform-signed proof prevents rebinding by text edits.tools: links and group-filled payments: widget. Everything from the url: note down survives byte for byte. Three writers — both products, the github bot and VSD8A extension — produce identical bytes, preventing no-op commits on reconnection.url: https://… — optional live URL, HTTP(S) only; VSD8A shows a footer globe while present. Commented # url: is ignored, as in the shipped sample.run:'s web entry or the first port-declaring entry. Commit a URL change to move the site; remove it to take the site down.run: starts an indented block: one terminal per entry, launched together only by VS Code's VSD8A ▶ button. Accepted forms: NAME[:PORT] [@ SUBFOLDER] = COMMAND lines or JSON { "name": "command" }, with entries also accepting { cmd, cwd, port }.// and lines starting # are ignored notes or disabled entries. Fresh examples are disabled; committing launches nothing until you remove //.run:; Server deliberately has no start-command field. Commit startup changes here to keep local and server commands aligned. Boxes run only commands declared here or in the project's package.json scripts.server: names the rented box's host; keys: lists setting names, never values, which live only on the box and are set in Server. Both blocks are generated. Since 2026-09-02 the platform commits changes automatically on renting, cancelling or losing a server and adding or removing setting names, without re-saving. Failed commits produce stale-file warnings in Manage group and Server; Connect with GitHub re-stamps the file.keys: flows both ways. Code can add names in the same pull request as features needing them. After merge, Server shows each as declared by the code, no value yet for an admin to fill. File imports only add names; removal requires Server, preventing credentials from remaining active after their names silently disappear.keys: at the proposed commit and are instructed to reject undeclared reads: unset server values would fail at first use. Tests, examples and docs are exempt; adding the name to keys: in the change satisfies the check.The header's third sentence covers sales from any page, including this repository's site, into shared credits. items: defines one item per line: name, dollar-and-cent price and optional text after a dash. Every merge updates listings exactly; unreadable lines are reported in Admin and skipped, while removed items are paused, not deleted. Paste the widget on the url: site; see Sell on your site for rules.
Bots, notifications & airdrops
Link Telegram, Discord or email for vote notifications and community airdrop claims there.
Link Telegram, Discord or email in settings; Twitter/X is identity-only. The app opens the bot, such as @DETFcomBOT on Telegram, to link your account and wallet. Receive proposal, money and mention notifications in chats and the site bell. Each channel can be toggled and tested.
Bots distribute community airdrops: groups reserve Telegram or Discord tokens, admins open with
/airdrop, and people use/claim. The bot prompts unlinked claimants to link a wallet. Pools split equally among claimants or pay a fixed amount each until exhausted. Migration airdrops need no setup: waiting tokens in a connected wallet automatically show a group-page claim banner, with no eligibility-check button.