merge: market standalone

This commit is contained in:
Padreug 2026-05-02 09:14:56 +02:00
commit c22b5de8bc
8 changed files with 392 additions and 1 deletions

19
market.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Market — Nostr</title>
<meta name="apple-mobile-web-app-title" content="Market">
<meta name="description" content="Decentralized marketplace on Nostr with Lightning payments">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/market-app/main.ts"></script>
</body>
</html>

View file

@ -21,6 +21,9 @@
"dev:chat": "vite --host --config vite.chat.config.ts",
"build:chat": "vue-tsc -b && vite build --config vite.chat.config.ts",
"preview:chat": "vite preview --host --config vite.chat.config.ts",
"dev:market": "vite --host --config vite.market.config.ts",
"build:market": "vue-tsc -b && vite build --config vite.market.config.ts",
"preview:market": "vite preview --host --config vite.market.config.ts",
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder",
"electron:package": "electron-builder",

47
src/market-app/App.vue Normal file
View file

@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { Button } from '@/components/ui/button'
import { LogIn } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
const isLoginPage = computed(() => route.path === '/login')
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<div class="min-h-screen bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
<LogIn class="w-4 h-4" />
</Button>
</div>
<main class="flex-1">
<router-view />
</main>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
</template>

View file

@ -0,0 +1,53 @@
import type { AppConfig } from '@/core/types'
/**
* Standalone Market app configuration.
* Only enables base + market modules.
*/
export const appConfig: AppConfig = {
modules: {
base: {
name: 'base',
enabled: true,
lazy: false,
config: {
nostr: {
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
},
auth: {
sessionTimeout: 24 * 60 * 60 * 1000,
},
pwa: {
autoPrompt: true
},
imageUpload: {
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
maxSizeMB: 10,
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
}
}
},
market: {
name: 'market',
enabled: true,
lazy: false,
config: {
defaultCurrency: 'sats',
paymentTimeout: 300000,
maxOrderHistory: 50,
apiConfig: {
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000'
}
}
},
},
features: {
pwa: true,
pushNotifications: true,
electronApp: false,
developmentMode: import.meta.env.DEV
}
}
export default appConfig

137
src/market-app/app.ts Normal file
View file

@ -0,0 +1,137 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'
import { pluginManager } from '@/core/plugin-manager'
import { eventBus } from '@/core/event-bus'
import { container } from '@/core/di-container'
import appConfig from './app.config'
import baseModule from '@/modules/base'
import marketModule from '@/modules/market'
import App from './App.vue'
import '@/assets/index.css'
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
function acceptTokenFromUrl() {
const params = new URLSearchParams(window.location.search)
const token = params.get('token')
if (token) {
localStorage.setItem('lnbits_access_token', token)
params.delete('token')
const clean = params.toString()
const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash
window.history.replaceState({}, '', newUrl)
console.log('[Market] Auth token accepted from URL')
}
}
export async function createAppInstance() {
console.log('Starting Market app...')
acceptTokenFromUrl()
const app = createApp(App)
const moduleRoutes = [
...baseModule.routes || [],
...marketModule.routes || [],
].filter(Boolean)
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/market'
},
{
path: '/login',
name: 'login',
component: import.meta.env.VITE_DEMO_MODE === 'true'
? () => import('@/pages/LoginDemo.vue')
: () => import('@/pages/Login.vue'),
meta: { requiresAuth: false }
},
...moduleRoutes,
]
})
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.use(i18n)
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
if (defaultLocale && !localStorage.getItem('user-locale')) {
await changeLocale(defaultLocale)
}
pluginManager.init(app, router)
const moduleRegistrations = []
if (appConfig.modules.base.enabled) {
moduleRegistrations.push(
pluginManager.register(baseModule, appConfig.modules.base)
)
}
if (appConfig.modules.market?.enabled) {
moduleRegistrations.push(
pluginManager.register(marketModule, appConfig.modules.market)
)
}
await Promise.all(moduleRegistrations)
await pluginManager.installAll()
const { auth } = await import('@/composables/useAuthService')
await auth.initialize()
router.beforeEach(async (to, _from, next) => {
const requiresAuth = to.meta.requiresAuth === true
if (requiresAuth && !auth.isAuthenticated.value) {
next('/login')
} else if (to.path === '/login' && auth.isAuthenticated.value) {
next('/')
} else {
next()
}
})
app.config.errorHandler = (err, _vm, info) => {
console.error('Global error:', err, info)
eventBus.emit('app:error', { error: err, info }, 'app')
}
if (appConfig.features.developmentMode) {
;(window as any).__pluginManager = pluginManager
;(window as any).__eventBus = eventBus
;(window as any).__container = container
}
console.log('Market app initialized')
return { app, router }
}
export async function startApp() {
try {
const { app } = await createAppInstance()
app.mount('#app')
console.log('Market app started!')
eventBus.emit('app:started', {}, 'app')
} catch (error) {
console.error('Failed to start Market app:', error)
document.getElementById('app')!.innerHTML = `
<div style="padding: 20px; text-align: center; color: red;">
<h1>Failed to Start</h1>
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
<p>Please refresh the page.</p>
</div>
`
}
}

17
src/market-app/main.ts Normal file
View file

@ -0,0 +1,17 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import 'vue-sonner/style.css'
const intervalMS = 60 * 60 * 1000
registerSW({
onRegistered(r) {
r && setInterval(() => {
r.update()
}, intervalMS)
},
onOfflineReady() {
console.log('Market app ready to work offline')
}
})
startApp()

View file

@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => ({
'**/*.{js,css,html,ico,png,svg}'
],
// Don't intercept standalone app paths — they have their own service workers
navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//, /^\/wallet\//, /^\/chat\//],
navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//, /^\/wallet\//, /^\/chat\//, /^\/market\//, /^\/cart\//, /^\/checkout\//],
},
includeAssets: [
'favicon.ico',

115
vite.market.config.ts Normal file
View file

@ -0,0 +1,115 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
function marketHtmlPlugin(): Plugin {
return {
name: 'market-html-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (
req.url &&
!req.url.startsWith('/@') &&
!req.url.startsWith('/src/') &&
!req.url.startsWith('/node_modules/') &&
!req.url.includes('.')
) {
req.url = '/market.html'
}
next()
})
},
}
}
/**
* Vite config for the standalone Market app.
*
* Set VITE_BASE_PATH to deploy under a path prefix:
* VITE_BASE_PATH=/market/ app.${domain}/market/ (shared auth)
* (default: /) market.${domain} (standalone subdomain)
*/
export default defineConfig(({ mode }) => ({
base: process.env.VITE_BASE_PATH || '/',
plugins: [
marketHtmlPlugin(),
vue(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
devOptions: { enabled: true },
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
navigateFallback: 'market.html',
navigateFallbackAllowlist: [
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
],
},
includeAssets: [
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Market — Nostr',
short_name: 'Market',
description: 'Decentralized marketplace on Nostr with Lightning payments',
theme_color: '#dc2626',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
scope: process.env.VITE_BASE_PATH || '/',
id: 'market-app',
categories: ['shopping', 'business'],
lang: 'en',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
ViteImageOptimizer({
jpg: { quality: 80 },
png: { quality: 80 },
webp: { lossless: true },
}),
mode === 'analyze' &&
visualizer({
open: true,
filename: 'dist-market/stats.html',
gzipSize: true,
brotliSize: true,
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@/app.config': fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)),
},
},
build: {
outDir: 'dist-market',
rollupOptions: {
input: 'market.html',
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['radix-vue', '@vueuse/core'],
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
},
},
},
chunkSizeWarningLimit: 1000,
},
}))