web/public/store/index.html

391 lines
12 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 Skeleton</p>
<p class="title">EDUT CRM Pro</p>
<p class="line">Price: 199.00 USDC</p>
<p class="line">Policy: member-only, workspace-bound, non-transferable</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.</p>
</div>
<script>
(function () {
'use strict';
const state = {
wallet: null,
membership: 'unknown',
gate: false,
source: 'live',
};
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');
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 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;
}
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 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: 'edut.crm.pro.annual',
}),
});
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: ' + 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);
applyGateState();
const initialMock = getMockFromQuery();
if (initialMock !== 'unknown') {
mockSelect.value = initialMock;
}
})();
</script>
</body>
</html>