diff --git a/.env.example b/.env.example index c92c023..1643766 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,63 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr # VITE_LIGHTNING_ENABLED=true # OBSOLETE: Not used in codebase - config.market.defaultCurrency is never consumed # VITE_MARKET_DEFAULT_CURRENCY=sat + +# ─────────────────────────────────────────────────────────────────────── +# Hub → standalone navigation URLs +# +# Each chakra tile in the hub builds an from these env vars and +# (when authenticated) appends ?token= so the destination +# auto-logs in via acceptTokenFromUrl(). +# +# Trailing slash matters under path-mode deployment: +# ✓ https://demo.example.com/market/ asset URLs resolve correctly +# ✗ https://demo.example.com/market relies on nginx 301 to add the +# slash; brittle, extra round trip. +# +# In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined +# in the vite configs): +# VITE_HUB_ACTIVITIES_URL=http://localhost:5181 +# VITE_HUB_CASTLE_URL=http://localhost:5180 +# VITE_HUB_WALLET_URL=http://localhost:5182 +# VITE_HUB_CHAT_URL=http://localhost:5183 +# VITE_HUB_FORUM_URL=http://localhost:5184 +# VITE_HUB_MARKET_URL=http://localhost:5185 +# VITE_HUB_TASKS_URL=http://localhost:5186 +# +# In PATH-MODE production (recommended for demo) — note the trailing slash: +# VITE_HUB_ACTIVITIES_URL=https://demo.example.com/activities/ +# VITE_HUB_CASTLE_URL=https://demo.example.com/castle/ +# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/ +# VITE_HUB_CHAT_URL=https://demo.example.com/chat/ +# VITE_HUB_FORUM_URL=https://demo.example.com/forum/ +# VITE_HUB_MARKET_URL=https://demo.example.com/market/ +# VITE_HUB_TASKS_URL=https://demo.example.com/tasks/ +# +# In SUBDOMAIN-MODE production: +# VITE_HUB_ACTIVITIES_URL=https://sortir.example.com +# VITE_HUB_CASTLE_URL=https://castle.example.com +# ...etc +# ─────────────────────────────────────────────────────────────────────── +VITE_HUB_ACTIVITIES_URL= +VITE_HUB_CASTLE_URL= +VITE_HUB_WALLET_URL= +VITE_HUB_CHAT_URL= +VITE_HUB_FORUM_URL= +VITE_HUB_MARKET_URL= +VITE_HUB_TASKS_URL= + +# ─────────────────────────────────────────────────────────────────────── +# VITE_BASE_PATH — build-time only, NOT per .env +# +# Each standalone vite config (vite..config.ts) reads VITE_BASE_PATH +# at build time. For path-mode deployment, set it as a shell variable when +# you build, NOT in this .env file (which is read at runtime by the +# bundle): +# +# VITE_BASE_PATH=/market/ npm run build:market +# VITE_BASE_PATH=/wallet/ npm run build:wallet +# ... +# +# The default '/' (no override) is what you want for subdomain-mode and +# for `npm run dev:all`. +# ─────────────────────────────────────────────────────────────────────── diff --git a/nginx.conf.example b/nginx.conf.example index 05cf1f6..662b4c9 100644 --- a/nginx.conf.example +++ b/nginx.conf.example @@ -11,115 +11,159 @@ http { real_ip_header X-Forwarded-For; real_ip_recursive on; - # Reusable location blocks - # JS / CSS / image MIME and caching - map $sent_http_content_type $cache_static { - default "off"; - ~image/ "6M"; - } - - # ─────────────────────────────────────────────────────────────── - # AIO hub — minimal app at app. - # Serves only the chakra icon hub + base infra (profile, relays). - # ─────────────────────────────────────────────────────────────── + # ─────────────────────────────────────────────────────────────────────── + # PATH-MODE deployment (recommended) + # + # demo../ — minimal AIO chakra hub + # demo../activities/ — Sortir / activities standalone + # demo../market/ — marketplace standalone + # demo../wallet/ — wallet standalone + # demo../chat/ — chat standalone + # demo../forum/ — forum standalone + # demo../tasks/ — tasks standalone + # demo../castle/ — castle (accounting) standalone + # + # Each standalone is built with VITE_BASE_PATH=// so its asset URLs + # are prefixed correctly. The hub's chakra tiles point at the canonical + # trailing-slash path (VITE_HUB__URL=https://demo.//). + # + # Per-app no-trailing-slash → with-slash 301 redirects exist for users + # who hand-type the URL or follow a stripped-slash link. + # + # All static assets (JS / CSS / images / SVGs) are MIME-typed and image + # types get a 6-month cache-control. + # ─────────────────────────────────────────────────────────────────────── server { listen 8080; - server_name app..; + server_name demo..; + # Hub at the root root /var/www/aio/dist; index index.html; + location = / { try_files $uri /index.html; } + location / { + # Default: serve from hub bundle if no // prefix matched. + try_files $uri $uri/ /index.html; + } - location / { try_files $uri $uri/ /index.html; } + # ── Activities (Sortir) ────────────────────────────────────────── + location = /activities { return 301 /activities/$is_args$args; } + location /activities/ { + alias /var/www/aio/dist-activities/; + try_files $uri $uri/ /activities.html; + } + + # ── Market ─────────────────────────────────────────────────────── + location = /market { return 301 /market/$is_args$args; } + location /market/ { + alias /var/www/aio/dist-market/; + try_files $uri $uri/ /market.html; + } + + # ── Wallet ─────────────────────────────────────────────────────── + location = /wallet { return 301 /wallet/$is_args$args; } + location /wallet/ { + alias /var/www/aio/dist-wallet/; + try_files $uri $uri/ /wallet.html; + } + + # ── Chat ───────────────────────────────────────────────────────── + location = /chat { return 301 /chat/$is_args$args; } + location /chat/ { + alias /var/www/aio/dist-chat/; + try_files $uri $uri/ /chat.html; + } + + # ── Forum ──────────────────────────────────────────────────────── + location = /forum { return 301 /forum/$is_args$args; } + location /forum/ { + alias /var/www/aio/dist-forum/; + try_files $uri $uri/ /forum.html; + } + + # ── Tasks ──────────────────────────────────────────────────────── + location = /tasks { return 301 /tasks/$is_args$args; } + location /tasks/ { + alias /var/www/aio/dist-tasks/; + try_files $uri $uri/ /tasks.html; + } + + # ── Castle (accounting) ────────────────────────────────────────── + location = /castle { return 301 /castle/$is_args$args; } + location /castle/ { + alias /var/www/aio/dist-castle/; + try_files $uri $uri/ /castle.html; + } + + # ── Static asset MIME / cache (applies to all bundles) ─────────── location ~* \.js$ { types { application/javascript js; } default_type application/javascript; } location ~* \.css$ { types { text/css css; } default_type text/css; } location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; } } - # ─────────────────────────────────────────────────────────────── - # Standalone module PWAs — one server block per subdomain - # ─────────────────────────────────────────────────────────────── - - # Marketplace — Muladhara + # ─────────────────────────────────────────────────────────────────────── + # Optional subdomain shortcuts → canonical path + # + # If you want pretty subdomain URLs that funnel into the path-mode + # canonical, add 301 redirects per app. Example: + # + # events.demo.. → demo../activities/ + # market.demo.. → demo../market/ + # ─────────────────────────────────────────────────────────────────────── server { listen 8080; - server_name market..; - root /var/www/aio/dist-market; - index market.html; - location / { try_files $uri $uri/ /market.html; } - location ~* \.js$ { types { application/javascript js; } default_type application/javascript; } - location ~* \.css$ { types { text/css css; } default_type text/css; } - location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; } + server_name events.demo..; + return 301 https://demo../activities/$request_uri; } - - # Activities — Swadhisthana server { listen 8080; - server_name sortir..; - root /var/www/aio/dist-activities; - index activities.html; - location / { try_files $uri $uri/ /activities.html; } - location ~* \.js$ { types { application/javascript js; } default_type application/javascript; } - location ~* \.css$ { types { text/css css; } default_type text/css; } - location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; } + server_name market.demo..; + return 301 https://demo../market/$request_uri; } - - # Wallet — Manipura server { listen 8080; - server_name wallet..; - root /var/www/aio/dist-wallet; - index wallet.html; - location / { try_files $uri $uri/ /wallet.html; } - location ~* \.js$ { types { application/javascript js; } default_type application/javascript; } - location ~* \.css$ { types { text/css css; } default_type text/css; } - location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; } + server_name wallet.demo..; + return 301 https://demo../wallet/$request_uri; } - - # Chat — Anahata server { listen 8080; - server_name chat..; - root /var/www/aio/dist-chat; - index chat.html; - location / { try_files $uri $uri/ /chat.html; } - location ~* \.js$ { types { application/javascript js; } default_type application/javascript; } - location ~* \.css$ { types { text/css css; } default_type text/css; } - location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; } + server_name chat.demo..; + return 301 https://demo../chat/$request_uri; } - - # Forum — Vishuddha server { listen 8080; - server_name forum..; - root /var/www/aio/dist-forum; - index forum.html; - location / { try_files $uri $uri/ /forum.html; } - location ~* \.js$ { types { application/javascript js; } default_type application/javascript; } - location ~* \.css$ { types { text/css css; } default_type text/css; } - location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; } + server_name forum.demo..; + return 301 https://demo../forum/$request_uri; } - - # Tasks — Ajna server { listen 8080; - server_name tasks..; - root /var/www/aio/dist-tasks; - index tasks.html; - location / { try_files $uri $uri/ /tasks.html; } - location ~* \.js$ { types { application/javascript js; } default_type application/javascript; } - location ~* \.css$ { types { text/css css; } default_type text/css; } - location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; } + server_name tasks.demo..; + return 301 https://demo../tasks/$request_uri; } - - # Castle — Sahasrara (accounting) server { listen 8080; - server_name castle..; - root /var/www/aio/dist-castle; - index castle.html; - location / { try_files $uri $uri/ /castle.html; } - location ~* \.js$ { types { application/javascript js; } default_type application/javascript; } - location ~* \.css$ { types { text/css css; } default_type text/css; } - location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; } + server_name castle.demo..; + return 301 https://demo../castle/$request_uri; } + + # ─────────────────────────────────────────────────────────────────────── + # SUBDOMAIN-MODE deployment (alternative — pure subdomains, no /path/) + # + # If you'd rather give each standalone its own subdomain and skip the + # path-mode entirely: + # + # server { server_name app.; root /var/www/aio/dist; ... } + # server { server_name market.; root /var/www/aio/dist-market; ... } + # server { server_name sortir.; root /var/www/aio/dist-activities; ... } + # server { server_name wallet.; root /var/www/aio/dist-wallet; ... } + # server { server_name chat.; root /var/www/aio/dist-chat; ... } + # server { server_name forum.; root /var/www/aio/dist-forum; ... } + # server { server_name tasks.; root /var/www/aio/dist-tasks; ... } + # server { server_name castle.; root /var/www/aio/dist-castle; ... } + # + # Each block uses `location / { try_files $uri $uri/ /.html; }`. + # In subdomain mode, build each standalone WITHOUT VITE_BASE_PATH (the + # default `/` is correct), and set VITE_HUB__URL to the subdomain + # in the hub's env (e.g. VITE_HUB_MARKET_URL=https://market.). + # ─────────────────────────────────────────────────────────────────────── } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9b77759..3e1a597 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -168,6 +168,15 @@ const messages: LocaleMessages = { notAvailable: 'Income submission is not yet available. This feature is coming soon.', }, }, + market: { + auth: { + loginPrompt: 'Log in to place your order', + logIn: 'Log in', + logInToCheckout: 'Log in to checkout', + nostrKeyRequired: 'A Nostr identity is required', + nostrKeyDescription: 'Configure your Nostr public key in Profile settings to place orders.', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 82f0816..53a7d40 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -168,6 +168,15 @@ const messages: LocaleMessages = { notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.', }, }, + market: { + auth: { + loginPrompt: 'Inicia sesi\u00f3n para realizar tu pedido', + logIn: 'Iniciar sesi\u00f3n', + logInToCheckout: 'Iniciar sesi\u00f3n para finalizar compra', + nostrKeyRequired: 'Se requiere una identidad Nostr', + nostrKeyDescription: 'Configura tu clave p\u00fablica Nostr en los ajustes del perfil para realizar pedidos.', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index b4b4c08..5f11684 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -168,6 +168,15 @@ const messages: LocaleMessages = { notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.', }, }, + market: { + auth: { + loginPrompt: 'Connectez-vous pour passer commande', + logIn: 'Se connecter', + logInToCheckout: 'Se connecter pour commander', + nostrKeyRequired: 'Une identit\u00e9 Nostr est requise', + nostrKeyDescription: 'Configurez votre cl\u00e9 publique Nostr dans les param\u00e8tres du profil pour passer commande.', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index ddd20c6..d81753a 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -144,6 +144,16 @@ export interface LocaleMessages { notAvailable: string } } + // Market module + market?: { + auth: { + loginPrompt: string + logIn: string + logInToCheckout: string + nostrKeyRequired: string + nostrKeyDescription: string + } + } // Add date/time formats dateTimeFormats: { short: { diff --git a/src/market-app/App.vue b/src/market-app/App.vue index 8c7f8ba..9aed606 100644 --- a/src/market-app/App.vue +++ b/src/market-app/App.vue @@ -6,8 +6,9 @@ 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' +import { + Store, ShoppingCart, Package, LogIn, User as UserIcon, +} from 'lucide-vue-next' const route = useRoute() const router = useRouter() @@ -17,8 +18,47 @@ const { isAuthenticated } = useAuth() const showLoginDialog = ref(false) +interface Tab { + name: string + icon: any + path?: string + authRequired?: boolean + onClick?: () => void +} + +const bottomTabs = computed(() => [ + { name: 'Browse', icon: Store, path: '/market' }, + { name: 'Cart', icon: ShoppingCart, path: '/cart' }, + { name: 'My Store', icon: Package, path: '/market/dashboard', authRequired: true }, + isAuthenticated.value + ? { name: 'Profile', icon: UserIcon, path: '/profile' } + : { name: 'Log in', icon: LogIn, path: '/login' }, +]) + const isLoginPage = computed(() => route.path === '/login') +function isActiveTab(tab: Tab): boolean { + if (!tab.path) return false + if (tab.path === '/market') { + return route.path === '/market' || route.path.startsWith('/market/stall/') || route.path.startsWith('/market/product/') + } + if (tab.path === '/cart') return route.path === '/cart' || route.path.startsWith('/checkout/') + return route.path.startsWith(tab.path) +} + +function onTabClick(tab: Tab) { + if (tab.authRequired && !isAuthenticated.value) { + toast.info(`${tab.name} requires login`, { + action: { + label: 'Log in', + onClick: () => router.push('/login'), + }, + }) + return + } + if (tab.path) router.push(tab.path) +} + async function handleLoginSuccess() { showLoginDialog.value = false toast.success('Welcome!') @@ -30,15 +70,33 @@ async function handleLoginSuccess() {
-
- -
- -
+
+ +
diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index cadb14b..47ccaa0 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -64,17 +64,15 @@ export function useMarket() { return 'disconnected' }) - // Load market from naddr + // Load market from naddr (or empty for public browse mode) const loadMarket = async (naddr: string) => { return await marketOperation.execute(async () => { - // Parse naddr to get market data + // Parse naddr (when given) to get market identifier + pubkey. + // Empty naddr + unauth user → public browse mode (no pubkey filter). + const parts = naddr ? naddr.split(':') : [] const marketData = { - identifier: naddr.split(':')[2] || 'default', - pubkey: naddr.split(':')[1] || authService.user.value?.pubkey || '' - } - - if (!marketData.pubkey) { - throw new Error('No pubkey available for market') + identifier: parts[2] || 'default', + pubkey: parts[1] || authService.user.value?.pubkey || '' } await loadMarketData(marketData) @@ -87,8 +85,30 @@ export function useMarket() { // Load market data from Nostr events const loadMarketData = async (marketData: any) => { try { - console.log('🛒 Loading market data for:', { identifier: marketData.identifier, pubkey: marketData.pubkey?.slice(0, 8) }) - + console.log('🛒 Loading market data for:', { identifier: marketData.identifier, pubkey: marketData.pubkey?.slice(0, 8) || '(public browse)' }) + + // Public browse mode: no curated naddr and no logged-in user. + // Skip the kind 30019 query and use a "Discover" placeholder market; + // loadStalls/loadProducts treat browseAll=true as "no authors filter". + if (!marketData.pubkey) { + const market = { + d: marketData.identifier, + pubkey: '', + relays: config.nostr.relays, + selected: true, + browseAll: true, + opts: { + name: 'Discover', + description: 'Public stalls and products from your relays', + merchants: [], + ui: {} + } + } + marketStore.addMarket(market) + marketStore.setActiveMarket(market) + return + } + // Check if we can query events (relays are connected) if (!isConnected.value) { console.log('🛒 Not connected to relays, creating default market') @@ -105,12 +125,12 @@ export function useMarket() { ui: {} } } - + marketStore.addMarket(market) marketStore.setActiveMarket(market) return } - + // Load market data from Nostr events // Fetch market configuration event const events = await relayHub.queryEvents([ @@ -148,7 +168,11 @@ export function useMarket() { relays: config.nostr.relays, selected: true, opts: { - name: `${import.meta.env.VITE_APP_NAME} Market`, + // Logged-in user has no published market event yet — show their + // namespace as "My Market". Avoids leaking VITE_APP_NAME (which + // is the brand of whichever standalone app is bundled, e.g. + // "Sortir" for activities) into the market label. + name: 'My Market', description: 'A communal market to sell your goods', merchants: [], ui: {} @@ -179,7 +203,7 @@ export function useMarket() { } } - // Load stalls from market merchants + // Load stalls from market merchants (or all stalls in public browse mode) const loadStalls = async () => { try { // Get the active market to filter by its merchants @@ -188,19 +212,20 @@ export function useMarket() { return } - const merchants = [...(activeMarket.opts.merchants || [])] - - if (merchants.length === 0) { - return - } + const browseAll = (activeMarket as any).browseAll === true + const merchants = [...(activeMarket.opts.merchants || [])] - // Fetch stall events from market merchants only - const events = await relayHub.queryEvents([ - { - kinds: [MARKET_EVENT_KINDS.STALL], - authors: merchants - } - ]) + if (!browseAll && merchants.length === 0) { + return + } + + // Build filter: in browse-all mode no authors filter; otherwise scope to merchants. + const stallFilter: any = { kinds: [MARKET_EVENT_KINDS.STALL] } + if (!browseAll && merchants.length > 0) { + stallFilter.authors = merchants + } + + const events = await relayHub.queryEvents([stallFilter]) console.log('🛒 Found', events.length, 'stall events for', merchants.length, 'merchants') @@ -245,7 +270,7 @@ export function useMarket() { } } - // Load products from market stalls + // Load products from market stalls (or all products in public browse mode) const loadProducts = async () => { try { const activeMarket = marketStore.activeMarket @@ -253,18 +278,19 @@ export function useMarket() { return } + const browseAll = (activeMarket as any).browseAll === true const merchants = [...(activeMarket.opts.merchants || [])] - if (merchants.length === 0) { - return - } - // Fetch product events from market merchants - const events = await relayHub.queryEvents([ - { - kinds: [MARKET_EVENT_KINDS.PRODUCT], - authors: merchants - } - ]) + if (!browseAll && merchants.length === 0) { + return + } + + const productFilter: any = { kinds: [MARKET_EVENT_KINDS.PRODUCT] } + if (!browseAll && merchants.length > 0) { + productFilter.authors = merchants + } + + const events = await relayHub.queryEvents([productFilter]) console.log('🛒 Found', events.length, 'product events for', merchants.length, 'merchants') diff --git a/src/modules/market/views/CheckoutPage.vue b/src/modules/market/views/CheckoutPage.vue index 9e87ab7..997ad39 100644 --- a/src/modules/market/views/CheckoutPage.vue +++ b/src/modules/market/views/CheckoutPage.vue @@ -241,6 +241,7 @@
-

+ +

{{ orderValidationMessage }}

+

+ {{ t('market.auth.loginPrompt') }} +

@@ -262,7 +275,9 @@