Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ Related metadata files:
This agent is intended for frontend-facing polish work only. It should improve usability, responsiveness, component presentation, modal interactions, mini cart behavior, and CSS consistency without changing backend APIs, database schema, business logic, or unrelated application behavior.

When working in this project, the agent should prefer existing UI patterns, reusable components, Bootstrap utilities, and established SORA/ThinkHub styling before introducing new abstractions or visual systems.

## Strict Security & Destructive Command Rules

- **Git Commands:** The agent MUST NOT automatically run any `git` commands (especially `git push`, `git reset`, `git clean`) without explicit step-by-step confirmation from the user. Committing and pushing must ALWAYS be asked for before execution.
- **MySQL/Database Commands:** The agent MUST NOT automatically run any destructive database commands (e.g., `DROP`, `DELETE`, `TRUNCATE`, `rm` on database files) under any circumstances.
- **File Deletion:** The agent MUST NOT run destructive terminal commands like `rm -rf` without explicit user consent. Do not run any command that deletes critical system or database files.
128 changes: 127 additions & 1 deletion frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,53 @@

<div v-else>
<router-view></router-view>

<QuickAddModal />

<!-- Overlay Loading Xác thực Google -->
<div v-if="isGoogleAuthenticating" class="google-auth-overlay">
<div class="google-auth-box">
<svg class="shadcn-spinner" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="2" x2="12" y2="6" opacity="1"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(30 12 12)" opacity="0.916"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(60 12 12)" opacity="0.833"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(90 12 12)" opacity="0.75"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(120 12 12)" opacity="0.666"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(150 12 12)" opacity="0.583"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(180 12 12)" opacity="0.5"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(210 12 12)" opacity="0.416"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(240 12 12)" opacity="0.333"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(270 12 12)" opacity="0.25"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(300 12 12)" opacity="0.166"></line>
<line x1="12" y1="2" x2="12" y2="6" transform="rotate(330 12 12)" opacity="0.083"></line>
</svg>
<p class="google-auth-text">{{ googleAuthStatus }}</p>
</div>
</div>
</div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, provide, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useQueryClient } from '@tanstack/vue-query';
import apiClient from '@/utils/apiClient';
import clientApiClient from '@/utils/clientApiClient';
import { useAdminRefreshListener } from '@/composables/useAdminRealtime.js';
import { useRealtimeSync } from '@/composables/useRealtimeSync.js';
import { useAuthSync } from '@/composables/useAuthSync.js';

// Nhúng modal global vào App
import QuickAddModal from '@/components/ui/QuickAddModal.vue';

const router = useRouter();
const route = useRoute();
const queryClient = useQueryClient();
const isCheckingAuth = ref(true);
const currentUser = ref(null);
const { syncAfterLogin } = useAuthSync();

const isGoogleAuthenticating = ref(false);
const googleAuthStatus = ref('Đang đồng bộ dữ liệu...');

provide('currentUser', currentUser);

Expand All @@ -35,6 +63,41 @@ watch(
},
{ immediate: true }
);

// ===== XỬ LÝ GOOGLE AUTH OVERLAY =====
watch(
() => route.fullPath,
async () => {
const isPendingSync = localStorage.getItem('pending_google_sync');
if (isPendingSync === 'true') {
localStorage.removeItem('pending_google_sync');

// Bật overlay loading
isGoogleAuthenticating.value = true;
googleAuthStatus.value = 'Đang đồng bộ tài khoản...';

try {
const token = localStorage.getItem('auth_token');
if (!token) throw new Error('Token missing');
const response = await clientApiClient.get('/user');
localStorage.setItem('userData', JSON.stringify(response.data));

await syncAfterLogin(queryClient);

googleAuthStatus.value = 'Thành công!';
setTimeout(() => {
isGoogleAuthenticating.value = false;
}, 500);

} catch (err) {
localStorage.removeItem('auth_token');
isGoogleAuthenticating.value = false;
router.push({ path: '/login', query: { error: 'google_auth_failed' } });
}
}
},
{ immediate: true }
);
// ===== KẾT THÚC: LOGIC LƯU MÃ AFFILIATE TỰ ĐỘNG =====

