852 lines
28 KiB
HTML
852 lines
28 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 ID-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;
|
|
}
|
|
.term-inline {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.inline-help {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
.inline-help > summary {
|
|
list-style: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 1px solid #c2c8d0;
|
|
border-radius: 50%;
|
|
text-align: center;
|
|
line-height: 14px;
|
|
font-size: 10px;
|
|
color: #5a616a;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
background: #fff;
|
|
}
|
|
.inline-help > summary::-webkit-details-marker { display: none; }
|
|
.inline-help > p {
|
|
position: absolute;
|
|
top: 20px;
|
|
left: 0;
|
|
width: min(280px, 70vw);
|
|
border: 1px solid #d0d5db;
|
|
background: #fff;
|
|
padding: 8px;
|
|
font-size: 11px;
|
|
line-height: 1.5;
|
|
color: #4d545d;
|
|
z-index: 20;
|
|
}
|
|
.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">EDUT ID-gated checkout behavior (internal preview scaffold)</p>
|
|
|
|
<div class="grid">
|
|
<section class="card">
|
|
<p class="label"><span class="term-inline">Wallet + EDUT ID<details class="inline-help"><summary aria-label="What is EDUT ID?">?</summary><p>EDUT ID is your one-time identity credential required to buy and activate EDUT products.</p></details></span></p>
|
|
<p class="line">Wallet: <span class="mono" id="wallet-label">not connected</span></p>
|
|
<p class="line"><span class="term-inline">EDUT ID status<details class="inline-help"><summary aria-label="What does EDUT ID status mean?">?</summary><p>Active means this wallet can check out and receive entitlements. Any other status blocks purchase.</p></details></span>: <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"><span class="term-inline">Ownership proof<details class="inline-help"><summary aria-label="What is ownership proof?">?</summary><p>If payer wallet differs from ownership wallet, a signature proves the owner approved that payer.</p></details></span>: <span class="mono" id="proof-label">not required</span></p>
|
|
<span class="state block" id="gate-pill">EDUT ID 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: EDUT ID 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 EDUT ID is active.</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<p class="label">Fail-Closed States</p>
|
|
<p class="line">No EDUT ID: 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 EDUT ID 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: '',
|
|
sessionExpiresAt: '',
|
|
sessionWallet: null,
|
|
sessionRefreshInFlight: 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: EDUT-ID-required=' + 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 = 'EDUT ID 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.session_expires_at === 'string') {
|
|
state.sessionExpiresAt = parsed.session_expires_at.trim();
|
|
}
|
|
if (typeof parsed.wallet === 'string') {
|
|
state.sessionWallet = parsed.wallet.trim();
|
|
}
|
|
} catch (err) {
|
|
// ignore malformed local cache
|
|
}
|
|
}
|
|
|
|
function clearSession(reason) {
|
|
state.sessionToken = '';
|
|
state.sessionExpiresAt = '';
|
|
state.sessionWallet = null;
|
|
if (reason) {
|
|
setLog('Wallet session cleared: ' + reason + '.');
|
|
}
|
|
}
|
|
|
|
function captureSessionHeaders(response) {
|
|
const token = String(response.headers.get('x-edut-session') || '').trim();
|
|
if (!token) return;
|
|
state.sessionToken = token;
|
|
const expiresAt = String(response.headers.get('x-edut-session-expires-at') || '').trim();
|
|
if (expiresAt) {
|
|
state.sessionExpiresAt = expiresAt;
|
|
}
|
|
if (state.wallet) {
|
|
state.sessionWallet = state.wallet;
|
|
}
|
|
}
|
|
|
|
function parseApiErrorPayload(text, fallbackStatus) {
|
|
if (!text) {
|
|
return { error: 'HTTP ' + fallbackStatus, code: '' };
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (parsed && typeof parsed === 'object') {
|
|
return {
|
|
error: String(parsed.error || ('HTTP ' + fallbackStatus)),
|
|
code: String(parsed.code || ''),
|
|
};
|
|
}
|
|
} catch (err) {
|
|
// non-json response
|
|
}
|
|
return { error: text, code: '' };
|
|
}
|
|
|
|
function isTerminalSessionCode(code) {
|
|
const normalized = String(code || '').toLowerCase();
|
|
return normalized === 'wallet_session_invalid' ||
|
|
normalized === 'wallet_session_expired' ||
|
|
normalized === 'wallet_session_revoked' ||
|
|
normalized === 'wallet_session_mismatch';
|
|
}
|
|
|
|
async function maybeRefreshSession(pathHint) {
|
|
if (!state.sessionToken || !state.wallet || !state.sessionExpiresAt) {
|
|
return;
|
|
}
|
|
if (String(pathHint || '').indexOf('/secret/wallet/session/') === 0) {
|
|
return;
|
|
}
|
|
const expiresAt = Date.parse(state.sessionExpiresAt);
|
|
if (!Number.isFinite(expiresAt)) {
|
|
return;
|
|
}
|
|
if ((expiresAt - Date.now()) > (5 * 60 * 1000)) {
|
|
return;
|
|
}
|
|
if (state.sessionRefreshInFlight) {
|
|
await state.sessionRefreshInFlight;
|
|
return;
|
|
}
|
|
state.sessionRefreshInFlight = (async function () {
|
|
const response = await fetch('/secret/wallet/session/refresh', {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ wallet: state.wallet }),
|
|
});
|
|
captureSessionHeaders(response);
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
const detail = parseApiErrorPayload(text, response.status);
|
|
if (isTerminalSessionCode(detail.code)) {
|
|
clearSession(detail.code);
|
|
}
|
|
throw new Error(detail.error);
|
|
}
|
|
let payload = {};
|
|
try {
|
|
payload = text ? JSON.parse(text) : {};
|
|
} catch (err) {
|
|
// ignore parse error for non-essential refresh payload
|
|
}
|
|
if (payload && typeof payload.session_token === 'string') {
|
|
state.sessionToken = payload.session_token.trim();
|
|
}
|
|
if (payload && typeof payload.session_expires_at === 'string') {
|
|
state.sessionExpiresAt = payload.session_expires_at.trim();
|
|
}
|
|
state.sessionWallet = state.wallet;
|
|
setLog('Wallet session refreshed.');
|
|
})().finally(function () {
|
|
state.sessionRefreshInFlight = null;
|
|
});
|
|
await state.sessionRefreshInFlight;
|
|
}
|
|
|
|
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) {
|
|
await maybeRefreshSession(url);
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: authHeaders(),
|
|
});
|
|
captureSessionHeaders(response);
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
const detail = parseApiErrorPayload(text, response.status);
|
|
if (isTerminalSessionCode(detail.code)) {
|
|
clearSession(detail.code);
|
|
}
|
|
throw new Error(detail.error);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function postJson(url, payload) {
|
|
await maybeRefreshSession(url);
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify(payload),
|
|
});
|
|
captureSessionHeaders(response);
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
const detail = parseApiErrorPayload(text, response.status);
|
|
if (isTerminalSessionCode(detail.code)) {
|
|
clearSession(detail.code);
|
|
}
|
|
throw new Error(detail.error);
|
|
}
|
|
return text ? JSON.parse(text) : {};
|
|
}
|
|
|
|
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/id/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/id/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 EDUT ID status...');
|
|
|
|
try {
|
|
const membership = await fetchLiveMembershipStatus();
|
|
state.membership = membership;
|
|
applyGateState();
|
|
setLog('Live EDUT ID 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()) {
|
|
clearSession('wallet changed');
|
|
}
|
|
state.sessionWallet = state.wallet;
|
|
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: EDUT ID 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 quotePayload = await postJson('/marketplace/checkout/quote', payload);
|
|
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>
|