Compare commits
11 commits
51aff8cc87
...
9161e0cf68
| Author | SHA1 | Date | |
|---|---|---|---|
| 9161e0cf68 | |||
| 367124bde2 | |||
| 5509668e6b | |||
| ba2370c71f | |||
| 73b67d2765 | |||
| ae68eb09c4 | |||
| 14b81bf3eb | |||
| 9c8383ba73 | |||
| cd84e106e8 | |||
| 3727b52da4 | |||
| e7b4ce7423 |
18 changed files with 441 additions and 152 deletions
60
.env.example
60
.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 <a href> from these env vars and
|
||||
# (when authenticated) appends ?token=<lnbits_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.<name>.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`.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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.<domain>
|
||||
# Serves only the chakra icon hub + base infra (profile, relays).
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# PATH-MODE deployment (recommended)
|
||||
#
|
||||
# demo.<domain>.<com>/ — minimal AIO chakra hub
|
||||
# demo.<domain>.<com>/activities/ — Sortir / activities standalone
|
||||
# demo.<domain>.<com>/market/ — marketplace standalone
|
||||
# demo.<domain>.<com>/wallet/ — wallet standalone
|
||||
# demo.<domain>.<com>/chat/ — chat standalone
|
||||
# demo.<domain>.<com>/forum/ — forum standalone
|
||||
# demo.<domain>.<com>/tasks/ — tasks standalone
|
||||
# demo.<domain>.<com>/castle/ — castle (accounting) standalone
|
||||
#
|
||||
# Each standalone is built with VITE_BASE_PATH=/<name>/ so its asset URLs
|
||||
# are prefixed correctly. The hub's chakra tiles point at the canonical
|
||||
# trailing-slash path (VITE_HUB_<NAME>_URL=https://demo.<domain>/<name>/).
|
||||
#
|
||||
# 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.<domain>.<com>;
|
||||
server_name demo.<domain>.<com>;
|
||||
|
||||
# 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 /<app>/ 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.<domain>.<com> → demo.<domain>.<com>/activities/
|
||||
# market.demo.<domain>.<com> → demo.<domain>.<com>/market/
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
server {
|
||||
listen 8080;
|
||||
server_name market.<domain>.<com>;
|
||||
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.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/activities/$request_uri;
|
||||
}
|
||||
|
||||
# Activities — Swadhisthana
|
||||
server {
|
||||
listen 8080;
|
||||
server_name sortir.<domain>.<com>;
|
||||
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.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/market/$request_uri;
|
||||
}
|
||||
|
||||
# Wallet — Manipura
|
||||
server {
|
||||
listen 8080;
|
||||
server_name wallet.<domain>.<com>;
|
||||
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.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/wallet/$request_uri;
|
||||
}
|
||||
|
||||
# Chat — Anahata
|
||||
server {
|
||||
listen 8080;
|
||||
server_name chat.<domain>.<com>;
|
||||
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.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/chat/$request_uri;
|
||||
}
|
||||
|
||||
# Forum — Vishuddha
|
||||
server {
|
||||
listen 8080;
|
||||
server_name forum.<domain>.<com>;
|
||||
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.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/forum/$request_uri;
|
||||
}
|
||||
|
||||
# Tasks — Ajna
|
||||
server {
|
||||
listen 8080;
|
||||
server_name tasks.<domain>.<com>;
|
||||
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.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/tasks/$request_uri;
|
||||
}
|
||||
|
||||
# Castle — Sahasrara (accounting)
|
||||
server {
|
||||
listen 8080;
|
||||
server_name castle.<domain>.<com>;
|
||||
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.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/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.<domain>; root /var/www/aio/dist; ... }
|
||||
# server { server_name market.<domain>; root /var/www/aio/dist-market; ... }
|
||||
# server { server_name sortir.<domain>; root /var/www/aio/dist-activities; ... }
|
||||
# server { server_name wallet.<domain>; root /var/www/aio/dist-wallet; ... }
|
||||
# server { server_name chat.<domain>; root /var/www/aio/dist-chat; ... }
|
||||
# server { server_name forum.<domain>; root /var/www/aio/dist-forum; ... }
|
||||
# server { server_name tasks.<domain>; root /var/www/aio/dist-tasks; ... }
|
||||
# server { server_name castle.<domain>; root /var/www/aio/dist-castle; ... }
|
||||
#
|
||||
# Each block uses `location / { try_files $uri $uri/ /<name>.html; }`.
|
||||
# In subdomain mode, build each standalone WITHOUT VITE_BASE_PATH (the
|
||||
# default `/` is correct), and set VITE_HUB_<NAME>_URL to the subdomain
|
||||
# in the hub's env (e.g. VITE_HUB_MARKET_URL=https://market.<domain>).
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<Tab[]>(() => [
|
||||
{ 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() {
|
|||
<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">
|
||||
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<nav
|
||||
v-if="!isLoginPage"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<button
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.name"
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="[
|
||||
isActiveTab(tab)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
tab.authRequired && !isAuthenticated ? 'opacity-50' : '',
|
||||
]"
|
||||
@click="onTabClick(tab)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@
|
|||
<!-- Place Order Button -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<Button
|
||||
v-if="auth.isAuthenticated.value"
|
||||
@click="placeOrder"
|
||||
:disabled="isPlacingOrder || !canPlaceOrder"
|
||||
class="w-full"
|
||||
|
|
@ -249,9 +250,21 @@
|
|||
<span v-if="isPlacingOrder" class="animate-spin mr-2">⚡</span>
|
||||
Place Order - {{ formatPrice(orderTotal, checkoutCart.currency) }}
|
||||
</Button>
|
||||
<p v-if="!canPlaceOrder" class="text-xs text-destructive mt-2 text-center">
|
||||
<Button
|
||||
v-else
|
||||
@click="router.push('/login')"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
>
|
||||
{{ t('market.auth.logInToCheckout') }}
|
||||
</Button>
|
||||
<p v-if="auth.isAuthenticated.value && !canPlaceOrder" class="text-xs text-destructive mt-2 text-center">
|
||||
{{ orderValidationMessage }}
|
||||
</p>
|
||||
<p v-else-if="!auth.isAuthenticated.value" class="text-xs text-muted-foreground mt-2 text-center">
|
||||
{{ t('market.auth.loginPrompt') }}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -262,7 +275,9 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
|
|
@ -292,6 +307,8 @@ import {
|
|||
|
||||
const { thumbnail } = useImageOptimizer()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const marketStore = useMarketStore()
|
||||
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
||||
|
||||
|
|
@ -434,12 +451,24 @@ const placeOrder = async () => {
|
|||
// Try to get pubkey from main auth first, fallback to auth service
|
||||
const userPubkey = auth.currentUser.value?.pubkey || authService.user.value?.pubkey
|
||||
|
||||
// Friendly toast instead of throw — same pattern as Activities favorites prompt.
|
||||
if (!auth.isAuthenticated.value) {
|
||||
throw new Error('You must be logged in to place an order')
|
||||
toast.info(t('market.auth.loginPrompt'), {
|
||||
action: {
|
||||
label: t('market.auth.logIn'),
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
isPlacingOrder.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!userPubkey) {
|
||||
throw new Error('Nostr identity required: Please configure your Nostr public key in your profile settings to place orders.')
|
||||
toast.info(t('market.auth.nostrKeyRequired'), {
|
||||
description: t('market.auth.nostrKeyDescription'),
|
||||
})
|
||||
isPlacingOrder.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Create the order using the market store's order placement functionality
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ const modules: Module[] = [
|
|||
{ label: 'Activities', chakra: 'Swadhisthana', icon: CalendarDays, bgClass: '', glow: 'rgba(255,165,0,0.5)', envKey: 'VITE_HUB_ACTIVITIES_URL', status: 'beta' },
|
||||
{ label: 'Chat', chakra: 'Anahata', icon: MessageCircle, bgClass: '', glow: 'rgba(0,200,80,0.5)', envKey: 'VITE_HUB_CHAT_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Forum', chakra: 'Vishuddha', icon: Newspaper, bgClass: '', glow: 'rgba(60,120,255,0.5)', envKey: 'VITE_HUB_FORUM_URL', status: 'alpha' },
|
||||
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha' },
|
||||
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Castle', chakra: 'Sahasrara', icon: Castle, bgClass: '', glow: 'rgba(160,80,220,0.5)', envKey: 'VITE_HUB_CASTLE_URL', status: 'beta', authRequired: true },
|
||||
]
|
||||
// Crown at top, root at bottom
|
||||
|
|
@ -57,7 +57,7 @@ const token = computed(() => localStorage.getItem('lnbits_access_token') || '')
|
|||
|
||||
function hubLink(m: Module): string | null {
|
||||
if (!m.envKey) return null
|
||||
// Auth-only modules (wallet, chat, castle) are ghosted when not logged in.
|
||||
// Auth-only modules (wallet, chat, castle, tasks) are ghosted when not logged in.
|
||||
if (m.authRequired && !isAuthenticated.value) return null
|
||||
const url = import.meta.env[m.envKey] as string | undefined
|
||||
if (!url) return null
|
||||
|
|
@ -68,6 +68,24 @@ function hubLink(m: Module): string | null {
|
|||
return url
|
||||
}
|
||||
|
||||
function isAuthGated(m: Module): boolean {
|
||||
return !!(m.authRequired && !isAuthenticated.value)
|
||||
}
|
||||
|
||||
function onTileClick(m: Module, event: Event) {
|
||||
// Ghosted auth-required tiles aren't anchors; intercept and toast.
|
||||
if (isAuthGated(m)) {
|
||||
event.preventDefault()
|
||||
toast.info(`${m.label} requires login`, {
|
||||
action: {
|
||||
label: 'Log in',
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
}
|
||||
// "Coming soon" tiles (no envKey, no authRequired) silently do nothing.
|
||||
}
|
||||
|
||||
const showProfile = ref(false)
|
||||
|
||||
function notImplemented() {
|
||||
|
|
@ -89,32 +107,25 @@ function notImplemented() {
|
|||
rgba(185, 28, 28, 0.10) 100%);
|
||||
"
|
||||
>
|
||||
<!-- Faint chakra mandala column behind tiles (peek through translucent tiles) -->
|
||||
<div class="absolute inset-x-0 top-0 bottom-16 flex flex-col justify-around items-center py-4 pointer-events-none">
|
||||
<img v-for="svg in ['sahasrara.svg','ajna.svg','vishuddha.svg','anahata.svg','manipura.svg','swadhisthana.svg','muladhara.svg']"
|
||||
:key="svg"
|
||||
:src="`/chakras/${svg}`"
|
||||
class="w-32 h-32 sm:w-40 sm:h-40 opacity-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Main grid -->
|
||||
<div class="relative w-full max-w-2xl mx-auto px-4 pt-6 pb-2 flex-1 flex flex-col min-h-0">
|
||||
<h1 class="text-2xl font-light text-center text-foreground/90 mb-1 tracking-wide">aiolabs</h1>
|
||||
<p class="text-xs text-center text-muted-foreground mb-3 italic">from earth to sky</p>
|
||||
<h1 class="text-2xl font-light text-center text-foreground/90 mb-3 tracking-wide">aiolabs</h1>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
||||
<component
|
||||
v-for="m in orderedModules"
|
||||
:key="m.label"
|
||||
:is="hubLink(m) ? 'a' : 'div'"
|
||||
:is="hubLink(m) ? 'a' : (isAuthGated(m) ? 'button' : 'div')"
|
||||
:href="hubLink(m) || undefined"
|
||||
class="relative flex flex-col items-center justify-center gap-1 rounded-xl border backdrop-blur-sm transition-all p-2 min-h-0"
|
||||
:class="[
|
||||
hubLink(m)
|
||||
? 'bg-card/60 hover:bg-card/40 border-white/10 hover:border-white/25 cursor-pointer'
|
||||
: 'bg-card/70 border-white/5 opacity-60 cursor-not-allowed',
|
||||
: isAuthGated(m)
|
||||
? 'bg-card/70 hover:bg-card/55 border-white/5 opacity-60 cursor-pointer'
|
||||
: 'bg-card/70 border-white/5 opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
@click="onTileClick(m, $event)"
|
||||
>
|
||||
<component :is="m.icon" class="h-7 w-7 text-foreground/90" :style="{ filter: `drop-shadow(0 0 8px ${m.glow})` }" />
|
||||
<div class="text-center leading-tight">
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ function activitiesHtmlPlugin(): Plugin {
|
|||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-activities',
|
||||
server: {
|
||||
port: 5181,
|
||||
strictPort: true,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ function castleHtmlPlugin(): Plugin {
|
|||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-castle',
|
||||
server: {
|
||||
port: 5180,
|
||||
strictPort: true,
|
||||
|
|
@ -105,10 +107,13 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
// CRITICAL: Remap @/app.config to the castle app's config
|
||||
// ExpensesAPI and other modules import from @/app.config directly
|
||||
// ORDER MATTERS — @rollup/plugin-alias is first-match-wins.
|
||||
// The more specific @/app.config remap must precede the @ prefix
|
||||
// alias, otherwise '@/app.config' matches '@' first and resolves
|
||||
// to ./src/app.config (the hub config). ExpensesAPI etc. import
|
||||
// from @/app.config and need the per-app config.
|
||||
'@/app.config': fileURLToPath(new URL('./src/accounting-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ function chatHtmlPlugin(): Plugin {
|
|||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-chat',
|
||||
server: {
|
||||
port: 5183,
|
||||
strictPort: true,
|
||||
|
|
@ -98,8 +100,9 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/chat-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
|||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-hub',
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ function forumHtmlPlugin(): Plugin {
|
|||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-forum',
|
||||
server: {
|
||||
port: 5184,
|
||||
strictPort: true,
|
||||
|
|
@ -98,8 +100,9 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ function marketHtmlPlugin(): Plugin {
|
|||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-market',
|
||||
server: {
|
||||
port: 5185,
|
||||
strictPort: true,
|
||||
|
|
@ -98,8 +100,9 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ function tasksHtmlPlugin(): Plugin {
|
|||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-tasks',
|
||||
server: {
|
||||
port: 5186,
|
||||
strictPort: true,
|
||||
|
|
@ -98,8 +100,9 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/tasks-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ function walletHtmlPlugin(): Plugin {
|
|||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-wallet',
|
||||
server: {
|
||||
port: 5182,
|
||||
strictPort: true,
|
||||
|
|
@ -104,8 +106,9 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue