Files
shopify-ai-backup/chat/public/apps.html

2498 lines
81 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Plugins - Plugin Compass</title>
<link rel="icon" type="image/png" href="/assets/Plugin.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=Inter:wght@400;600&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="/chat/styles.css">
<style>
:root {
--shopify-green: #008060;
--shopify-green-dark: #004c3f;
--shopify-green-light: #e3f5ef;
--accent: #5A31F4;
--accent-2: #8B5CF6;
}
body {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.apps-container {
max-width: 1400px;
margin: 0 auto;
padding: 40px 24px;
}
.apps-header {
margin-bottom: 48px;
}
.apps-header h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: 36px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 12px 0;
}
.apps-header p {
color: #6c757d;
font-size: 16px;
margin: 0;
}
.apps-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
flex-wrap: wrap;
gap: 16px;
}
.action-buttons {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.upload-app-btn {
background: #0f172a;
color: #fff;
border: none;
border-radius: 12px;
padding: 12px 16px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease;
}
.upload-app-btn:hover:not(.locked) {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.25);
}
.upload-app-btn.locked {
background: #f8fafc;
color: #9aa3ad;
border: 1px dashed #d0d7de;
box-shadow: none;
cursor: not-allowed;
}
.pill-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(0, 128, 96, 0.08);
color: var(--shopify-green);
padding: 6px 10px;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.01em;
border: 1px solid rgba(0, 128, 96, 0.14);
}
.plan-hint {
display: flex;
align-items: center;
gap: 10px;
color: #4b5563;
font-weight: 600;
font-size: 13px;
margin-top: -12px;
margin-bottom: 8px;
}
/* Header card to match builder page */
.apps-header {
background: #fff;
border: 1px solid var(--border, #e6e9ee);
border-radius: 16px;
padding: 18px 20px;
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.05);
}
.apps-header h1 {
margin: 0 0 6px 0;
font-size: 20px;
}
.apps-header p {
margin: 0;
color: var(--muted, #6b7280);
font-size: 13px;
}
/* Search box */
.search-box {
display: flex;
align-items: center;
background: #fff;
border: 1px solid var(--border, #e6e9ee);
border-radius: 12px;
padding: 8px 12px;
flex: 1;
max-width: 520px;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.search-box svg {
color: var(--muted, #9aa3ad);
flex: 0 0 auto;
margin-right: 8px;
}
.search-box input {
border: none;
outline: none;
background: transparent;
font-size: 14px;
width: 100%;
color: inherit;
}
.search-box input::placeholder {
color: var(--muted, #9aa3ad);
}
.search-box:focus-within {
border-color: rgba(0, 128, 96, 0.4);
box-shadow: 0 6px 20px rgba(0, 128, 96, 0.06);
}
/* Create button (primary) */
.create-app-btn {
background: linear-gradient(135deg, var(--shopify-green), var(--shopify-green-dark));
color: #fff;
border: none;
border-radius: 12px;
padding: 12px 18px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
display: inline-flex;
align-items: center;
gap: 10px;
box-shadow: 0 8px 24px rgba(0, 128, 96, 0.12);
}
.create-app-btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(0, 128, 96, 0.18);
}
/* Upload button - more prominent and conversion-focused */
.upload-app-btn {
background: linear-gradient(135deg, #10b981, #059669);
/* slightly brighter */
color: #fff;
border: none;
border-radius: 12px;
padding: 12px 18px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease;
display: inline-flex;
align-items: center;
gap: 10px;
box-shadow: 0 10px 28px rgba(16, 185, 129, 0.12);
}
.upload-app-btn:hover {
transform: translateY(-2px);
box-shadow: 0 18px 40px rgba(16, 185, 129, 0.18);
}
/* Subtle upgrade state: still clickable but visually hints that action will prompt upgrade */
.upload-app-btn.needs-upgrade {
opacity: 1;
/* keep prominent to encourage click */
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.08);
}
.upload-app-btn .upload-label {
display: inline-block;
}
/* Keep the small hint text but ensure it's subtle and readable */
#upload-hint {
color: #6b7280;
font-size: 13px;
margin-top: 6px;
}
/* App grid and cards */
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.app-action-btn.delete {
border-color: #f5c6cb;
color: #c92a2a;
background: #fff;
}
.app-badge.status-active {
background: var(--shopify-green-light);
color: var(--shopify-green);
padding: 6px 10px;
border-radius: 10px;
font-weight: 700;
font-size: 12px;
}
.app-date {
color: var(--muted, #6b7280);
font-size: 13px;
margin-top: 6px;
}
.app-card {
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 2px solid transparent;
transition: all 0.2s;
cursor: pointer;
position: relative;
overflow: hidden;
}
.app-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(135deg, var(--shopify-green), var(--shopify-green-dark));
transform: scaleX(0);
transition: transform 0.2s;
}
.app-card:hover {
border-color: var(--shopify-green);
box-shadow: 0 8px 24px rgba(0, 128, 96, 0.12);
transform: translateY(-4px);
}
.app-card:hover::before {
transform: scaleX(1);
}
.app-icon {
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(135deg, var(--shopify-green-light), #d4f1e8);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 16px;
color: var(--shopify-green);
}
.app-card h3 {
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 8px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.app-meta {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.app-badge {
background: #f8f9fa;
color: #6c757d;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.app-badge.model {
background: linear-gradient(135deg, rgba(90, 49, 244, 0.1), rgba(139, 92, 246, 0.1));
color: var(--accent);
}
.app-badge.upload {
background: rgba(15, 23, 42, 0.08);
color: #0f172a;
font-weight: 700;
}
.app-badge.status-active {
background: var(--shopify-green-light);
color: var(--shopify-green);
}
.app-date {
color: #adb5bd;
font-size: 13px;
margin-top: auto;
}
.app-actions {
display: flex;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f1f3f5;
}
.app-action-btn {
padding: 8px 12px;
border: 1px solid #dee2e6;
background: white;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
color: #6c757d;
font-weight: 500;
}
.app-action-btn:hover {
border-color: var(--shopify-green);
color: var(--shopify-green);
background: var(--shopify-green-light);
}
.app-action-btn.delete {
color: #dc3545;
border-color: #dc3545;
}
.app-action-btn.delete:hover {
background: #fff5f5;
}
.app-action-btn.primary {
background: linear-gradient(135deg, var(--shopify-green), var(--shopify-green-dark));
color: #fff;
border: none;
box-shadow: 0 8px 24px rgba(0, 128, 96, 0.08);
}
.app-action-btn.primary:hover {
transform: translateY(-2px);
box-shadow: 0 14px 36px rgba(0, 128, 96, 0.12);
}
.app-action-btn.outline {
background: transparent;
border: 1px solid #dee2e6;
color: #374151;
}
.app-action-btn.outline:hover {
background: var(--shopify-green-light);
color: var(--shopify-green);
border-color: rgba(0, 128, 96, 0.16);
}
.empty-state {
text-align: center;
padding: 80px 20px;
background: white;
border-radius: 16px;
border: 2px dashed #dee2e6;
}
.empty-state-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--shopify-green-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
margin: 0 auto 24px;
color: var(--shopify-green);
}
.empty-state h2 {
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 12px 0;
}
.empty-state p {
color: #6c757d;
margin: 0 0 24px 0;
font-size: 16px;
}
.nav-bar {
background: white;
border-bottom: 1px solid #e9ecef;
padding: 16px 24px;
margin-bottom: 32px;
}
.nav-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.brand-mark {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, var(--shopify-green), var(--shopify-green-dark));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 16px;
}
.brand-text {
font-family: 'Space Grotesk', sans-serif;
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
}
.nav-links {
display: flex;
gap: 24px;
align-items: center;
}
.user-chip {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #e9ecef;
border-radius: 12px;
text-decoration: none;
color: inherit;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.user-chip:hover {
border-color: var(--shopify-green);
background: #f8f9fa;
}
.user-menu-container {
position: relative;
display: inline-block;
}
.user-menu-popup {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: #fff;
border: 1px solid #e9ecef;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
z-index: 1000;
min-width: 180px;
display: none;
padding: 6px;
animation: menuFadeIn 0.2s ease-out;
}
@keyframes menuFadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.user-menu-popup.active {
display: block;
}
.user-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
text-decoration: none;
color: #495057;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
transition: all 0.2s;
}
.user-menu-item:hover {
background: #f8f9fa;
color: var(--shopify-green);
}
.user-menu-item svg {
color: #6c757d;
}
.user-menu-item:hover svg {
color: var(--shopify-green);
}
.user-chip-avatar {
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--shopify-green-light);
color: var(--shopify-green);
display: grid;
place-items: center;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
flex-shrink: 0;
}
.user-chip-status {
font-size: 11px;
color: #6c757d;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.user-chip-email {
font-size: 13px;
font-weight: 700;
color: #1a1a1a;
max-width: 160px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Mobile portrait: show only avatar to save space */
@media (max-width: 640px) and (orientation: portrait) {
.user-chip .user-chip-status,
.user-chip .user-chip-email {
display: none !important;
}
.user-chip {
padding: 6px 8px;
}
.user-chip-avatar {
width: 32px;
height: 32px;
}
}
.nav-link {
color: #6c757d;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.nav-link:hover {
color: var(--shopify-green);
}
.loading {
text-align: center;
padding: 60px 20px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #f1f3f5;
border-top-color: var(--shopify-green);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal-container {
background: white;
border-radius: 16px;
max-width: 480px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
transform: scale(0.95) translateY(20px);
transition: all 0.2s ease;
}
.modal-overlay.active .modal-container {
transform: scale(1) translateY(0);
}
.modal-header {
padding: 24px;
border-bottom: 1px solid #e9ecef;
}
.modal-header h2 {
font-family: 'Space Grotesk', sans-serif;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
margin: 0;
}
.modal-body {
padding: 24px;
}
.upload-dropzone {
border: 2px dashed #d0d7de;
border-radius: 12px;
padding: 14px 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: #f8fafc;
}
.upload-dropzone strong {
color: #0f172a;
}
.upload-dropzone small {
color: #6c757d;
display: block;
margin-top: 4px;
}
.modal-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #6c757d;
margin-bottom: 8px;
}
.modal-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #dee2e6;
border-radius: 12px;
font-size: 15px;
color: #1a1a1a;
transition: all 0.2s;
outline: none;
}
.modal-input:focus {
border-color: var(--shopify-green);
box-shadow: 0 0 0 3px var(--shopify-green-light);
}
.modal-input::placeholder {
color: #adb5bd;
}
.modal-footer {
padding: 24px;
border-top: 1px solid #e9ecef;
display: flex;
gap: 12px;
justify-content: flex-end;
}
.modal-btn {
padding: 12px 24px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.modal-btn-secondary {
background: #f8f9fa;
color: #6c757d;
border: 2px solid #dee2e6;
}
.modal-btn-secondary:hover {
background: #e9ecef;
border-color: #ced4da;
}
.modal-btn-primary {
background: linear-gradient(135deg, var(--shopify-green), var(--shopify-green-dark));
color: white;
box-shadow: 0 4px 12px rgba(0, 128, 96, 0.15);
}
.modal-btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 128, 96, 0.25);
}
.modal-btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Templates Modal Specific */
.templates-modal-container {
max-width: 1000px;
width: 95%;
}
.templates-grid-modal {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
max-height: 70vh;
overflow-y: auto;
padding: 10px;
}
.template-card-mini {
background: white;
border-radius: 12px;
overflow: hidden;
border: 1px solid #e9ecef;
transition: all 0.2s;
display: flex;
flex-direction: column;
}
.template-card-mini:hover {
border-color: var(--shopify-green);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transform: translateY(-2px);
}
.template-card-mini .card-image {
height: 140px;
background: #f1f3f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
}
.template-card-mini .card-body {
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
}
.template-card-mini .badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
background: var(--shopify-green-light);
color: var(--shopify-green);
margin-bottom: 8px;
align-self: flex-start;
}
.template-card-mini .card-title {
font-size: 16px;
font-weight: 700;
margin: 0 0 8px 0;
color: #1a1a1a;
}
.template-card-mini .card-desc {
font-size: 13px;
color: #6c757d;
margin-bottom: 16px;
line-height: 1.4;
flex: 1;
}
.template-card-mini .use-template-btn {
background: #1a1a1a;
color: white;
text-align: center;
padding: 10px;
border-radius: 8px;
text-decoration: none;
font-size: 13px;
font-weight: 600;
transition: background 0.2s;
border: none;
cursor: pointer;
}
.template-card-mini .use-template-btn:hover {
background: var(--shopify-green);
}
</style>
<!-- PostHog Analytics -->
<script src="/posthog.js"></script>
</head>
<body>
<nav class="nav-bar">
<div class="nav-content">
<a href="/" class="brand">
<img src="/assets/Plugin.png" alt="Plugin Compass" style="width: 32px; height: 32px; border-radius: 8px;">
<span class="brand-text">Plugin Compass</span>
</a>
<div class="nav-links">
<div class="user-menu-container">
<div class="user-chip" id="apps-user-chip" title="Account & settings">
<div class="user-chip-avatar" id="apps-user-avatar">?</div>
<div style="display:flex; flex-direction:column; gap:2px; min-width:0;">
<span class="user-chip-status">Signed in</span>
<span class="user-chip-email" id="apps-user-email">Checking account…</span>
</div>
</div>
<div class="user-menu-popup" id="user-menu-popup">
<a href="/settings" class="user-menu-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z">
</path>
</svg>
Settings
</a>
<a href="/feature-requests" class="user-menu-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
Feature Requests
</a>
<a href="/topup" class="user-menu-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"></path>
<path d="M12 18V6"></path>
</svg>
Tokens Top-up
</a>
<div style="margin: 4px 0; border-top: 1px solid #f1f3f5;"></div>
<a href="#" class="user-menu-item" id="logout-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Logout
</a>
</div>
</div>
</div>
</div>
</nav>
<div class="apps-container">
<div class="apps-header">
<h1>My Wordpress Plugins</h1>
<p>Build, manage, and deploy your Wordpress Plugins powered by AI</p>
</div>
<div class="apps-actions">
<div class="search-box">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" style="color:#adb5bd; margin-right:8px;">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="search-input" placeholder="Search apps..." />
</div>
<div class="action-buttons">
<button class="upload-app-btn" id="upload-app-btn" aria-label="Upload ZIP">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="16 16 12 12 8 16"></polyline>
<line x1="12" y1="12" x2="12" y2="21"></line>
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 4 16.3"></path>
</svg>
<span class="upload-label">Upload ZIP</span>
</button>
<button class="create-app-btn" id="create-new-app">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Create New App
</button>
<button id="browse-templates-btn" class="create-app-btn"
style="background: white; color: var(--shopify-green); border: 1px solid var(--shopify-green); margin-left: 10px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
Browse Templates
</button>
<a href="/upgrade?source=apps_page" class="create-app-btn upgrade-page-btn"
style="background: linear-gradient(135deg, #008060, #004c3f); color: white; border: none; margin-left: 10px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
<path d="M2 17l10 5 10-5"></path>
<path d="M2 12l10 5 10-5"></path>
</svg>
Upgrade Plan
</a>
</div>
</div>
<div id="apps-content">
<div class="loading">
<div class="spinner"></div>
<p>Loading your apps...</p>
</div>
</div>
</div>
<!-- Create App Modal -->
<div class="modal-overlay" id="create-app-modal">
<div class="modal-container">
<div class="modal-header">
<h2 style="display:flex; align-items:center; gap:10px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Create New App
</h2>
</div>
<div class="modal-body">
<label for="app-name-input" class="modal-label">App Name</label>
<input type="text" id="app-name-input" class="modal-input" placeholder="e.g., Product Discount Manager"
maxlength="100" />
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn-secondary" id="cancel-create-app">Cancel</button>
<button class="modal-btn modal-btn-primary" id="confirm-create-app">Create App</button>
</div>
</div>
</div>
<!-- Upload App Modal -->
<div class="modal-overlay" id="upload-app-modal">
<div class="modal-container">
<div class="modal-header">
<h2 style="display:flex; align-items:center; gap:10px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 16 12 12 8 16"></polyline>
<line x1="12" y1="12" x2="12" y2="21"></line>
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 4 16.3"></path>
</svg>
Upload Existing App (ZIP)
</h2>
</div>
<div class="modal-body" id="upload-body">
<label for="upload-app-name-input" class="modal-label">Plugin Name</label>
<input type="text" id="upload-app-name-input" class="modal-input" placeholder="e.g., My Custom Plugin"
maxlength="100" />
<div class="upload-dropzone" style="margin-top:12px;">
<div>
<strong>Select a ZIP file</strong>
<small>We will create a new app using your uploaded files.</small>
</div>
<label class="modal-btn modal-btn-primary" style="margin:0; cursor:pointer;">
Choose File
<input type="file" id="upload-input" accept=".zip" style="display:none;">
</label>
</div>
<div style="margin-top:12px; font-size:13px; color:#6b7280;" id="upload-hint">
Uploads available on Business and Enterprise plans. Max size 25MB.
</div>
<div style="margin-top:10px; display:none;" id="upload-selected"></div>
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn-secondary" id="cancel-upload-app">Cancel</button>
<button class="modal-btn modal-btn-primary" id="confirm-upload-app" disabled>Upload & Open</button>
</div>
</div>
</div>
<!-- App Notice Modal -->
<div class="modal-overlay" id="app-notice-modal">
<div class="modal-container">
<div class="modal-header">
<h2 id="app-notice-title"></h2>
</div>
<div class="modal-body" id="app-notice-body" style="color:#475569; font-size:14px;"></div>
<div class="modal-footer" id="app-notice-footer">
<button class="modal-btn modal-btn-secondary" id="app-notice-secondary">Close</button>
<button class="modal-btn modal-btn-primary" id="app-notice-primary">OK</button>
</div>
</div>
</div>
<!-- Onboarding Modal -->
<div id="onboarding-modal" class="onboarding-modal"
style="display:none; position:fixed; inset:0; background:transparent; z-index:10001; transition: all 0.3s ease;">
<div id="onboarding-container" class="onboarding-container"
style="background:#fff; border-radius:12px; width:280px; box-shadow:0 20px 50px rgba(0,0,0,0.2); position:fixed; overflow:visible; display:flex; flex-direction:column; transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);">
<!-- Tooltip Pointer -->
<div id="onboarding-pointer" style="position:absolute; width:16px; height:16px; background:#fff; transform:rotate(45deg); z-index:-1; display:none;"></div>
<button id="onboarding-exit"
style="position:absolute; top:8px; right:8px; border:none; background:rgba(255,255,255,0.8); color:#9ca3af; cursor:pointer; font-size:14px; width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; z-index:100; transition:all 0.2s ease;">
</button>
<div id="onboarding-content" class="onboarding-content" style="padding:16px 18px 12px;">
<div id="onboarding-step-1" class="onboarding-step">
<h2 style="font-size:15px; font-weight:700; color:#0f172a; margin-bottom:6px;">Welcome!</h2>
<p style="color:#64748b; font-size:13px; line-height:1.4; margin-bottom:0;">
This is your workspace. Here you can manage all your WordPress projects in one place.
</p>
</div>
<div id="onboarding-step-2" class="onboarding-step" style="display:none;">
<h2 style="font-size:15px; font-weight:700; color:#0f172a; margin-bottom:6px;">New Projects</h2>
<p style="color:#64748b; font-size:13px; line-height:1.4; margin-bottom:0;">
Start from scratch by describing your idea. Our AI will guide you through the planning phase.
</p>
</div>
<div id="onboarding-step-3" class="onboarding-step" style="display:none;">
<h2 style="font-size:15px; font-weight:700; color:#0f172a; margin-bottom:6px;">Upload Plugins</h2>
<p style="color:#64748b; font-size:13px; line-height:1.4; margin-bottom:0;">
Have an existing project or plugin? Upload them here to edit them with plugin compass.
</p>
</div>
<div id="onboarding-step-4" class="onboarding-step" style="display:none;">
<h2 style="font-size:15px; font-weight:700; color:#0f172a; margin-bottom:6px;">Templates</h2>
<p style="color:#64748b; font-size:13px; line-height:1.4; margin-bottom:0;">
Browse our library of pre-built templates for common WordPress needs to jumpstart development.
</p>
</div>
<div id="onboarding-step-5" class="onboarding-step" style="display:none;">
<h2 style="font-size:15px; font-weight:700; color:#0f172a; margin-bottom:6px;">Ready!</h2>
<p style="color:#64748b; font-size:13px; line-height:1.4; margin-bottom:0;">
You're all set to begin. Build your first custom plugin today and see the power of AI.
</p>
</div>
</div>
<div class="onboarding-footer" style="padding:0 18px 14px; display:flex; align-items:center; justify-content:space-between;">
<button id="onboarding-back"
style="padding:6px 0; font-size:13px; font-weight:600; border:none; background:transparent; color:#94a3b8; cursor:pointer; display:none;">
Back
</button>
<div id="onboarding-step-counter" style="font-size:12px; font-weight:600; color:#94a3b8; flex:1; text-align:center;">
1 of 5
</div>
<button id="onboarding-next"
style="padding:6px 12px; font-size:13px; font-weight:700; border-radius:8px; border:none; background:#008060; color:#fff; cursor:pointer; transition:all 0.2s ease;">
Next
</button>
</div>
</div>
</div>
<!-- Templates Modal -->
<div class="modal-overlay" id="templates-modal">
<div class="modal-container templates-modal-container">
<div class="modal-header">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h2 style="display:flex; align-items:center; gap:10px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
Choose a Template
</h2>
<button class="modal-btn modal-btn-secondary" id="close-templates-modal" style="padding: 8px;"></button>
</div>
</div>
<div class="modal-body">
<div id="templates-grid-modal" class="templates-grid-modal">
<div class="loading">
<div class="spinner"></div>
<p>Loading templates...</p>
</div>
</div>
</div>
</div>
</div>
<script>
const state = {
userId: null,
apps: [],
filteredApps: [],
plan: 'starter',
canUpload: false,
uploadFile: null,
uploadLimit: 25_000_000,
};
const MAX_UPLOAD_BYTES = 25_000_000; // Mirror server MAX_UPLOAD_ZIP_SIZE default (25MB)
// API function for making requests to the backend
async function api(endpoint, options = {}) {
const defaultOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
};
// Add user authentication header if available
if (state.userId) {
defaultOptions.headers['X-User-Id'] = state.userId;
}
const mergedOptions = {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers,
},
};
try {
const response = await fetch(endpoint, mergedOptions);
// Handle non-2xx responses
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
}
} catch (e) {
// If JSON parsing fails, use the status text
}
throw new Error(errorMessage);
}
// Try to parse JSON response
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
}
return await response.text();
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
const userChip = document.getElementById('apps-user-chip');
const userChipEmail = document.getElementById('apps-user-email');
const userChipAvatar = document.getElementById('apps-user-avatar');
const userMenuPopup = document.getElementById('user-menu-popup');
const logoutLink = document.getElementById('logout-link');
if (userChip && userMenuPopup) {
userChip.addEventListener('click', (e) => {
e.stopPropagation();
userMenuPopup.classList.toggle('active');
});
document.addEventListener('click', (e) => {
if (!userMenuPopup.contains(e.target) && !userChip.contains(e.target)) {
userMenuPopup.classList.remove('active');
}
});
}
if (logoutLink) {
logoutLink.addEventListener('click', async (e) => {
e.preventDefault();
try {
const response = await fetch('/api/logout', { method: 'POST' });
if (response.ok) {
localStorage.removeItem('shopify_ai_user');
localStorage.removeItem('wordpress_plugin_ai_user');
localStorage.removeItem('plugin_compass_onboarding_completed');
window.location.href = '/login';
}
} catch (err) {
console.error('Logout failed:', err);
window.location.href = '/login';
}
});
}
/** Deterministic hash used to derive a stable account id from email for client-side display (used by computeAccountId later in this script) */
function cyrb53(str, seed = 0) {
let h1 = 0xdeadbeef ^ seed;
let h2 = 0x41c6ce57 ^ seed;
// Constants derive from the original cyrb53 implementation to balance speed and distribution
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
function computeAccountId(email) {
const normalized = (email || '').trim().toLowerCase();
if (!normalized) return '';
const hash = cyrb53(normalized);
return `acct-${hash.toString(16)}`;
}
function resolveUserId() {
try {
const keys = ['shopify_ai_user', 'wordpress_plugin_ai_user'];
for (const key of keys) {
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed && parsed.email) {
const accountId = parsed.accountId || computeAccountId(parsed.email);
if (accountId && (!parsed.accountId || parsed.accountId !== accountId)) {
try { localStorage.setItem(key, JSON.stringify({ ...parsed, accountId })); } catch (_) { }
}
return accountId;
}
}
}
} catch (_) { }
return '';
}
const MOBILE_HEADER_MQ = window.matchMedia('(max-width: 640px) and (orientation: portrait)');
function updateUserChipForMobile(isMobile) {
if (!userChip) return;
const emailEl = userChipEmail;
const statusEl = userChip.querySelector('.user-chip-status');
if (emailEl) emailEl.setAttribute('aria-hidden', isMobile ? 'true' : 'false');
if (statusEl) statusEl.setAttribute('aria-hidden', isMobile ? 'true' : 'false');
if (isMobile) {
userChip.setAttribute('aria-label', 'Account');
document.body.classList.add('mobile-portrait');
} else {
const email = (userChipEmail && userChipEmail.textContent) || readLocalEmail();
userChip.setAttribute('aria-label', email ? `Signed in as ${email}` : 'Account settings');
document.body.classList.remove('mobile-portrait');
}
// Lightweight tracking: push to dataLayer if available and make a best-effort fire-and-forget POST
try {
if (window.dataLayer && typeof window.dataLayer.push === 'function') {
window.dataLayer.push({ event: 'mobilePortraitHeader', mobilePortrait: isMobile });
}
fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'mobilePortraitHeader', mobilePortrait: isMobile }),
keepalive: true,
}).catch(() => { });
} catch (_) { }
}
function setUserChipEmail(email) {
const safe = (email || '').trim();
if (userChipEmail) userChipEmail.textContent = safe || 'Guest';
if (userChipAvatar) userChipAvatar.textContent = safe ? safe.charAt(0).toUpperCase() : '?';
// apply appropriate aria/visibility based on current media state
try {
const isMobile = MOBILE_HEADER_MQ.matches;
updateUserChipForMobile(isMobile);
} catch (_) {
if (userChip) {
userChip.setAttribute('aria-label', safe ? `Signed in as ${safe}` : 'Account settings');
}
}
}
// Wire up media query changes
if (MOBILE_HEADER_MQ.addEventListener) {
MOBILE_HEADER_MQ.addEventListener('change', (e) => updateUserChipForMobile(e.matches));
} else if (MOBILE_HEADER_MQ.addListener) {
MOBILE_HEADER_MQ.addListener((e) => updateUserChipForMobile(e.matches));
}
// initialize
updateUserChipForMobile(MOBILE_HEADER_MQ.matches);
function syncUploadUi() {
const uploadBtn = document.getElementById('upload-app-btn');
const uploadHint = document.getElementById('upload-hint');
if (!uploadBtn) return;
const limitMb = Math.ceil((state.uploadLimit || MAX_UPLOAD_BYTES) / (1024 * 1024));
if (state.canUpload) {
uploadBtn.classList.remove('needs-upgrade');
uploadBtn.removeAttribute('aria-disabled');
if (uploadHint) uploadHint.textContent = `Upload a ZIP to create a new app instantly (max ${limitMb}MB).`;
} else {
// keep the CTA text the same (no "(paid)") so the button remains inviting,
// but add a class so we can style it subtly and show the in-app upgrade hint
uploadBtn.classList.add('needs-upgrade');
uploadBtn.setAttribute('aria-disabled', 'false'); // still clickable
if (uploadHint) uploadHint.textContent = `Upgrade to Business or Enterprise to upload existing apps (max ${limitMb}MB).`;
}
}
function readLocalEmail() {
const keys = ['shopify_ai_user', 'wordpress_plugin_ai_user'];
for (const key of keys) {
try {
const raw = localStorage.getItem(key);
if (!raw) continue;
const parsed = JSON.parse(raw);
if (parsed?.email) return parsed.email;
} catch (_) { /* ignore */ }
}
return '';
}
async function loadUserChip() {
let email = '';
try {
const resp = await fetch('/api/account', { credentials: 'same-origin' });
if (resp.ok) {
const data = await resp.json().catch(() => ({}));
email = data?.account?.email || '';
if (data?.account?.plan) {
state.plan = data.account.plan;
state.canUpload = ['business', 'enterprise'].includes(state.plan.toLowerCase());
// Hide upgrade button for enterprise users
if (state.plan.toLowerCase() === 'enterprise') {
const upgradeBtn = document.querySelector('.upgrade-page-btn');
if (upgradeBtn) {
upgradeBtn.style.display = 'none';
}
}
}
if (data?.account?.limits?.uploadZipBytes) {
state.uploadLimit = Number(data.account.limits.uploadZipBytes) || MAX_UPLOAD_BYTES;
} else {
state.uploadLimit = MAX_UPLOAD_BYTES;
}
}
} catch (_) { /* ignore */ }
if (!email) email = readLocalEmail();
setUserChipEmail(email);
}
state.userId = resolveUserId();
if (!state.userId) {
const next = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?next=${next}`;
}
try {
document.cookie = `chat_user=${encodeURIComponent(state.userId)}; path=/; SameSite=Lax`;
} catch (_) { }
loadUserChip().finally(syncUploadUi);
async function api(path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-User-Id': state.userId,
...(options.headers || {}),
};
const res = await fetch(path, { headers, ...options });
const text = await res.text();
const json = text ? JSON.parse(text) : {};
if (!res.ok) {
throw new Error(json.error || res.statusText);
}
return json;
}
function formatDate(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function getAppIcon(title) {
// Use consistent colors for initials
const colors = [
'linear-gradient(135deg, #FF6B6B 0%, #EE5253 100%)',
'linear-gradient(135deg, #4834d4 0%, #686de0 100%)',
'linear-gradient(135deg, #6ab04c 0%, #badc58 100%)',
'linear-gradient(135deg, #f0932b 0%, #ffbe76 100%)',
'linear-gradient(135deg, #22a6b3 0%, #7ed6df 100%)'
];
const char = (title || 'A').charAt(0).toUpperCase();
const hash = char.charCodeAt(0) % colors.length;
return `<div style="width:100%; height:100%; display:flex; align-items:center; justify-content:center; color:white; font-weight:700; font-size:24px; background:${colors[hash]}; border-radius:12px;">${char}</div>`;
}
function renderApps() {
const container = document.getElementById('apps-content');
if (state.filteredApps.length === 0 && state.apps.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg>
</div>
<h2>No apps yet</h2>
<p>Create your first Wordpress Plugin and let AI do the heavy lifting</p>
<button class="create-app-btn" onclick="createNewApp()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
Create Your First App
</button>
</div>
`;
return;
}
if (state.filteredApps.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</div>
<h2>No apps found</h2>
<p>Try adjusting your search criteria</p>
</div>
`;
return;
}
const grid = document.createElement('div');
grid.className = 'apps-grid';
state.filteredApps.forEach(app => {
const card = document.createElement('div');
card.className = 'app-card';
card.onclick = (e) => {
if (!e.target.classList.contains('app-action-btn')) {
openApp(app.id);
}
};
const status = app.pending && app.pending > 0 ? 'Building' : 'Ready';
const icon = getAppIcon(app.title);
const uploadBadge = app.source === 'upload';
const chatCountBadge = app.chatCount > 1 ? `<span class="app-badge">${app.chatCount} chats</span>` : '';
card.innerHTML = `
<div class="app-icon" style="padding:0; overflow:hidden;">${icon}</div>
<h3>${app.title || 'Untitled App'}</h3>
<div class="app-meta">
<span class="app-badge ${status === 'Ready' ? 'status-active' : ''}">${status}</span>
${uploadBadge ? '<span class="app-badge upload">Uploaded</span>' : ''}
${chatCountBadge}
</div>
<div class="app-date">Created ${formatDate(app.createdAt)}</div>
<div class="app-actions">
<button class="app-action-btn primary" onclick="event.stopPropagation(); openApp('${app.id}')">
Open
</button>
<button class="app-action-btn outline" onclick="event.stopPropagation(); exportApp('${app.id}')">
Export
</button>
<button class="app-action-btn delete" onclick="event.stopPropagation(); deleteApp('${app.id}')">
Delete
</button>
</div>
`;
grid.appendChild(card);
});
container.innerHTML = '';
container.appendChild(grid);
}
function filterApps(searchTerm) {
const term = searchTerm.toLowerCase().trim();
if (!term) {
state.filteredApps = [...state.apps];
} else {
state.filteredApps = state.apps.filter(app => {
const title = (app.title || '').toLowerCase();
const model = (app.model || '').toLowerCase();
return title.includes(term) || model.includes(term);
});
}
renderApps();
}
async function loadApps() {
try {
const data = await api('/api/sessions');
const sessions = data.sessions || [];
// Group sessions by appId to display apps (not individual chats)
const timestampCache = new Map();
let fallbackTimestamp = null;
sessions.forEach((session) => {
const value = session.updatedAt || session.createdAt || 0;
const ts = Date.parse(value);
if (!Number.isNaN(ts)) {
timestampCache.set(session.id, ts);
fallbackTimestamp = fallbackTimestamp === null ? ts : Math.max(fallbackTimestamp, ts);
}
});
if (fallbackTimestamp === null) {
fallbackTimestamp = Date.now();
}
sessions.forEach((session) => {
if (!timestampCache.has(session.id)) {
timestampCache.set(session.id, fallbackTimestamp);
}
});
const getSessionTimestamp = (session) => (
timestampCache.has(session.id) ? timestampCache.get(session.id) : fallbackTimestamp
);
const grouped = new Map();
sessions.forEach((session) => {
const key = session.appId || `single-${session.id}`;
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, session);
return;
}
if (getSessionTimestamp(session) > getSessionTimestamp(existing)) {
grouped.set(key, session);
}
});
state.apps = Array.from(grouped.values()).sort((a, b) => {
return getSessionTimestamp(b) - getSessionTimestamp(a);
});
// Enhance apps with chatCount, source, and pending aggregation
const appsMap = new Map();
for (const session of sessions) {
const appId = session.appId || session.id;
if (!appsMap.has(appId)) {
appsMap.set(appId, {
id: session.id,
appId: appId,
title: session.title || 'Untitled App',
createdAt: session.createdAt,
updatedAt: session.updatedAt,
model: session.model,
pending: session.pending || 0,
source: session.source,
chatCount: 1,
});
} else {
const app = appsMap.get(appId);
app.chatCount++;
app.pending += (session.pending || 0);
const existingDate = new Date(app.updatedAt || app.createdAt || 0);
const sessionDate = new Date(session.updatedAt || session.createdAt || 0);
if (sessionDate > existingDate) {
app.id = session.id;
app.updatedAt = session.updatedAt;
}
}
}
// Merge enhanced data into state.apps
state.apps = state.apps.map(app => {
const enhanced = appsMap.get(app.appId || app.id);
if (enhanced) {
return { ...app, chatCount: enhanced.chatCount, pending: enhanced.pending, source: enhanced.source || app.source };
}
return app;
});
state.filteredApps = [...state.apps];
renderApps();
} catch (error) {
document.getElementById('apps-content').innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<h2>Error loading apps</h2>
<p>${error.message}</p>
<button class="create-app-btn" onclick="loadApps()">
Retry
</button>
</div>
`;
}
}
function openCreateAppModal() {
const modal = document.getElementById('create-app-modal');
const input = document.getElementById('app-name-input');
input.value = '';
modal.classList.add('active');
input.focus();
}
function closeCreateAppModal() {
const modal = document.getElementById('create-app-modal');
modal.classList.remove('active');
state.pendingTemplateId = null;
}
function openUploadModal() {
if (!state.canUpload) {
showUpgradePrompt('Uploading existing apps is available on Business and Enterprise plans.');
return;
}
const modal = document.getElementById('upload-app-modal');
const status = document.getElementById('upload-selected');
const appNameInput = document.getElementById('upload-app-name-input');
state.uploadFile = null;
if (status) status.style.display = 'none';
if (appNameInput) appNameInput.value = '';
document.getElementById('confirm-upload-app').disabled = true;
modal.classList.add('active');
}
function closeUploadModal() {
const modal = document.getElementById('upload-app-modal');
const appNameInput = document.getElementById('upload-app-name-input');
modal.classList.remove('active');
state.uploadFile = null;
if (appNameInput) appNameInput.value = '';
}
function openTemplatesModal() {
const modal = document.getElementById('templates-modal');
modal.classList.add('active');
loadTemplatesModal();
}
function closeTemplatesModal() {
const modal = document.getElementById('templates-modal');
modal.classList.remove('active');
}
async function loadTemplatesModal() {
const grid = document.getElementById('templates-grid-modal');
try {
const res = await fetch('/api/templates');
const data = await res.json();
if (!data.templates || !data.templates.length) {
grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 40px;">No templates found.</div>';
return;
}
grid.innerHTML = data.templates.map(t => `
<div class="template-card-mini">
<div class="card-image">
${t.image && !t.image.includes('placeholder') ? `<img src="${t.image}" style="width:100%; height:100%; object-fit:cover;">` : '🧩'}
</div>
<div class="card-body">
<div class="badge">${t.category || 'General'}</div>
<h3 class="card-title">${t.name}</h3>
<p class="card-desc">${t.description}</p>
<button onclick="handleTemplateSelect('${t.id}'); closeTemplatesModal();" class="use-template-btn">Use Template</button>
</div>
</div>
`).join('');
} catch (err) {
grid.innerHTML = '<div style="color:red; text-align:center; padding: 40px;">Failed to load templates</div>';
}
}
// Generic in-app notice modal (replaces native alert/confirm)
function isRestrictedError(msg) {
if (!msg) return false;
const l = (String(msg) || '').toLowerCase();
return /\b(limit|upgrade|allowance|quota|paid|plan|business|enterprise)\b/.test(l);
}
function showAppNotice({ title = 'Notice', message = '', primaryText = 'OK', primaryHref, onPrimary } = {}) {
return new Promise((resolve) => {
const overlay = document.getElementById('app-notice-modal');
const tit = document.getElementById('app-notice-title');
const body = document.getElementById('app-notice-body');
const primary = document.getElementById('app-notice-primary');
const secondary = document.getElementById('app-notice-secondary');
tit.textContent = title;
body.innerHTML = message || '';
primary.textContent = primaryText || 'OK';
secondary.textContent = 'Close';
// cleanup handlers
primary.onclick = null;
secondary.onclick = null;
const close = (result) => {
overlay.classList.remove('active');
primary.onclick = null;
secondary.onclick = null;
overlay.onclick = null;
resolve(result);
};
secondary.onclick = () => close(false);
primary.onclick = () => {
if (primaryHref) {
window.location.href = primaryHref;
close(true);
return;
}
if (onPrimary) {
try { onPrimary(); } catch (_) { }
close(true);
return;
}
close(true);
};
overlay.onclick = (e) => { if (e.target === overlay) close(false); };
overlay.classList.add('active');
});
}
function showUpgradePrompt(message) {
if (state.plan && state.plan.toLowerCase() === 'enterprise') {
return showAppNotice({ title: 'Enterprise Plan', message: 'You are already on the Enterprise plan with full access.', primaryText: 'OK', primaryHref: null });
}
return showAppNotice({ title: 'Upgrade required', message, primaryText: 'Upgrade', primaryHref: '/upgrade' });
}
function showConfirmModal({ title = 'Confirm', message = '', confirmText = 'Confirm', cancelText = 'Cancel' } = {}) {
return new Promise((resolve) => {
const overlay = document.getElementById('app-notice-modal');
const tit = document.getElementById('app-notice-title');
const body = document.getElementById('app-notice-body');
const primary = document.getElementById('app-notice-primary');
const secondary = document.getElementById('app-notice-secondary');
tit.textContent = title;
body.innerHTML = message || '';
primary.textContent = confirmText || 'Confirm';
secondary.textContent = cancelText || 'Cancel';
primary.onclick = null;
secondary.onclick = null;
const cleanup = (result) => {
overlay.classList.remove('active');
primary.onclick = null;
secondary.onclick = null;
overlay.onclick = null;
resolve(result);
};
secondary.onclick = () => cleanup(false);
primary.onclick = () => cleanup(true);
overlay.onclick = (e) => { if (e.target === overlay) cleanup(false); };
overlay.classList.add('active');
});
}
function handleUploadSelect(file) {
const display = document.getElementById('upload-selected');
const confirmBtn = document.getElementById('confirm-upload-app');
const appNameInput = document.getElementById('upload-app-name-input');
state.uploadFile = file || null;
if (display) {
if (file) {
const sizeMb = (file.size / (1024 * 1024)).toFixed(2);
display.style.display = 'block';
display.textContent = `${file.name} (${sizeMb} MB)`;
} else {
display.style.display = 'none';
}
}
const appName = appNameInput ? (appNameInput.value || '').trim() : '';
if (confirmBtn) confirmBtn.disabled = !file || !appName;
}
function handleAppNameChange() {
const confirmBtn = document.getElementById('confirm-upload-app');
const appNameInput = document.getElementById('upload-app-name-input');
const appName = appNameInput ? (appNameInput.value || '').trim() : '';
if (confirmBtn) confirmBtn.disabled = !state.uploadFile || !appName;
}
async function uploadAppFromZip() {
if (!state.uploadFile) return;
if (!state.canUpload) {
showUpgradePrompt('Uploading existing apps is available on Business and Enterprise plans.');
return;
}
const file = state.uploadFile;
const maxSize = state.uploadLimit || MAX_UPLOAD_BYTES;
if (file.size > maxSize) {
const maxMb = Math.ceil(maxSize / (1024 * 1024));
await showAppNotice({ title: 'File too large', message: `File too large. Max ${maxMb}MB.` });
return;
}
const confirmBtn = document.getElementById('confirm-upload-app');
confirmBtn.disabled = true;
confirmBtn.textContent = 'Uploading...';
try {
const base64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const appNameInput = document.getElementById('upload-app-name-input');
const appName = (appNameInput?.value || '').trim() || 'Uploaded Plugin';
const payload = { data: base64, name: file.name, title: appName };
const res = await api('/api/apps/upload', { method: 'POST', body: JSON.stringify(payload) });
if (res?.session?.id) {
recordBuilderEntry(res.session.id);
closeUploadModal();
window.location.href = `/builder?session=${res.session.id}`;
} else {
throw new Error('Upload succeeded but no session returned');
}
} catch (err) {
if (isRestrictedError(err.message)) {
await showUpgradePrompt(err.message);
} else {
await showAppNotice({ title: 'Upload failed', message: 'Failed to upload: ' + err.message });
}
} finally {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Upload & Open';
}
}
async function createNewApp() {
openCreateAppModal();
}
function slugifyName(name) {
const base = (name || '').trim().toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
const suffix = Math.random().toString(36).slice(2, 6);
return `${base || 'app'}-${suffix}`;
}
const BUILDER_ENTRY_KEY = 'apps_to_builder';
function recordBuilderEntry(appId) {
try {
const payload = { appId: appId || '', ts: Date.now() };
sessionStorage.setItem(BUILDER_ENTRY_KEY, JSON.stringify(payload));
} catch (_) { /* ignore */ }
}
async function confirmCreateApp() {
const input = document.getElementById('app-name-input');
const trimmed = (input.value || '').trim();
if (!trimmed) {
input.style.borderColor = '#dc3545';
input.focus();
return;
}
input.style.borderColor = '#dee2e6';
const appId = slugifyName(trimmed);
try {
const payload = { title: trimmed, appId };
if (state.pendingTemplateId) {
payload.templateId = state.pendingTemplateId;
}
const data = await api('/api/sessions', {
method: 'POST',
body: JSON.stringify(payload),
});
if (data.session && data.session.id) {
recordBuilderEntry(data.session.id);
closeCreateAppModal();
window.location.href = `/builder?session=${data.session.id}`;
}
} catch (error) {
if (isRestrictedError(error.message)) {
await showUpgradePrompt(error.message);
} else {
await showAppNotice({ title: 'Failed to create app', message: String(error.message || 'Unknown error') });
}
}
}
function openApp(appId) {
recordBuilderEntry(appId);
window.location.href = `/builder?session=${appId}`;
}
async function exportApp(appId) {
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = 'Exporting...';
btn.disabled = true;
try {
const exportUrl = `/api/export/zip?sessionId=${encodeURIComponent(appId)}`;
const response = await fetch(exportUrl, { headers: { 'X-User-Id': state.userId } });
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Export failed');
}
// Get filename from Content-Disposition header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'shopify-app.zip';
if (contentDisposition) {
const match = contentDisposition.match(/filename="([^"]+)"/);
if (match) filename = match[1];
}
// Download the file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
btn.textContent = '✓ Done';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 2000);
} catch (error) {
if (isRestrictedError(error.message)) {
await showUpgradePrompt(error.message);
} else {
await showAppNotice({ title: 'Export failed', message: String(error.message || 'Unknown error') });
}
btn.textContent = originalText;
btn.disabled = false;
}
}
async function deleteApp(appId) {
const confirmed = await showConfirmModal({ title: 'Delete app', message: 'Are you sure you want to delete this app? This action cannot be undone.', confirmText: 'Delete', cancelText: 'Cancel' });
if (!confirmed) return;
try {
await api(`/api/sessions/${appId}`, { method: 'DELETE' });
state.apps = state.apps.filter(app => app.id !== appId);
state.filteredApps = state.filteredApps.filter(app => app.id !== appId);
renderApps();
} catch (error) {
if (isRestrictedError(error.message)) {
await showUpgradePrompt(error.message);
} else {
await showAppNotice({ title: 'Failed to delete app', message: String(error.message || 'Unknown error') });
}
}
}
// Event listeners
document.getElementById('create-new-app').addEventListener('click', createNewApp);
document.getElementById('cancel-create-app').addEventListener('click', closeCreateAppModal);
document.getElementById('confirm-create-app').addEventListener('click', confirmCreateApp);
document.getElementById('search-input').addEventListener('input', (e) => {
filterApps(e.target.value);
});
const uploadBtn = document.getElementById('upload-app-btn');
const uploadModal = document.getElementById('upload-app-modal');
const uploadInput = document.getElementById('upload-input');
const cancelUploadBtn = document.getElementById('cancel-upload-app');
const confirmUploadBtn = document.getElementById('confirm-upload-app');
if (uploadBtn) uploadBtn.addEventListener('click', openUploadModal);
if (cancelUploadBtn) cancelUploadBtn.addEventListener('click', closeUploadModal);
if (confirmUploadBtn) confirmUploadBtn.addEventListener('click', uploadAppFromZip);
const browseTemplatesBtn = document.getElementById('browse-templates-btn');
const closeTemplatesBtn = document.getElementById('close-templates-modal');
if (browseTemplatesBtn) browseTemplatesBtn.addEventListener('click', openTemplatesModal);
if (closeTemplatesBtn) closeTemplatesBtn.addEventListener('click', closeTemplatesModal);
document.getElementById('templates-modal').addEventListener('click', (e) => {
if (e.target.id === 'templates-modal') closeTemplatesModal();
});
if (uploadModal) {
uploadModal.addEventListener('click', (e) => {
if (e.target.id === 'upload-app-modal') closeUploadModal();
});
}
if (uploadInput) {
uploadInput.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
handleUploadSelect(file);
});
}
const uploadAppNameInput = document.getElementById('upload-app-name-input');
if (uploadAppNameInput) {
uploadAppNameInput.addEventListener('input', handleAppNameChange);
uploadAppNameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
uploadAppFromZip();
} else if (e.key === 'Escape') {
closeUploadModal();
}
});
}
// Handle Enter key in modal input
document.getElementById('app-name-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
confirmCreateApp();
} else if (e.key === 'Escape') {
closeCreateAppModal();
}
});
// Close modal when clicking outside
document.getElementById('create-app-modal').addEventListener('click', (e) => {
if (e.target.id === 'create-app-modal') {
closeCreateAppModal();
}
});
// Load apps on page load
if (state.userId) {
loadApps().then(() => {
// Check for template param
const params = new URLSearchParams(window.location.search);
const templateId = params.get('template');
if (templateId) {
handleTemplateSelect(templateId);
}
});
}
async function handleTemplateSelect(templateId) {
// Check plan
const plan = state.plan ? state.plan.toLowerCase() : 'starter';
const isPaid = ['business', 'enterprise', 'professional'].includes(plan);
if (!isPaid) {
// Show upgrade notice
showAppNotice({
title: 'Upgrade Required',
message: `Using pre-built templates is a feature available on Professional plans and above. <br><br>
Please upgrade your plan to access our library of high-quality templates.`,
onPrimary: () => { window.location.href = '/upgrade'; },
primaryText: 'View Pricing'
});
// clear param
window.history.replaceState({}, '', '/apps');
return;
}
// Proceed to create app from template
// We can reuse the create app modal but pre-fill or just directly create if we have a name?
// Let's ask for a name first using a modified create flow.
const templateName = templateId.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
document.getElementById('app-name-input').value = `My ${templateName}`;
// Open modal and store templateId in a accessible way for the confirm handler
state.pendingTemplateId = templateId;
openCreateAppModal();
}
</script>
<script>
// Onboarding Modal functionality for apps.html
(function() {
const ONBOARDING_COMPLETED_KEY = 'plugin_compass_onboarding_completed';
let currentStep = 1;
const totalSteps = 5;
let onboardingStatus = null;
async function isOnboardingCompleted() {
if (onboardingStatus !== null) {
return onboardingStatus;
}
try {
const res = await fetch('/api/onboarding');
if (res.ok) {
const data = await res.json();
onboardingStatus = data.completed;
return onboardingStatus;
}
} catch (e) {
}
return false;
}
async function markOnboardingCompleted() {
try {
const res = await fetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: true })
});
if (res.ok) {
onboardingStatus = true;
}
} catch (e) {
console.warn('Failed to save onboarding completion status');
}
}
const tourSteps = [
{ target: null, color: '#008060' },
{ target: '#create-new-app', color: '#5A31F4' },
{ target: '#upload-app-btn', color: '#F59E0B' },
{ target: '#browse-templates-btn', color: '#10B981' },
{ target: null, color: '#EC4899' }
];
function showStep(step) {
const totalSteps = tourSteps.length;
const config = tourSteps[step - 1];
// Update content visibility
for (let i = 1; i <= totalSteps; i++) {
const stepEl = document.getElementById('onboarding-step-' + i);
if (stepEl) stepEl.style.display = i === step ? 'block' : 'none';
}
// Update counter
const counter = document.getElementById('onboarding-step-counter');
if (counter) counter.textContent = `${step} of ${totalSteps}`;
const backBtn = document.getElementById('onboarding-back');
const nextBtn = document.getElementById('onboarding-next');
if (backBtn) backBtn.style.display = step === 1 ? 'none' : 'block';
if (nextBtn) {
nextBtn.textContent = step === totalSteps ? 'Get Started' : 'Next';
nextBtn.style.background = step === totalSteps ? '#10B981' : '#008060';
}
// Position the box
positionTourBox(config.target);
}
function positionTourBox(targetSelector) {
const container = document.getElementById('onboarding-container');
const pointer = document.getElementById('onboarding-pointer');
const modal = document.getElementById('onboarding-modal');
if (!container) return;
const isMobile = window.innerWidth < 480;
const boxWidth = isMobile ? Math.min(280, window.innerWidth - 32) : 280;
if (!targetSelector) {
// Center position
container.style.top = '50%';
container.style.left = '50%';
container.style.transform = 'translate(-50%, -50%)';
if (pointer) pointer.style.display = 'none';
modal.style.background = 'transparent';
return;
}
const target = document.querySelector(targetSelector);
if (!target) {
// Fallback to center if target missing
positionTourBox(null);
return;
}
modal.style.background = 'transparent'; // Lighter backdrop for tour
const rect = target.getBoundingClientRect();
// Default: Position above the element
let top = rect.top - container.offsetHeight - 16;
let left = rect.left + (rect.width / 2) - (boxWidth / 2);
// Bounds checking
if (top < 16) {
// Position below instead
top = rect.bottom + 16;
if (pointer) {
pointer.style.display = 'block';
pointer.style.top = '-8px';
pointer.style.bottom = 'auto';
pointer.style.left = '50%';
pointer.style.marginLeft = '-8px';
}
} else {
if (pointer) {
pointer.style.display = 'block';
pointer.style.bottom = '-8px';
pointer.style.top = 'auto';
pointer.style.left = '50%';
pointer.style.marginLeft = '-8px';
}
}
// Keep horizontal within viewport
const padding = 16;
left = Math.max(padding, Math.min(left, window.innerWidth - boxWidth - padding));
container.style.top = top + 'px';
container.style.left = left + 'px';
container.style.transform = 'none';
}
function nextStep() {
if (currentStep < tourSteps.length) {
currentStep++;
showStep(currentStep);
} else {
completeOnboarding();
}
}
function prevStep() {
if (currentStep > 1) {
currentStep--;
showStep(currentStep);
}
}
function goToStep(step) {
if (step >= 1 && step <= tourSteps.length) {
currentStep = step;
showStep(currentStep);
}
}
function completeOnboarding() {
markOnboardingCompleted();
hideOnboarding();
if (typeof posthog !== 'undefined') {
posthog.capture('onboarding_completed');
}
}
function hideOnboarding() {
const modal = document.getElementById('onboarding-modal');
if (modal) {
modal.style.display = 'none';
}
}
function showOnboarding() {
const modal = document.getElementById('onboarding-modal');
if (modal) {
currentStep = 1;
modal.style.display = 'block'; // Changed to allow absolute children
showStep(currentStep);
}
}
window.showOnboarding = showOnboarding;
window.hideOnboarding = hideOnboarding;
function initOnboarding() {
const exitBtn = document.getElementById('onboarding-exit');
const backBtn = document.getElementById('onboarding-back');
const nextBtn = document.getElementById('onboarding-next');
const modal = document.getElementById('onboarding-modal');
if (exitBtn) {
exitBtn.addEventListener('click', completeOnboarding);
}
if (backBtn) {
backBtn.addEventListener('click', prevStep);
}
if (nextBtn) {
nextBtn.addEventListener('click', nextStep);
}
if (modal) {
modal.addEventListener('click', function(e) {
if (e.target === modal) {
completeOnboarding();
}
});
}
window.addEventListener('resize', () => {
if (modal.style.display !== 'none') {
const config = tourSteps[currentStep - 1];
positionTourBox(config.target);
}
});
if (typeof document !== 'undefined') {
document.addEventListener('keydown', function(e) {
const onboardingModal = document.getElementById('onboarding-modal');
if (!onboardingModal || onboardingModal.style.display === 'none') return;
if (e.key === 'Escape') {
completeOnboarding();
} else if (e.key === 'ArrowRight') {
nextStep();
} else if (e.key === 'ArrowLeft') {
prevStep();
}
});
}
}
document.addEventListener('DOMContentLoaded', async function() {
initOnboarding();
if (!(await isOnboardingCompleted())) {
setTimeout(showOnboarding, 600);
}
});
})();
</script>
<style>
/* Onboarding Modal Styles */
#onboarding-exit:hover {
background: #f3f4f6;
color: #374151;
}
.onboarding-btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(0, 128, 96, 0.3);
}
.onboarding-btn-primary:active {
transform: translateY(0);
}
.onboarding-btn-secondary:hover {
background: #f9fafb;
border-color: #d1d5db;
}
.onboarding-dot.active {
background: #008060;
transform: scale(1.2);
}
.onboarding-dot:hover {
background: #008060;
opacity: 0.7;
}
</style>
<footer
style="margin-top: 80px; padding: 40px 24px; border-top: 1px solid #e1e3e5; text-align: center; color: #6d7175; font-size: 0.875rem;">
<p>&copy; 2026 Plugin Compass. All rights reserved.</p>
<div style="margin-top: 12px; display: flex; justify-content: center; gap: 16px;">
<a href="/terms" style="color: #008060; text-decoration: none;">Terms</a>
<a href="/privacy" style="color: #008060; text-decoration: none;">Privacy</a>
<a href="/contact" style="color: #008060; text-decoration: none;">Contact Us</a>
</div>
</footer>
</body>
</html>