const checkAuthentication = async () => {
Expand Down Expand Up @@ -172,6 +235,22 @@ input::-webkit-credentials-auto-fill-button {
text-decoration: none;
}

.sora-popup-gradient {
background: linear-gradient(135deg, #ffffff 0%, #fdfbf7 50%, #fae8eb 100%) !important;
border: 1px solid rgba(159, 39, 59, 0.1) !important;
}

.text-gold-gradient {
background: linear-gradient(135deg, #e7ce7d 0%, #f9f0d1 50%, #d4af37 100%) !important;
-webkit-background-clip: text !important;
background-clip: text !important;
color: transparent !important;
text-shadow: 0 2px 10px rgba(231, 206, 125, 0.2);
padding-top: 0.2em;
padding-bottom: 0.1em;
line-height: 1.4 !important;
}

.editorial-btn::after {
content: '';
position: absolute;
Expand Down Expand Up @@ -236,4 +315,51 @@ input::-webkit-credentials-auto-fill-button {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
}

/* Modal Google Auth Overlay */
.google-auth-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background-color: rgba(255, 255, 255, 0.7); /* Bán trong suốt */
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
}

.google-auth-box {
background: white;
padding: 30px 40px;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}

.google-auth-box .shadcn-spinner {
width: 50px;
height: 50px;
color: var(--sora-primary);
animation: spin-auth 1s steps(12) infinite;
margin-bottom: 20px;
}

.google-auth-text {
color: var(--sora-primary);
font-family: 'Josefin Sans', sans-serif;
font-size: 18px;
font-weight: bold;
margin: 0;
}

@keyframes spin-auth {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
29 changes: 16 additions & 13 deletions frontend/src/components/ui/ProductCard.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="luxury-related-card bg-white d-flex flex-column group position-relative overflow-hidden border border-light-subtle h-100">
<div class="luxury-related-card d-flex flex-column group position-relative overflow-hidden h-100" style="background-color: #ffffff !important; border: 1px solid #eaeaea; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.02); transition: all 0.35s ease;">

<div class="position-relative bg-light text-center border-bottom border-light-subtle sora-img-container" :class="{'has-hover-image': showHoverImage && hasHoverImage(product)}">
<div class="position-relative bg-white text-center border-bottom sora-img-container" style="border-color: #f8f9fa !important;" :class="{'has-hover-image': showHoverImage && hasHoverImage(product)}">

<!-- Compare Button -->
<button
Expand Down Expand Up @@ -65,16 +65,16 @@
<div class="theme-bar position-absolute bottom-0 start-0 bg-sora-primary z-index-2"></div>
</div>

<div class="position-relative flex-grow-1 bg-white d-flex flex-column">
<div class="position-relative flex-grow-1 d-flex flex-column" style="background-color: #ffffff !important;">
<div class="p-4 text-start d-flex flex-column flex-grow-1 product-card-body">
<router-link
:to="{ name: 'productDetail', params: { shop_slug: shopSlug, slug: product.slug } }"
class="text-decoration-none flex-grow-1 d-flex flex-column justify-content-center"
>
<h6 class="text-dark font-oswald text-uppercase tracking-widest fw-bold mb-2 text-truncate-2 fs-5 lh-base product-name">{{ product.name }}</h6>
<h6 class="text-dark font-oswald text-uppercase fw-normal mb-2 text-truncate-2 product-name" style="font-size: 1.05rem; letter-spacing: 1px; line-height: 1.4;">{{ product.name }}</h6>

<div class="d-flex justify-content-between align-items-center mb-2">
<p class="font-serif fst-italic text-muted small mb-0">{{ product.category?.name || 'Trang sức SORA' }}</p>
<p class="font-oswald text-secondary small fw-light mb-0" style="letter-spacing: 0.8px; font-size: 0.75rem; opacity: 0.7;">{{ product.category?.name || 'TRANG SỨC SORA' }}</p>

<!-- Luxury constraint: Show only one badge (Out of Stock > Scarcity > Sold Count > In Stock) -->
<span v-if="isOutOfStock" class="small font-oswald text-uppercase tracking-widest text-secondary" style="font-size: 0.7rem; opacity: 0.8;">
Expand All @@ -101,12 +101,12 @@
<div class="d-flex align-items-center justify-content-between">
<template v-if="priceInfo.isRange">
<div class="d-flex align-items-baseline gap-2 flex-wrap w-100">
<span class="text-sora-primary fw-bold font-oswald fs-5 text-truncate" :title="`${formatCurrency(priceInfo.min)} - ${formatCurrency(priceInfo.max)}`" style="font-size: 1.1rem !important;">{{ formatCompactPrice(priceInfo.min) }} - {{ formatCompactPrice(priceInfo.max) }}</span>
<span class="text-main fw-normal font-oswald text-truncate" :title="`${formatCurrency(priceInfo.min)} - ${formatCurrency(priceInfo.max)}`" style="font-size: 1.15rem; letter-spacing: 0.5px;">{{ formatCompactPrice(priceInfo.min) }} - {{ formatCompactPrice(priceInfo.max) }}</span>
</div>
</template>
<template v-else>
<div class="d-flex align-items-baseline gap-2 flex-wrap">
<span class="text-sora-primary fw-bold font-oswald fs-5 product-price">{{ formatCurrency(priceInfo.price) }}</span>
<span class="text-main fw-normal font-oswald product-price" style="font-size: 1.15rem; letter-spacing: 0.5px;">{{ formatCurrency(priceInfo.price) }}</span>
<span v-if="priceInfo.oldPrice" class="text-muted text-decoration-line-through small fw-light font-oswald product-old-price" style="font-size: 0.85rem;">
{{ formatCurrency(priceInfo.oldPrice) }}
</span>
Expand All @@ -124,7 +124,8 @@
<button
type="button"
@click.stop="isOutOfStock ? null : handleQuickAddClick()"
class="btn w-100 rounded-0 py-3 font-oswald tracking-widest text-uppercase fw-bold shadow-none fs-6 d-flex align-items-center justify-content-center btn-add-cart"
class="btn w-100 rounded-0 font-oswald tracking-widest text-uppercase fw-light shadow-none d-flex align-items-center justify-content-center btn-add-cart"
style="padding: 14px 0; font-size: 0.85rem; border: none;"
:class="isOutOfStock ? 'luxury-btn-sold-out' : 'luxury-btn-solid'"
:disabled="isOutOfStock"
:style="isOutOfStock ? 'cursor: not-allowed;' : ''"
Expand Down Expand Up @@ -353,15 +354,17 @@ const hasHoverImage = (product) => {

.luxury-btn-solid {
background-color: #9f273b;
color: white;
border: 1px solid #9f273b;
color: #ffffff;
border-top: none;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.luxury-btn-solid:hover {
background-color: #cc1e2e;
border-color: #cc1e2e;
color: white;
box-shadow: 0 8px 20px rgba(204,30,46,0.3);
color: #ffffff;
box-shadow: 0 -4px 15px rgba(204,30,46,0.15);
}
.luxury-btn-solid i {
color: #ffffff !important;
}

.luxury-btn-sold-out {
Expand Down
15 changes: 11 additions & 4 deletions frontend/src/components/ui/ProfileSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,12 @@

<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useQueryClient } from '@tanstack/vue-query';
import { getStorageUrl } from '@/utils/env';
import Swal from 'sweetalert2';
import clientApiClient from '@/utils/clientApiClient';
import { useAuthSync } from '@/composables/useAuthSync.js';

const props = defineProps({
/** Cho phép truyền thẳng user data từ parent */
Expand All @@ -176,6 +179,11 @@ const props = defineProps({
const emit = defineEmits(['navigate', 'logout']);

const route = useRoute();
const router = useRouter();
const queryClient = useQueryClient();
const { clearAuthSession } = useAuthSync();

const isPreviewMode = computed(() => route.path.startsWith('/admin'));

// ===== USER DATA =====
const userData = ref({
Expand Down Expand Up @@ -316,9 +324,8 @@ const isActive = (path, tab = null) => {
// ===== LOGOUT =====
const handleLogout = () => {
emit('logout');
localStorage.clear();
sessionStorage.clear();
window.location.href = '/login';
clearAuthSession(queryClient);
router.push({ name: 'login' });
};

// ===== FETCH PROFILE =====
Expand Down
Loading