471 lines
15 KiB
HTML
471 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>EDUT Store Preview</title>
|
|
<meta name="description" content="EDUT membership-gated marketplace preview states.">
|
|
<meta name="theme-color" content="#f0f4f8">
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&display=swap');
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
background: #f0f4f8;
|
|
color: #2c2c2c;
|
|
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
|
min-height: 100vh;
|
|
padding: 48px 20px;
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
}
|
|
.container { max-width: 960px; margin: 0 auto; }
|
|
a { color: #2c2c2c; text-underline-offset: 2px; }
|
|
.back {
|
|
display: inline-block;
|
|
margin-bottom: 28px;
|
|
font-size: 12px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
}
|
|
h1 {
|
|
font-size: 18px;
|
|
letter-spacing: 0.16em;
|
|
text-transform: uppercase;
|
|
font-weight: 400;
|
|
margin-bottom: 10px;
|
|
}
|
|
.sub {
|
|
color: #5a5f67;
|
|
font-size: 12px;
|
|
letter-spacing: 0.08em;
|
|
margin-bottom: 28px;
|
|
}
|
|
.grid {
|
|
display: grid;
|
|
gap: 14px;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
margin-bottom: 20px;
|
|
}
|
|
.card {
|
|
border: 1px solid #d0d5db;
|
|
background: #f8fafc;
|
|
padding: 14px;
|
|
}
|
|
.label {
|
|
font-size: 11px;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: #6a7179;
|
|
margin-bottom: 10px;
|
|
}
|
|
.title {
|
|
font-size: 13px;
|
|
margin-bottom: 10px;
|
|
color: #33393f;
|
|
}
|
|
.line {
|
|
font-size: 12px;
|
|
line-height: 1.7;
|
|
color: #454b54;
|
|
}
|
|
.state {
|
|
display: inline-block;
|
|
margin-top: 8px;
|
|
padding: 3px 8px;
|
|
font-size: 10px;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
border: 1px solid #c2c8d0;
|
|
color: #5a616a;
|
|
}
|
|
.state.ok { border-color: #6f8d72; color: #3f6545; }
|
|
.state.block { border-color: #9d7676; color: #7c4a4a; }
|
|
.state.warn { border-color: #99834b; color: #6d5b30; }
|
|
.actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
margin-top: 12px;
|
|
}
|
|
button {
|
|
border: 1px solid #c2c8d0;
|
|
background: #ffffff;
|
|
padding: 7px 10px;
|
|
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
|
font-size: 11px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: #3d434a;
|
|
cursor: pointer;
|
|
}
|
|
button:hover { border-color: #8c949d; }
|
|
button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
select {
|
|
border: 1px solid #c2c8d0;
|
|
background: #ffffff;
|
|
padding: 6px 8px;
|
|
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
|
font-size: 11px;
|
|
color: #3d434a;
|
|
}
|
|
.status-log {
|
|
margin-top: 12px;
|
|
border-top: 1px solid #d0d5db;
|
|
padding-top: 10px;
|
|
font-size: 11px;
|
|
color: #60666f;
|
|
line-height: 1.6;
|
|
min-height: 40px;
|
|
white-space: pre-wrap;
|
|
}
|
|
.mono { font-family: 'IBM Plex Mono', 'Courier New', monospace; }
|
|
.foot {
|
|
margin-top: 20px;
|
|
border-top: 1px solid #d0d5db;
|
|
padding-top: 14px;
|
|
font-size: 11px;
|
|
letter-spacing: 0.08em;
|
|
color: #666d75;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<a href="/" class="back">← Back</a>
|
|
<h1>EDUT Store</h1>
|
|
<p class="sub">Membership-gated checkout behavior (live-state scaffold)</p>
|
|
|
|
<div class="grid">
|
|
<section class="card">
|
|
<p class="label">Wallet + Membership</p>
|
|
<p class="line">Wallet: <span class="mono" id="wallet-label">not connected</span></p>
|
|
<p class="line">Membership status: <span class="mono" id="membership-label">unknown</span></p>
|
|
<p class="line">Gate decision: <span class="mono" id="gate-label">blocked</span></p>
|
|
<span class="state block" id="gate-pill">membership required</span>
|
|
<div class="actions">
|
|
<button id="connect-btn" type="button">connect wallet</button>
|
|
<button id="refresh-btn" type="button">refresh state</button>
|
|
<label class="line" for="mock-select">mock mode:</label>
|
|
<select id="mock-select" aria-label="Mock state override">
|
|
<option value="">live</option>
|
|
<option value="active">active</option>
|
|
<option value="none">none</option>
|
|
<option value="suspended">suspended</option>
|
|
<option value="revoked">revoked</option>
|
|
</select>
|
|
</div>
|
|
<div class="status-log" id="status-log">No checks run yet.</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<p class="label">Offer Catalog</p>
|
|
<p class="line">Offer:</p>
|
|
<select id="offer-select" aria-label="Select offer"></select>
|
|
<p class="title" id="offer-title">Loading offers...</p>
|
|
<p class="line" id="offer-summary">Catalog data pending.</p>
|
|
<p class="line" id="offer-price">Price: --</p>
|
|
<p class="line" id="offer-policy">Policy: --</p>
|
|
<p class="line">Action chain: membership check -> quote -> wallet confirm -> entitlement receipt</p>
|
|
<div class="actions">
|
|
<button id="checkout-btn" type="button" disabled>request checkout quote</button>
|
|
</div>
|
|
<div class="status-log" id="checkout-log">Checkout is blocked until membership is active.</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<p class="label">Fail-Closed States</p>
|
|
<p class="line">No membership: checkout blocked.</p>
|
|
<p class="line">Suspended/revoked: checkout and activation blocked.</p>
|
|
<p class="line">Unknown state or API error: blocked by default.</p>
|
|
<span class="state warn">default deny</span>
|
|
</section>
|
|
</div>
|
|
|
|
<p class="foot">This page is intentionally deterministic: if membership cannot be confirmed, purchase remains blocked. <a href="/trust">Trust page</a>.</p>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
const state = {
|
|
wallet: null,
|
|
membership: 'unknown',
|
|
gate: false,
|
|
source: 'live',
|
|
offers: [],
|
|
selectedOfferId: null,
|
|
};
|
|
|
|
const walletLabel = document.getElementById('wallet-label');
|
|
const membershipLabel = document.getElementById('membership-label');
|
|
const gateLabel = document.getElementById('gate-label');
|
|
const gatePill = document.getElementById('gate-pill');
|
|
const statusLog = document.getElementById('status-log');
|
|
const checkoutLog = document.getElementById('checkout-log');
|
|
const connectBtn = document.getElementById('connect-btn');
|
|
const refreshBtn = document.getElementById('refresh-btn');
|
|
const checkoutBtn = document.getElementById('checkout-btn');
|
|
const mockSelect = document.getElementById('mock-select');
|
|
const offerSelect = document.getElementById('offer-select');
|
|
const offerTitle = document.getElementById('offer-title');
|
|
const offerSummary = document.getElementById('offer-summary');
|
|
const offerPrice = document.getElementById('offer-price');
|
|
const offerPolicy = document.getElementById('offer-policy');
|
|
|
|
function abbreviateWallet(wallet) {
|
|
if (!wallet || wallet.length < 10) return wallet || 'not connected';
|
|
return wallet.slice(0, 6) + '...' + wallet.slice(-4);
|
|
}
|
|
|
|
function setLog(message) {
|
|
statusLog.textContent = message;
|
|
}
|
|
|
|
function setCheckoutLog(message) {
|
|
checkoutLog.textContent = message;
|
|
}
|
|
|
|
function getSelectedOffer() {
|
|
if (!state.selectedOfferId) return null;
|
|
return state.offers.find((offer) => offer.offer_id === state.selectedOfferId) || null;
|
|
}
|
|
|
|
function renderSelectedOffer() {
|
|
const selected = getSelectedOffer();
|
|
if (!selected) {
|
|
offerTitle.textContent = 'No offer selected';
|
|
offerSummary.textContent = 'Catalog data is unavailable.';
|
|
offerPrice.textContent = 'Price: --';
|
|
offerPolicy.textContent = 'Policy: --';
|
|
return;
|
|
}
|
|
|
|
offerTitle.textContent = selected.title || selected.offer_id;
|
|
offerSummary.textContent = selected.summary || 'No summary provided.';
|
|
offerPrice.textContent = 'Price: ' + (selected.price || '--') + ' ' + (selected.currency || '');
|
|
offerPolicy.textContent = 'Policy: member-only=' + Boolean(selected.member_only) +
|
|
', workspace-bound=' + Boolean(selected.workspace_bound) +
|
|
', transferable=' + Boolean(selected.transferable);
|
|
}
|
|
|
|
function populateOfferSelect() {
|
|
offerSelect.innerHTML = '';
|
|
for (const offer of state.offers) {
|
|
const option = document.createElement('option');
|
|
option.value = offer.offer_id;
|
|
option.textContent = offer.offer_id;
|
|
offerSelect.appendChild(option);
|
|
}
|
|
if (state.selectedOfferId) {
|
|
offerSelect.value = state.selectedOfferId;
|
|
}
|
|
renderSelectedOffer();
|
|
}
|
|
|
|
function normalizeMembership(raw) {
|
|
const value = String(raw || '').toLowerCase();
|
|
if (value === 'active') return 'active';
|
|
if (value === 'suspended') return 'suspended';
|
|
if (value === 'revoked') return 'revoked';
|
|
if (value === 'none' || value === 'inactive') return 'none';
|
|
return 'unknown';
|
|
}
|
|
|
|
function applyGateState() {
|
|
state.gate = state.membership === 'active';
|
|
|
|
walletLabel.textContent = abbreviateWallet(state.wallet);
|
|
membershipLabel.textContent = state.membership;
|
|
gateLabel.textContent = state.gate ? 'enabled' : 'blocked';
|
|
|
|
if (state.gate) {
|
|
gatePill.className = 'state ok';
|
|
gatePill.textContent = 'checkout enabled';
|
|
} else if (state.membership === 'unknown') {
|
|
gatePill.className = 'state warn';
|
|
gatePill.textContent = 'status unknown';
|
|
} else {
|
|
gatePill.className = 'state block';
|
|
gatePill.textContent = 'membership required';
|
|
}
|
|
|
|
checkoutBtn.disabled = !state.gate || !state.selectedOfferId;
|
|
}
|
|
|
|
function getMockFromQuery() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
return normalizeMembership(params.get('mock'));
|
|
}
|
|
|
|
function getStoredDesignationCode() {
|
|
const raw = localStorage.getItem('edut_ack_state');
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && parsed.code ? parsed.code : null;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const response = await fetch(url, { method: 'GET' });
|
|
if (!response.ok) {
|
|
throw new Error('HTTP ' + response.status);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function loadOffers() {
|
|
try {
|
|
const payload = await fetchJson('/store/offers.json');
|
|
if (!payload || !Array.isArray(payload.offers) || payload.offers.length === 0) {
|
|
throw new Error('offers catalog is empty');
|
|
}
|
|
state.offers = payload.offers;
|
|
state.selectedOfferId = payload.offers[0].offer_id;
|
|
populateOfferSelect();
|
|
setCheckoutLog('Offer catalog loaded: ' + payload.offers.length + ' offers.');
|
|
} catch (err) {
|
|
state.offers = [{
|
|
offer_id: 'edut.governance.core',
|
|
title: 'EDUT Governance Core (fallback)',
|
|
summary: 'Fallback governance offer loaded because catalog fetch failed.',
|
|
price: '499.00',
|
|
currency: 'USDC',
|
|
member_only: true,
|
|
workspace_bound: false,
|
|
transferable: false,
|
|
}];
|
|
state.selectedOfferId = state.offers[0].offer_id;
|
|
populateOfferSelect();
|
|
setCheckoutLog('Catalog load failed: ' + err.message + '. Using fallback offer.');
|
|
}
|
|
}
|
|
|
|
async function fetchLiveMembershipStatus() {
|
|
if (!state.wallet) {
|
|
throw new Error('Connect wallet first.');
|
|
}
|
|
|
|
const walletUrl = '/secret/membership/status?wallet=' + encodeURIComponent(state.wallet);
|
|
try {
|
|
const payload = await fetchJson(walletUrl);
|
|
return normalizeMembership(payload.status || payload.membership_status);
|
|
} catch (walletErr) {
|
|
const designationCode = getStoredDesignationCode();
|
|
if (!designationCode) {
|
|
throw walletErr;
|
|
}
|
|
const codeUrl = '/secret/membership/status?designation_code=' + encodeURIComponent(designationCode);
|
|
const payload = await fetchJson(codeUrl);
|
|
return normalizeMembership(payload.status || payload.membership_status);
|
|
}
|
|
}
|
|
|
|
async function refreshMembershipState() {
|
|
const mock = normalizeMembership(mockSelect.value || getMockFromQuery());
|
|
|
|
if (mock !== 'unknown' && mock !== '') {
|
|
state.source = 'mock';
|
|
state.membership = mock;
|
|
applyGateState();
|
|
setLog('Mock mode active: ' + mock + '.');
|
|
return;
|
|
}
|
|
|
|
state.source = 'live';
|
|
setLog('Checking live membership status...');
|
|
|
|
try {
|
|
const membership = await fetchLiveMembershipStatus();
|
|
state.membership = membership;
|
|
applyGateState();
|
|
setLog('Live status resolved: ' + membership + '.');
|
|
} catch (err) {
|
|
state.membership = 'unknown';
|
|
applyGateState();
|
|
setLog('Live status check failed: ' + err.message + '. Purchase remains blocked.');
|
|
}
|
|
}
|
|
|
|
async function connectWallet() {
|
|
if (!window.ethereum) {
|
|
setLog('No wallet provider detected on this device.');
|
|
return;
|
|
}
|
|
try {
|
|
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
throw new Error('Wallet connection not approved.');
|
|
}
|
|
state.wallet = accounts[0];
|
|
setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.');
|
|
await refreshMembershipState();
|
|
} catch (err) {
|
|
setLog('Wallet connection failed: ' + err.message + '.');
|
|
}
|
|
}
|
|
|
|
async function requestCheckoutQuote() {
|
|
if (!state.gate) {
|
|
setCheckoutLog('Checkout blocked: membership is not active.');
|
|
return;
|
|
}
|
|
|
|
if (!state.wallet) {
|
|
setCheckoutLog('Checkout blocked: wallet not connected.');
|
|
return;
|
|
}
|
|
|
|
setCheckoutLog('Requesting quote...');
|
|
|
|
try {
|
|
const response = await fetch('/marketplace/checkout/quote', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
wallet: state.wallet,
|
|
offer_id: state.selectedOfferId,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('HTTP ' + response.status);
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const quoteId = payload.quote_id || 'unknown';
|
|
const amount = payload.amount || payload.amount_atomic || 'unknown';
|
|
const currency = payload.currency || 'unknown';
|
|
setCheckoutLog('Quote ready for ' + state.selectedOfferId + ': ' + quoteId + ' (' + amount + ' ' + currency + ').');
|
|
} catch (err) {
|
|
setCheckoutLog('Quote request failed: ' + err.message + '. API wiring pending.');
|
|
}
|
|
}
|
|
|
|
connectBtn.addEventListener('click', connectWallet);
|
|
refreshBtn.addEventListener('click', refreshMembershipState);
|
|
checkoutBtn.addEventListener('click', requestCheckoutQuote);
|
|
mockSelect.addEventListener('change', refreshMembershipState);
|
|
offerSelect.addEventListener('change', function () {
|
|
state.selectedOfferId = offerSelect.value;
|
|
renderSelectedOffer();
|
|
applyGateState();
|
|
});
|
|
|
|
applyGateState();
|
|
loadOffers();
|
|
const initialMock = getMockFromQuery();
|
|
if (initialMock !== 'unknown') {
|
|
mockSelect.value = initialMock;
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|