web/public/store/index.html

692 lines
22 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">
<meta name="robots" content="noindex,nofollow,noarchive,nosnippet">
<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;
}
input {
border: 1px solid #c2c8d0;
background: #ffffff;
padding: 6px 8px;
font-family: 'IBM Plex Mono', 'Courier New', monospace;
font-size: 11px;
color: #3d434a;
width: 100%;
margin-top: 6px;
}
.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" id="preview-mode-note">Membership-gated checkout behavior (internal preview 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>
<p class="line">Payer wallet override (optional):</p>
<input id="payer-wallet-input" type="text" placeholder="0x... (leave blank to use ownership wallet)">
<p class="line">Ownership proof: <span class="mono" id="proof-label">not required</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>
<button id="sign-proof-btn" type="button">sign ownership proof</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 -> ownership proof (if needed) -> 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 internalPreview = new URLSearchParams(window.location.search).get('internal_preview') === '1';
const state = {
wallet: null,
ownershipProof: null,
membership: 'unknown',
gate: false,
source: 'live',
offers: [],
selectedOfferId: null,
sessionToken: '',
sessionWallet: 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 signProofBtn = document.getElementById('sign-proof-btn');
const checkoutBtn = document.getElementById('checkout-btn');
const mockSelect = document.getElementById('mock-select');
const payerWalletInput = document.getElementById('payer-wallet-input');
const proofLabel = document.getElementById('proof-label');
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');
const previewModeNote = document.getElementById('preview-mode-note');
function abbreviateWallet(wallet) {
if (!wallet || wallet.length < 10) return wallet || 'not connected';
return wallet.slice(0, 6) + '...' + wallet.slice(-4);
}
function normalizeWallet(value) {
return String(value || '').trim();
}
function isValidWallet(wallet) {
return /^0x[a-fA-F0-9]{40}$/.test(wallet);
}
function getPayerWallet() {
const override = normalizeWallet(payerWalletInput.value);
return override || state.wallet;
}
function updateProofLabel() {
const payer = getPayerWallet();
if (!state.wallet || !payer || payer.toLowerCase() === state.wallet.toLowerCase()) {
proofLabel.textContent = 'not required';
return;
}
if (state.ownershipProof && state.ownershipProof.payer_wallet &&
state.ownershipProof.payer_wallet.toLowerCase() === payer.toLowerCase()) {
proofLabel.textContent = 'signed';
return;
}
proofLabel.textContent = 'required';
}
function setLog(message) {
statusLog.textContent = message;
}
function setCheckoutLog(message) {
checkoutLog.textContent = message;
}
function disableInteractiveStore(reason) {
connectBtn.disabled = true;
refreshBtn.disabled = true;
signProofBtn.disabled = true;
checkoutBtn.disabled = true;
offerSelect.disabled = true;
mockSelect.disabled = true;
payerWalletInput.disabled = true;
offerTitle.textContent = 'Launcher-only catalog';
offerSummary.textContent = 'Catalog and checkout are available inside the EDUT launcher app.';
offerPrice.textContent = 'Price: hidden on public web';
offerPolicy.textContent = 'Policy: launcher access required';
setLog(reason);
setCheckoutLog('Public web checkout is disabled. Use EDUT launcher.');
gatePill.className = 'state warn';
gatePill.textContent = 'launcher only';
gateLabel.textContent = 'blocked';
membershipLabel.textContent = 'hidden';
proofLabel.textContent = 'launcher only';
if (previewModeNote) {
previewModeNote.textContent = 'Public web checkout disabled. Open EDUT launcher for catalog and purchase.';
}
}
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 || '');
let profile = '';
if (selected.execution_profile && typeof selected.execution_profile === 'object') {
const pace = selected.execution_profile.pacing_tier || 'unknown';
const surface = selected.execution_profile.connector_surface || 'unknown';
profile = ', pacing=' + pace + ', surface=' + surface;
}
offerPolicy.textContent = 'Policy: member-only=' + Boolean(selected.member_only) +
', workspace-bound=' + Boolean(selected.workspace_bound) +
', transferable=' + Boolean(selected.transferable) +
profile;
}
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;
updateProofLabel();
}
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;
}
}
function hydrateSessionFromAcknowledgement() {
const raw = localStorage.getItem('edut_ack_state');
if (!raw) return;
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
if (typeof parsed.session_token === 'string') {
state.sessionToken = parsed.session_token.trim();
}
if (typeof parsed.wallet === 'string') {
state.sessionWallet = parsed.wallet.trim();
}
} catch (err) {
// ignore malformed local cache
}
}
function authHeaders(extra) {
const headers = Object.assign({}, extra || {});
if (state.sessionToken) {
headers['X-Edut-Session'] = state.sessionToken;
headers.Authorization = 'Bearer ' + state.sessionToken;
}
return headers;
}
async function fetchJson(url) {
const response = await fetch(url, {
method: 'GET',
headers: authHeaders(),
});
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.workspace.core',
title: 'EDUT Workspace Core (fallback)',
summary: 'Fallback workspace core offer loaded because catalog fetch failed.',
price: '1000.00',
currency: 'USDC',
member_only: true,
workspace_bound: true,
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];
state.ownershipProof = null;
if (state.sessionWallet && state.wallet && state.sessionWallet.toLowerCase() !== state.wallet.toLowerCase()) {
state.sessionToken = '';
}
setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.');
await refreshMembershipState();
} catch (err) {
setLog('Wallet connection failed: ' + err.message + '.');
}
}
async function signOwnershipProof() {
if (!state.wallet) {
setLog('Connect ownership wallet first.');
return;
}
if (!window.ethereum) {
setLog('No wallet provider detected for signing.');
return;
}
const payer = getPayerWallet();
if (!payer) {
setLog('Payer wallet is empty.');
return;
}
if (!isValidWallet(payer)) {
setLog('Payer wallet format is invalid.');
return;
}
if (payer.toLowerCase() === state.wallet.toLowerCase()) {
state.ownershipProof = null;
updateProofLabel();
setLog('Ownership proof is not required when payer equals ownership wallet.');
return;
}
try {
const selectedOffer = state.selectedOfferId || 'none';
const issuedAt = new Date().toISOString();
const message = [
'EDUT OWNERSHIP PROOF',
'Ownership wallet: ' + state.wallet,
'Payer wallet: ' + payer,
'Offer: ' + selectedOffer,
'Issued at: ' + issuedAt,
].join('\\n');
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, state.wallet],
});
state.ownershipProof = {
payer_wallet: payer,
signature: signature,
message: message,
issued_at: issuedAt,
};
updateProofLabel();
setLog('Ownership proof signed for payer wallet override.');
} catch (err) {
setLog('Ownership proof signing 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;
}
const payerWallet = getPayerWallet();
if (!payerWallet || !isValidWallet(payerWallet)) {
setCheckoutLog('Checkout blocked: payer wallet is invalid.');
return;
}
const usingDistinctPayer = payerWallet.toLowerCase() !== state.wallet.toLowerCase();
if (usingDistinctPayer) {
if (!state.ownershipProof ||
!state.ownershipProof.signature ||
!state.ownershipProof.payer_wallet ||
state.ownershipProof.payer_wallet.toLowerCase() !== payerWallet.toLowerCase()) {
setCheckoutLog('Checkout blocked: sign ownership proof for the payer wallet override.');
return;
}
}
setCheckoutLog('Requesting quote...');
try {
const payload = {
wallet: state.wallet,
offer_id: state.selectedOfferId,
};
if (usingDistinctPayer) {
payload.payer_wallet = payerWallet;
payload.ownership_proof = state.ownershipProof.signature;
}
const response = await fetch('/marketplace/checkout/quote', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const quotePayload = await response.json();
const quoteId = quotePayload.quote_id || 'unknown';
const amount = quotePayload.amount || quotePayload.amount_atomic || 'unknown';
const total = quotePayload.total_amount || quotePayload.total_amount_atomic || amount;
const currency = quotePayload.currency || 'unknown';
const lines = Array.isArray(quotePayload.line_items) ? quotePayload.line_items : [];
let breakdown = '';
if (lines.length > 0) {
breakdown = '\\nLine items:\\n' + lines.map(function (item) {
const value = item.amount || item.amount_atomic || 'unknown';
const unit = item.currency || currency;
const label = item.label || item.kind || 'item';
return '- ' + label + ': ' + value + ' ' + unit;
}).join('\\n');
}
setCheckoutLog(
'Quote ready for ' + state.selectedOfferId + ': ' + quoteId +
' (license ' + amount + ' ' + currency + ', total ' + total + ' ' + currency + ').' +
breakdown
);
} catch (err) {
setCheckoutLog('Quote request failed: ' + err.message + '. API wiring pending.');
}
}
connectBtn.addEventListener('click', connectWallet);
refreshBtn.addEventListener('click', refreshMembershipState);
signProofBtn.addEventListener('click', signOwnershipProof);
checkoutBtn.addEventListener('click', requestCheckoutQuote);
mockSelect.addEventListener('change', refreshMembershipState);
payerWalletInput.addEventListener('input', function () {
state.ownershipProof = null;
updateProofLabel();
});
offerSelect.addEventListener('change', function () {
state.selectedOfferId = offerSelect.value;
state.ownershipProof = null;
renderSelectedOffer();
applyGateState();
});
applyGateState();
hydrateSessionFromAcknowledgement();
if (!internalPreview) {
disableInteractiveStore('Preview mode is disabled on public web.');
return;
}
loadOffers();
const initialMock = getMockFromQuery();
if (initialMock !== 'unknown') {
mockSelect.value = initialMock;
}
})();
</script>
</body>
</html>