feat: multi-module improvements across merchant, store, i18n, and customer systems
All checks were successful
All checks were successful
- Fix platform-grouped merchant sidebar menu with core items at root level - Add merchant store management (detail page, create store, team page) - Fix store settings 500 error by removing dead stripe/API tab - Move onboarding translations to module-owned locale files - Fix onboarding banner i18n with server-side rendering + context inheritance - Refactor login language selectors to use languageSelector() function (LANG-002) - Move HTTPException handling to global exception handler in merchant routes (API-003) - Add language selector to all login pages and portal headers - Fix customer module: drop order stats from customer model, add to orders module - Fix admin menu config visibility for super admin platform context - Fix storefront auth and layout issues - Add missing i18n translations for onboarding steps (en/fr/de/lb) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Admin Login
|
||||
{{ _("auth.admin_login") }}
|
||||
</h1>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
@@ -45,80 +45,129 @@
|
||||
x-transition></div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin">
|
||||
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Username</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.username") }}</span>
|
||||
<input x-model="credentials.username"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.username }"
|
||||
placeholder="Enter your username"
|
||||
placeholder="{{ _('auth.username_placeholder') }}"
|
||||
autocomplete="username"
|
||||
required />
|
||||
<span x-show="errors.username" x-text="errors.username"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="***************"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
|
||||
<div class="relative" x-data="{ showPw: false }">
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
:type="showPw ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="{{ _('auth.password_placeholder') }}"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPw = !showPw"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPw ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox"
|
||||
x-model="rememberMe"
|
||||
class="form-checkbox text-purple-600 border-gray-300 rounded focus:ring-purple-500 focus:outline-none">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
|
||||
</label>
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
{{ _("auth.forgot_password") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# noqa: FE-002 - Inline spinner SVG for loading state #}
|
||||
<button type="submit" :disabled="loading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Sign in</span>
|
||||
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
{{ _("auth.signing_in") }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<div x-show="showForgotPassword" x-cloak x-transition class="mt-6">
|
||||
<hr class="mb-6" />
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _("auth.reset_password") }}</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">{{ _("auth.reset_password_desc") }}</p>
|
||||
<form @submit.prevent="handleForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
|
||||
<input x-model="forgotPasswordEmail"
|
||||
:disabled="forgotPasswordLoading"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
placeholder="{{ _('auth.email_placeholder') }}"
|
||||
type="email"
|
||||
required />
|
||||
</label>
|
||||
<button type="submit" :disabled="forgotPasswordLoading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!forgotPasswordLoading">{{ _("auth.send_reset_link") }}</span>
|
||||
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = false"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
← {{ _("auth.back_to_login") }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4">
|
||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
|
||||
href="#">
|
||||
Forgot your password?
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
|
||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
|
||||
href="/">
|
||||
{{ _("auth.visit_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← Back to Platform
|
||||
← {{ _("auth.back_to_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center justify-center gap-3 mt-6" x-data="{
|
||||
async setLang(lang) {
|
||||
await fetch('/api/v1/platform/language/set', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({language: lang})
|
||||
});
|
||||
window.location.reload();
|
||||
}
|
||||
}">
|
||||
<button @click="setLang('en')" class="fi fi-gb text-lg opacity-60 hover:opacity-100 transition-opacity" title="English"></button>
|
||||
<button @click="setLang('fr')" class="fi fi-fr text-lg opacity-60 hover:opacity-100 transition-opacity" title="Français"></button>
|
||||
<button @click="setLang('de')" class="fi fi-de text-lg opacity-60 hover:opacity-100 transition-opacity" title="Deutsch"></button>
|
||||
<button @click="setLang('lb')" class="fi fi-lu text-lg opacity-60 hover:opacity-100 transition-opacity" title="Lëtzebuergesch"></button>
|
||||
<div class="flex items-center justify-center gap-2 mt-6"
|
||||
x-data='languageSelector("{{ request.state.language|default("en") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
|
||||
:class="currentLang === lang
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
x-text="lang.toUpperCase()"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="platform?.name || 'Loading...'"></h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure which menu items are visible for admins, stores, and merchants on this platform.
|
||||
Configure which menu items are visible for admins and stores on this platform.
|
||||
</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" x-text="platform?.code?.toUpperCase()"></span>
|
||||
@@ -52,17 +52,8 @@
|
||||
<span x-html="$icon('shopping-bag', 'w-4 h-4 inline mr-2')"></span>
|
||||
Store Frontend
|
||||
</button>
|
||||
<button
|
||||
@click="frontendType = 'merchant'; loadPlatformMenuConfig()"
|
||||
:class="{
|
||||
'bg-white dark:bg-gray-800 shadow': frontendType === 'merchant',
|
||||
'text-gray-600 dark:text-gray-400': frontendType !== 'merchant'
|
||||
}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md transition-all"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-4 h-4 inline mr-2')"></span>
|
||||
Merchant Frontend
|
||||
</button>
|
||||
{# Merchant frontend menu is driven by module enablement + subscriptions,
|
||||
not by AdminMenuConfig visibility. No tab needed here. #}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,20 +37,44 @@
|
||||
<p class="text-red-700 dark:text-red-400" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Super Admin Notice -->
|
||||
<div x-show="isSuperAdmin && !loading" x-cloak class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p class="text-blue-700 dark:text-blue-400">
|
||||
You are a Super Admin with access to all platforms. Redirecting to dashboard...
|
||||
</p>
|
||||
<!-- Global Mode Card (Super Admins Only) -->
|
||||
<div x-show="isSuperAdmin && !loading" x-cloak class="mb-3">
|
||||
<button
|
||||
@click="deselectPlatform()"
|
||||
:disabled="selecting"
|
||||
class="w-full flex items-center p-4 rounded-lg border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="!currentPlatformId
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-500 ring-2 ring-green-200 dark:ring-green-800'
|
||||
: 'bg-gray-50 dark:bg-gray-700 border-transparent hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/20'"
|
||||
>
|
||||
<!-- Globe Icon -->
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center mr-4">
|
||||
<span x-html="$icon('globe-alt', 'w-6 h-6 text-green-600 dark:text-green-400')"></span>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-gray-800 dark:text-gray-200">Global Mode (All Platforms)</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Access all modules across all platforms</p>
|
||||
</div>
|
||||
|
||||
<!-- Checkmark for active -->
|
||||
<div class="flex-shrink-0 ml-4" x-show="!currentPlatformId">
|
||||
<span x-html="$icon('check-circle', 'w-6 h-6 text-green-600 dark:text-green-400')"></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Platform List -->
|
||||
<div x-show="!loading && !isSuperAdmin && platforms.length > 0" x-cloak class="space-y-3">
|
||||
<div x-show="!loading && platforms.length > 0" x-cloak class="space-y-3">
|
||||
<template x-for="platform in platforms" :key="platform.id">
|
||||
<button
|
||||
@click="selectPlatform(platform)"
|
||||
@click="choosePlatform(platform)"
|
||||
:disabled="selecting"
|
||||
class="w-full flex items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border-2 border-transparent hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="w-full flex items-center p-4 rounded-lg border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="isCurrentPlatform(platform)
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-500 ring-2 ring-purple-200 dark:ring-purple-800'
|
||||
: 'bg-gray-50 dark:bg-gray-700 border-transparent hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20'"
|
||||
>
|
||||
<!-- Platform Icon/Logo -->
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center mr-4">
|
||||
@@ -68,9 +92,14 @@
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="platform.code"></p>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<!-- Checkmark for active / Arrow for others -->
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
<template x-if="isCurrentPlatform(platform)">
|
||||
<span x-html="$icon('check-circle', 'w-6 h-6 text-purple-600 dark:text-purple-400')"></span>
|
||||
</template>
|
||||
<template x-if="!isCurrentPlatform(platform)">
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</template>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -93,8 +122,41 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<div class="mt-4 flex justify-center">
|
||||
<!-- Language & Theme Toggle -->
|
||||
<div class="mt-4 flex justify-center items-center gap-2">
|
||||
<!-- Language selector -->
|
||||
<div class="relative" x-data="{ langOpen: false, currentLang: '{{ request.state.language|default('en') }}', async setLang(lang) { this.currentLang = lang; await fetch('/api/v1/platform/language/set', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({language: lang}) }); window.location.reload(); } }">
|
||||
<button
|
||||
@click="langOpen = !langOpen"
|
||||
@click.outside="langOpen = false"
|
||||
class="inline-flex items-center gap-1 p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<span x-html="$icon('globe-alt', 'w-5 h-5')"></span>
|
||||
<span class="text-xs font-semibold uppercase" x-text="currentLang"></span>
|
||||
</button>
|
||||
<div
|
||||
x-show="langOpen"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 w-40 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-100 dark:border-gray-600 py-1 z-50"
|
||||
>
|
||||
<button @click="setLang('en'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<span class="font-semibold text-xs w-5">EN</span> English
|
||||
</button>
|
||||
<button @click="setLang('fr'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<span class="font-semibold text-xs w-5">FR</span> Français
|
||||
</button>
|
||||
<button @click="setLang('de'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<span class="font-semibold text-xs w-5">DE</span> Deutsch
|
||||
</button>
|
||||
<button @click="setLang('lb'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<span class="font-semibold text-xs w-5">LB</span> Lëtzebuergesch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dark mode toggle -->
|
||||
<button
|
||||
@click="toggleDarkMode()"
|
||||
class="p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Merchant Login
|
||||
{{ _("auth.merchant_login") }}
|
||||
</h1>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
@@ -46,110 +46,129 @@
|
||||
x-transition></div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin">
|
||||
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
|
||||
<input x-model="credentials.email"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.email }"
|
||||
placeholder="you@example.com"
|
||||
placeholder="{{ _('auth.email_placeholder') }}"
|
||||
autocomplete="username"
|
||||
required />
|
||||
<span x-show="errors.email" x-text="errors.email"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="***************"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
|
||||
<div class="relative" x-data="{ showPw: false }">
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
:type="showPw ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="{{ _('auth.password_placeholder') }}"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPw = !showPw"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPw ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox"
|
||||
x-model="rememberMe"
|
||||
class="form-checkbox text-purple-600 border-gray-300 rounded focus:ring-purple-500 focus:outline-none">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
|
||||
</label>
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
{{ _("auth.forgot_password") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# noqa: FE-002 - Inline spinner SVG for loading state #}
|
||||
<button type="submit" :disabled="loading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Sign in</span>
|
||||
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
{{ _("auth.signing_in") }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<div x-show="showForgotPassword" x-transition>
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
<div x-show="showForgotPassword" x-cloak x-transition class="mt-6">
|
||||
<hr class="mb-6" />
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _("auth.reset_password") }}</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">{{ _("auth.reset_password_desc") }}</p>
|
||||
<form @submit.prevent="handleForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
|
||||
<input x-model="forgotPasswordEmail"
|
||||
:disabled="forgotPasswordLoading"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
placeholder="you@example.com"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
placeholder="{{ _('auth.email_placeholder') }}"
|
||||
type="email"
|
||||
required />
|
||||
</label>
|
||||
<button type="submit" :disabled="forgotPasswordLoading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!forgotPasswordLoading">Send Reset Link</span>
|
||||
<span x-show="forgotPasswordLoading">Sending...</span>
|
||||
<span x-show="!forgotPasswordLoading">{{ _("auth.send_reset_link") }}</span>
|
||||
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = false"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
← Back to Login
|
||||
← {{ _("auth.back_to_login") }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!showForgotPassword">
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← Back to Platform
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
|
||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
|
||||
href="/">
|
||||
{{ _("auth.visit_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← {{ _("auth.back_to_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center justify-center gap-3 mt-6" x-data="{
|
||||
async setLang(lang) {
|
||||
await fetch('/api/v1/platform/language/set', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({language: lang})
|
||||
});
|
||||
window.location.reload();
|
||||
}
|
||||
}">
|
||||
<button @click="setLang('en')" class="fi fi-gb text-lg opacity-60 hover:opacity-100 transition-opacity" title="English"></button>
|
||||
<button @click="setLang('fr')" class="fi fi-fr text-lg opacity-60 hover:opacity-100 transition-opacity" title="Français"></button>
|
||||
<button @click="setLang('de')" class="fi fi-de text-lg opacity-60 hover:opacity-100 transition-opacity" title="Deutsch"></button>
|
||||
<button @click="setLang('lb')" class="fi fi-lu text-lg opacity-60 hover:opacity-100 transition-opacity" title="Lëtzebuergesch"></button>
|
||||
<div class="flex items-center justify-center gap-2 mt-6"
|
||||
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
|
||||
:class="currentLang === lang
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
x-text="lang.toUpperCase()"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
315
app/modules/tenancy/templates/tenancy/merchant/store-detail.html
Normal file
315
app/modules/tenancy/templates/tenancy/merchant/store-detail.html
Normal file
@@ -0,0 +1,315 @@
|
||||
{# app/modules/tenancy/templates/tenancy/merchant/store-detail.html #}
|
||||
{% extends "merchant/base.html" %}
|
||||
|
||||
{% block title %}Store Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="merchantStoreDetail({{ store_id }})">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<a href="/merchants/account/stores" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-4">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Stores
|
||||
</a>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900" x-text="store?.name || 'Store Details'"></h2>
|
||||
<p class="mt-1 text-sm text-gray-400 font-mono" x-text="store?.store_code"></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Status Badges -->
|
||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': store?.is_active,
|
||||
'bg-gray-100 text-gray-600': !store?.is_active
|
||||
}"
|
||||
x-text="store?.is_active ? 'Active' : 'Inactive'"></span>
|
||||
<template x-if="store?.is_verified">
|
||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">Verified</span>
|
||||
</template>
|
||||
<template x-if="store && !store.is_verified">
|
||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">Pending Verification</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-sm text-red-800" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p class="text-sm text-green-800" x-text="successMessage"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Loading store details...
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && store" class="space-y-6">
|
||||
|
||||
<!-- Store Information (read-only) -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Store Information</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Store Code</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono" x-text="store.store_code"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Subdomain</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.subdomain"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Default Language</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.default_language || 'Not set'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="formatDate(store.created_at)"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Assignments -->
|
||||
<div x-show="store.platforms && store.platforms.length > 0" class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Platforms</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="platform in store.platforms" :key="platform.id">
|
||||
<span class="inline-flex items-center px-3 py-1.5 text-sm rounded-lg border"
|
||||
:class="{
|
||||
'bg-green-50 border-green-200 text-green-800': platform.is_active,
|
||||
'bg-gray-50 border-gray-200 text-gray-600': !platform.is_active
|
||||
}">
|
||||
<span x-text="platform.name"></span>
|
||||
<span class="ml-1.5 text-xs font-mono opacity-60" x-text="platform.code"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editable Fields -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Store Details</h3>
|
||||
<template x-if="!editing">
|
||||
<button @click="startEditing()" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">Edit</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- View Mode -->
|
||||
<div x-show="!editing" class="p-6">
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.name"></dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Description</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.description || 'No description'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Contact Email</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.contact_email || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Contact Phone</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.contact_phone || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Website</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.website || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Tax Number</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.tax_number || '-'"></dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Business Address</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.business_address || '-'"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
<form x-show="editing" @submit.prevent="saveStore()" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" x-model="editForm.name" required minlength="2" maxlength="255"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea x-model="editForm.description" rows="2"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Email</label>
|
||||
<input type="email" x-model="editForm.contact_email"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Phone</label>
|
||||
<input type="tel" x-model="editForm.contact_phone"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Website</label>
|
||||
<input type="url" x-model="editForm.website" placeholder="https://example.com"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Business Address</label>
|
||||
<textarea x-model="editForm.business_address" rows="2"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Tax Number (VAT ID)</label>
|
||||
<input type="text" x-model="editForm.tax_number" placeholder="LU12345678"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" @click="editing = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="saving"
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
|
||||
<span x-show="!saving">Save Changes</span>
|
||||
<span x-show="saving" class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Not Found -->
|
||||
<div x-show="!loading && !store && notFound" class="text-center py-16">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-700">Store not found</h3>
|
||||
<p class="mt-1 text-gray-500">This store doesn't exist or you don't have access to it.</p>
|
||||
<a href="/merchants/account/stores" class="mt-4 inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800">
|
||||
← Back to Stores
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function merchantStoreDetail(storeId) {
|
||||
return {
|
||||
storeId,
|
||||
loading: true,
|
||||
store: null,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
notFound: false,
|
||||
editing: false,
|
||||
saving: false,
|
||||
editForm: {},
|
||||
|
||||
init() {
|
||||
this.loadStore();
|
||||
},
|
||||
|
||||
async loadStore() {
|
||||
try {
|
||||
this.store = await apiClient.get(`/merchants/account/stores/${this.storeId}`);
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
this.notFound = true;
|
||||
} else {
|
||||
this.error = 'Failed to load store details.';
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
startEditing() {
|
||||
this.editForm = {
|
||||
name: this.store.name || '',
|
||||
description: this.store.description || '',
|
||||
contact_email: this.store.contact_email || '',
|
||||
contact_phone: this.store.contact_phone || '',
|
||||
website: this.store.website || '',
|
||||
business_address: this.store.business_address || '',
|
||||
tax_number: this.store.tax_number || '',
|
||||
};
|
||||
this.editing = true;
|
||||
},
|
||||
|
||||
async saveStore() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
// Only send fields that changed
|
||||
const payload = {};
|
||||
for (const [key, value] of Object.entries(this.editForm)) {
|
||||
const original = this.store[key] || '';
|
||||
if (value !== original) {
|
||||
payload[key] = value || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
this.editing = false;
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.store = await apiClient.put(
|
||||
`/merchants/account/stores/${this.storeId}`,
|
||||
payload
|
||||
);
|
||||
this.editing = false;
|
||||
this.successMessage = 'Store updated successfully.';
|
||||
setTimeout(() => { this.successMessage = null; }, 3000);
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update store.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -7,9 +7,21 @@
|
||||
<div x-data="merchantStores()">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900">My Stores</h2>
|
||||
<p class="mt-1 text-gray-500">View and manage your connected stores.</p>
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">My Stores</h2>
|
||||
<p class="mt-1 text-gray-500">View and manage your connected stores.</p>
|
||||
</div>
|
||||
<button
|
||||
x-show="canCreateStore"
|
||||
@click="showCreateModal = true"
|
||||
class="inline-flex items-center px-4 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Add Store
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
@@ -17,6 +29,11 @@
|
||||
<p class="text-sm text-red-800" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p class="text-sm text-green-800" x-text="successMessage"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
@@ -33,50 +50,195 @@
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-700">No stores yet</h3>
|
||||
<p class="mt-1 text-gray-500">Your stores will appear here once connected.</p>
|
||||
<p class="mt-1 text-gray-500">Create your first store to get started.</p>
|
||||
<button
|
||||
x-show="canCreateStore"
|
||||
@click="showCreateModal = true"
|
||||
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-semibold text-indigo-600 border border-indigo-300 rounded-lg hover:bg-indigo-50 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Add Store
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Store Cards Grid -->
|
||||
<div x-show="!loading && stores.length > 0" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="store in stores" :key="store.id">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
|
||||
<a :href="'/merchants/account/stores/' + store.id"
|
||||
class="block bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md hover:border-indigo-200 transition-all cursor-pointer group">
|
||||
<div class="p-6">
|
||||
<!-- Store Name and Status -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900" x-text="store.name"></h3>
|
||||
<h3 class="text-lg font-semibold text-gray-900 group-hover:text-indigo-600 transition-colors" x-text="store.name"></h3>
|
||||
<p class="text-sm text-gray-400 font-mono" x-text="store.store_code"></p>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': store.status === 'active',
|
||||
'bg-yellow-100 text-yellow-800': store.status === 'pending',
|
||||
'bg-gray-100 text-gray-600': store.status === 'inactive',
|
||||
'bg-red-100 text-red-800': store.status === 'suspended'
|
||||
'bg-green-100 text-green-800': store.is_active,
|
||||
'bg-gray-100 text-gray-600': !store.is_active
|
||||
}"
|
||||
x-text="(store.status || 'active').toUpperCase()"></span>
|
||||
x-text="store.is_active ? 'ACTIVE' : 'INACTIVE'"></span>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Store Code</dt>
|
||||
<dd class="font-medium text-gray-900" x-text="store.store_code"></dd>
|
||||
<dt class="text-gray-500">Subdomain</dt>
|
||||
<dd class="font-medium text-gray-900" x-text="store.subdomain || '-'"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Created</dt>
|
||||
<dd class="font-medium text-gray-900" x-text="formatDate(store.created_at)"></dd>
|
||||
</div>
|
||||
<div x-show="store.platform_name" class="flex justify-between">
|
||||
<dt class="text-gray-500">Platform</dt>
|
||||
<dd class="font-medium text-gray-900" x-text="store.platform_name"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Verification Badge -->
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<template x-if="store.is_verified">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Verified
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!store.is_verified">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-yellow-100 text-yellow-800">
|
||||
Pending Verification
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card Footer -->
|
||||
<div class="px-6 py-3 bg-gray-50 border-t border-gray-100 text-right">
|
||||
<span class="text-xs text-indigo-600 font-medium group-hover:underline">View Details →</span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Create Store Modal -->
|
||||
<div x-show="showCreateModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50" @click="showCreateModal = false"></div>
|
||||
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-4" @click.stop>
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Create New Store</h3>
|
||||
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createStore()" class="p-6 space-y-4">
|
||||
<!-- Store Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Store Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="createForm.name"
|
||||
@input="autoGenerateCode()"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="255"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="My Awesome Store"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Store Code & Subdomain -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Store Code</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="createForm.store_code"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="50"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 font-mono uppercase focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="MYSTORE"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Subdomain</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="createForm.subdomain"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="100"
|
||||
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 lowercase focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="my-store"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
x-model="createForm.description"
|
||||
rows="2"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="Brief description of the store"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Platform Selection -->
|
||||
<div x-show="availablePlatforms.length > 0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Platforms</label>
|
||||
<div class="space-y-2">
|
||||
<template x-for="platform in availablePlatforms" :key="platform.id">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="platform.id"
|
||||
x-model.number="createForm.platform_ids"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700" x-text="platform.name"></span>
|
||||
<span class="text-xs text-gray-400 font-mono" x-text="platform.code"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Error -->
|
||||
<div x-show="createError" class="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-sm text-red-800" x-text="createError"></p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
@click="showCreateModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating"
|
||||
class="inline-flex items-center px-5 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!creating">Create Store</span>
|
||||
<span x-show="creating" class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Creating...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -86,7 +248,20 @@ function merchantStores() {
|
||||
return {
|
||||
loading: true,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
stores: [],
|
||||
canCreateStore: false,
|
||||
showCreateModal: false,
|
||||
creating: false,
|
||||
createError: null,
|
||||
availablePlatforms: [],
|
||||
createForm: {
|
||||
name: '',
|
||||
store_code: '',
|
||||
subdomain: '',
|
||||
description: '',
|
||||
platform_ids: [],
|
||||
},
|
||||
|
||||
init() {
|
||||
this.loadStores();
|
||||
@@ -96,6 +271,7 @@ function merchantStores() {
|
||||
try {
|
||||
const data = await apiClient.get('/merchants/account/stores');
|
||||
this.stores = data.stores || data.items || [];
|
||||
this.canCreateStore = data.can_create_store !== false;
|
||||
} catch (err) {
|
||||
console.error('Error loading stores:', err);
|
||||
this.error = 'Failed to load stores. Please try again.';
|
||||
@@ -104,6 +280,45 @@ function merchantStores() {
|
||||
}
|
||||
},
|
||||
|
||||
autoGenerateCode() {
|
||||
const name = this.createForm.name;
|
||||
this.createForm.store_code = name
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 20);
|
||||
this.createForm.subdomain = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
},
|
||||
|
||||
async createStore() {
|
||||
this.creating = true;
|
||||
this.createError = null;
|
||||
|
||||
// Load platforms if not yet loaded
|
||||
if (this.availablePlatforms.length === 0) {
|
||||
try {
|
||||
this.availablePlatforms = await apiClient.get('/merchants/account/platforms');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post('/merchants/account/stores', this.createForm);
|
||||
this.showCreateModal = false;
|
||||
this.createForm = { name: '', store_code: '', subdomain: '', description: '', platform_ids: [] };
|
||||
this.successMessage = 'Store created successfully! It is pending admin verification.';
|
||||
setTimeout(() => { this.successMessage = null; }, 5000);
|
||||
await this.loadStores();
|
||||
} catch (err) {
|
||||
this.createError = err.message || 'Failed to create store.';
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
140
app/modules/tenancy/templates/tenancy/merchant/team.html
Normal file
140
app/modules/tenancy/templates/tenancy/merchant/team.html
Normal file
@@ -0,0 +1,140 @@
|
||||
{# app/modules/tenancy/templates/tenancy/merchant/team.html #}
|
||||
{% extends "merchant/base.html" %}
|
||||
|
||||
{% block title %}{{ _("tenancy.team.title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="merchantTeam()">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("tenancy.team.title") }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("tenancy.team.members") }}
|
||||
<span x-show="data" class="font-medium" x-text="`(${data?.total_members || 0})`"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ _("common.loading") }}
|
||||
</div>
|
||||
|
||||
<!-- Store Teams -->
|
||||
<div x-show="!loading && data" x-cloak class="space-y-6">
|
||||
<template x-for="store in data?.stores || []" :key="store.store_id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<!-- Store Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span x-html="$icon('shopping-bag', 'w-5 h-5 text-gray-400')"></span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="store.store_name"></h3>
|
||||
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="store.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="store.is_active ? '{{ _("common.active") }}' : '{{ _("common.inactive") }}'">
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="`${store.member_count} {{ _("tenancy.team.members").toLowerCase() }}`"></span>
|
||||
<a :href="`/store/${store.store_code}/team`"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 rounded-md hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors">
|
||||
<span x-html="$icon('external-link', 'w-3.5 h-3.5 mr-1')"></span>
|
||||
{{ _("common.view") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<!-- Owner Row -->
|
||||
<div class="px-6 py-3 flex items-center gap-4 bg-gray-50/50 dark:bg-gray-700/30">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center flex-shrink-0">
|
||||
<span x-html="$icon('shield-check', 'w-4 h-4 text-indigo-600 dark:text-indigo-400')"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="data?.owner_email || '{{ _("tenancy.team.owner") }}'"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 rounded-full">{{ _("tenancy.team.owner") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Team Members -->
|
||||
<template x-for="member in store.members" :key="member.id">
|
||||
<div class="px-6 py-3 flex items-center gap-4">
|
||||
<div class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||
<span x-html="$icon('user', 'w-4 h-4 text-gray-500 dark:text-gray-400')"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<span x-text="member.first_name || ''"></span>
|
||||
<span x-text="member.last_name || ''"></span>
|
||||
<span x-show="!member.first_name && !member.last_name" x-text="member.email"></span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="member.email"
|
||||
x-show="member.first_name || member.last_name"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="member.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'"
|
||||
x-text="member.is_active ? (member.role_name || '{{ _("tenancy.team.members") }}') : '{{ _("common.pending") }}'">
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template x-if="store.members.length === 0">
|
||||
<div class="px-6 py-6 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ _("tenancy.team.title") }} - {{ _("common.none") }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State: No Stores -->
|
||||
<template x-if="data && data.stores.length === 0">
|
||||
<div class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<span x-html="$icon('user-group', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ _("common.not_available") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function merchantTeam() {
|
||||
return {
|
||||
loading: true,
|
||||
error: null,
|
||||
data: null,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.data = await apiClient.get('/merchants/tenancy/account/team');
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Failed to load team data';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -40,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Store Portal Login
|
||||
{{ _("auth.store_login") }}
|
||||
</h1>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
@@ -54,42 +54,63 @@
|
||||
|
||||
<!-- Login Form (only show if store found) -->
|
||||
<template x-if="store">
|
||||
<form @submit.prevent="handleLogin">
|
||||
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Username</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.username") }}</span>
|
||||
<input x-model="credentials.username"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.username }"
|
||||
placeholder="Enter your username"
|
||||
placeholder="{{ _('auth.username_placeholder') }}"
|
||||
autocomplete="username"
|
||||
required />
|
||||
<span x-show="errors.username" x-text="errors.username"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="***************"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
|
||||
<div class="relative" x-data="{ showPw: false }">
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
:type="showPw ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="{{ _('auth.password_placeholder') }}"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPw = !showPw"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPw ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox"
|
||||
x-model="rememberMe"
|
||||
class="form-checkbox text-purple-600 border-gray-300 rounded focus:ring-purple-500 focus:outline-none">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
|
||||
</label>
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
{{ _("auth.forgot_password") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Sign in</span>
|
||||
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Signing in...
|
||||
{{ _("auth.signing_in") }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
@@ -117,49 +138,64 @@
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading store information...</p>
|
||||
</div>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<div x-show="showForgotPassword" x-transition>
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
<div x-show="store && showForgotPassword" x-cloak x-transition class="mt-6">
|
||||
<hr class="mb-6" />
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _("auth.reset_password") }}</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">{{ _("auth.reset_password_desc") }}</p>
|
||||
<form @submit.prevent="handleForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
|
||||
<input x-model="forgotPasswordEmail"
|
||||
:disabled="forgotPasswordLoading"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
placeholder="you@example.com"
|
||||
type="email"
|
||||
required />
|
||||
</label>
|
||||
<button type="submit" :disabled="forgotPasswordLoading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!forgotPasswordLoading">Send Reset Link</span>
|
||||
<span x-show="forgotPasswordLoading">Sending...</span>
|
||||
<span x-show="!forgotPasswordLoading">{{ _("auth.send_reset_link") }}</span>
|
||||
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = false"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
← Back to Login
|
||||
← {{ _("auth.back_to_login") }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!showForgotPassword">
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← Back to Platform
|
||||
</a>
|
||||
</p>
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
|
||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
|
||||
href="/">
|
||||
{{ _("auth.visit_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← {{ _("auth.back_to_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center justify-center gap-2 mt-6"
|
||||
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
|
||||
:class="currentLang === lang
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
x-text="lang.toUpperCase()"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
{{ tab_button('branding', 'Branding', tab_var='activeSection', icon='color-swatch') }}
|
||||
{{ tab_button('email', 'Email', tab_var='activeSection', icon='mail') }}
|
||||
{{ tab_button('domains', 'Domains', tab_var='activeSection', icon='globe-alt') }}
|
||||
{{ tab_button('api', 'API', tab_var='activeSection', icon='key') }}
|
||||
{{ tab_button('notifications', 'Notifications', tab_var='activeSection', icon='bell') }}
|
||||
{% endcall %}
|
||||
|
||||
@@ -1274,79 +1273,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API & Payments Settings -->
|
||||
<div x-show="activeSection === 'api'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">API & Payments</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Payment integrations and API access</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-6">
|
||||
<!-- Stripe Integration -->
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||
<span x-html="$icon('credit-card', 'w-6 h-6 text-purple-600 dark:text-purple-400')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300">Stripe</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Payment processing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="settings?.stripe_info?.has_stripe_customer">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">Connected</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Customer ID</label>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="settings?.stripe_info?.customer_id_masked"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!settings?.stripe_info?.has_stripe_customer">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5 text-gray-400 dark:text-gray-500')"></span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Not connected</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Letzshop API (if credentials exist) -->
|
||||
<template x-if="settings?.letzshop?.has_credentials">
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-orange-100 dark:bg-orange-900/30 rounded-lg flex items-center justify-center">
|
||||
<span x-html="$icon('shopping-cart', 'w-6 h-6 text-orange-600 dark:text-orange-400')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300">Letzshop API</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Marketplace integration</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">Credentials configured</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-start gap-2">
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5')"></span>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
API keys and payment credentials are managed securely. Contact support for changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Settings -->
|
||||
<div x-show="activeSection === 'notifications'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
|
||||
Reference in New Issue
Block a user