feat(account): add editable phone and password change with validation
- Add editable phone field in /account General tab with UK phone validation - Create PUT /api/user/change-password endpoint in backend - Add zxcvbn password strength meter to change password modal - Add "passwords don't match" validation message to both /account and /register - Fix navbar logout reactivity with invalidateAll and $derived values - Fix a11y warnings: add labels, roles, and keyboard handlers - Remove unused CSS from account page
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
@@ -486,3 +487,69 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req ChangePasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||
http.Error(w, "current password and new password are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 8 {
|
||||
http.Error(w, "password must be at least 8 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) > 72 {
|
||||
http.Error(w, "password must be less than 72 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var passwordHash string
|
||||
err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to fetch password hash for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)); err != nil {
|
||||
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("Failed to hash new password for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(r.Context(), `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, string(newHash), userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update password for user %s: %v", userID, err)
|
||||
http.Error(w, "failed to update password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ func main() {
|
||||
|
||||
r.Get("/user/profile", user.GetProfileHandler)
|
||||
r.Put("/user/profile", user.UpdateProfileHandler)
|
||||
r.Put("/user/change-password", user.ChangePasswordHandler)
|
||||
r.Delete("/user/account", user.DeleteAccountHandler)
|
||||
r.Get("/user/loyalty", user.GetLoyaltyHandler)
|
||||
|
||||
|
||||
@@ -416,27 +416,14 @@
|
||||
</AlertDialog.Root>
|
||||
|
||||
<style>
|
||||
/* Hide number input arrows for Chrome, Safari, Edge */
|
||||
input.no-spin::-webkit-outer-spin-button,
|
||||
input.no-spin::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Hide number input arrows for Firefox */
|
||||
input.no-spin[type='number'] {
|
||||
/* Hide number input arrows for all number inputs in the component */
|
||||
:global(input[type='number']) {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
/* Remove arrows from all number inputs in the component */
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
input[type='number']::-webkit-outer-spin-button,
|
||||
input[type='number']::-webkit-inner-spin-button {
|
||||
:global(input[type='number']::-webkit-outer-spin-button),
|
||||
:global(input[type='number']::-webkit-inner-spin-button) {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700">Tags</label>
|
||||
<label for="tag-input" class="text-sm font-medium text-gray-700">Tags</label>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
@@ -494,6 +494,9 @@
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'hover:bg-gray-100'}"
|
||||
onclick={() => selectSuggestion(s)}
|
||||
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectSuggestion(s)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{s}
|
||||
</div>
|
||||
|
||||
@@ -16,19 +16,21 @@
|
||||
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' }
|
||||
];
|
||||
|
||||
let mobileMenuOpen: boolean = false;
|
||||
let mobileMenuOpen = $state(false);
|
||||
function toggleMenu() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
|
||||
// Close mobile menu when navigation starts
|
||||
$: if ($navigating) {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
$effect(() => {
|
||||
if ($navigating) {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to decide visibility
|
||||
// Helper to decide visibility - use $derived for reactivity
|
||||
const canShow = (link: { href: string; label: string; showWhen: string; width: string }) => {
|
||||
if (authStore.isLoading) return true; // keep skeleton placeholders
|
||||
if (authStore.isLoading) return true;
|
||||
|
||||
// hide booking link for admins
|
||||
if (link.href === '/book' && authStore.currentUser?.role === 'admin') return false;
|
||||
@@ -40,6 +42,11 @@
|
||||
if (link.showWhen === 'admin' && authStore.currentUser?.role === 'admin') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Derived values for auth state - ensures reactivity
|
||||
let isAuthenticated = $derived(authStore.isAuthenticated);
|
||||
let currentUserRole = $derived(authStore.currentUser?.role);
|
||||
let isLoading = $derived(authStore.isLoading);
|
||||
</script>
|
||||
|
||||
<nav
|
||||
@@ -80,7 +87,7 @@
|
||||
<div class="hidden space-x-8 md:flex">
|
||||
{#each links as link}
|
||||
{#if canShow(link)}
|
||||
{#if authStore.isLoading}
|
||||
{#if isLoading}
|
||||
<Skeleton class={`h-4 ${link.width} rounded`} />
|
||||
{:else}
|
||||
<a href={link.href} class="font-medium text-gray-800 hover:text-primary"
|
||||
@@ -93,17 +100,17 @@
|
||||
|
||||
<!-- Desktop Login Button -->
|
||||
<div class="hidden items-center md:flex">
|
||||
{#if !authStore.isLoading && !authStore.isAuthenticated && $page.url.pathname !== '/login'}
|
||||
{#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'}
|
||||
<Button href="/login">Login</Button>
|
||||
{/if}
|
||||
{#if authStore.isLoading}
|
||||
{#if isLoading}
|
||||
<Skeleton class="h-8 w-16 rounded" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Burger -->
|
||||
<div class="flex items-center md:hidden">
|
||||
<button on:click={toggleMenu} class="focus:outline-none" aria-label="Toggle menu">
|
||||
<button onclick={toggleMenu} class="focus:outline-none" aria-label="Toggle menu">
|
||||
<svg class="h-6 w-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
@@ -123,7 +130,7 @@
|
||||
<div class="space-y-1 px-2 pt-2 pb-3">
|
||||
{#each links as link}
|
||||
{#if canShow(link)}
|
||||
{#if authStore.isLoading}
|
||||
{#if isLoading}
|
||||
<Skeleton class="h-4 w-full rounded" />
|
||||
{:else}
|
||||
<a
|
||||
@@ -136,9 +143,9 @@
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if authStore.isLoading}
|
||||
{#if isLoading}
|
||||
<Skeleton class="mt-2 h-8 w-full rounded" />
|
||||
{:else if !authStore.isAuthenticated}
|
||||
{:else if !isAuthenticated}
|
||||
<Button href="/login" class="mt-2 w-full text-center">Login</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@ class AuthStore {
|
||||
// inside AuthStore
|
||||
logout = () => {
|
||||
this.clearAuth();
|
||||
goto('/');
|
||||
goto('/', { invalidateAll: true });
|
||||
};
|
||||
|
||||
private clearAuth() {
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||
|
||||
// zxcvbn-ts imports
|
||||
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
|
||||
import * as languageCommon from '@zxcvbn-ts/language-common';
|
||||
import * as languageEn from '@zxcvbn-ts/language-en';
|
||||
|
||||
// set up options so that feedback, dictionary etc. are included
|
||||
zxcvbnOptions.setOptions({
|
||||
translations: languageEn.translations,
|
||||
graphs: languageCommon.adjacencyGraphs,
|
||||
dictionary: {
|
||||
...languageCommon.dictionary,
|
||||
...languageEn.dictionary
|
||||
}
|
||||
});
|
||||
|
||||
// shadcn-svelte components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -66,6 +81,102 @@
|
||||
let loadingUser = $state(true);
|
||||
let stamps = $state(0);
|
||||
|
||||
// =============== Phone Edit Mode ===============
|
||||
let editingPhone = $state(false);
|
||||
let phoneInput = $state('');
|
||||
let phoneError = $state('');
|
||||
let savingPhone = $state(false);
|
||||
|
||||
// Phone validation (UK format)
|
||||
function validatePhone(phone: string): boolean {
|
||||
if (!phone) {
|
||||
phoneError = '';
|
||||
return true;
|
||||
}
|
||||
const cleanPhone = phone.replace(/[\s\-()]/g, '');
|
||||
// UK phone regex: +44 followed by 10-11 digits, or 0 followed by 10-11 digits
|
||||
const phoneRegex = /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/;
|
||||
const isValid = phoneRegex.test(cleanPhone);
|
||||
phoneError = isValid ? '' : 'Invalid UK phone number';
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Format phone number as user types
|
||||
function formatPhoneInput(value: string): string {
|
||||
// Remove all non-digits except +
|
||||
const digits = value.replace(/[^\d+]/g, '');
|
||||
// Format UK numbers
|
||||
if (digits.startsWith('+44')) {
|
||||
return digits; // Keep +44 as is
|
||||
}
|
||||
if (digits.startsWith('44')) {
|
||||
return '+' + digits;
|
||||
}
|
||||
if (digits.startsWith('0')) {
|
||||
// Format 07xx xxx xxxx
|
||||
if (digits.length <= 5) {
|
||||
return digits;
|
||||
}
|
||||
if (digits.length <= 10) {
|
||||
return digits.slice(0, 5) + ' ' + digits.slice(5);
|
||||
}
|
||||
return digits.slice(0, 5) + ' ' + digits.slice(5, 10) + ' ' + digits.slice(10, 12);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
function startEditPhone() {
|
||||
phoneInput = userData?.phone || '';
|
||||
phoneError = '';
|
||||
editingPhone = true;
|
||||
}
|
||||
|
||||
function cancelEditPhone() {
|
||||
editingPhone = false;
|
||||
phoneInput = '';
|
||||
phoneError = '';
|
||||
}
|
||||
|
||||
async function savePhone() {
|
||||
const formattedPhone = phoneInput.replace(/[\s\-()]/g, '');
|
||||
if (!validatePhone(formattedPhone)) {
|
||||
return;
|
||||
}
|
||||
|
||||
savingPhone = true;
|
||||
const loadingToast = toast.loading('Updating phone number...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
firstName: userData?.firstName,
|
||||
lastName: userData?.lastName,
|
||||
phone: formattedPhone
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Phone number updated successfully!', { id: loadingToast });
|
||||
editingPhone = false;
|
||||
// Refresh user data
|
||||
await fetchUserData();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error(text || 'Failed to update phone number', { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating phone:', err);
|
||||
toast.error('Network error', { id: loadingToast });
|
||||
} finally {
|
||||
savingPhone = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Fetch User Data ===============
|
||||
async function fetchUserData() {
|
||||
if (pageState !== 'authorized') return;
|
||||
@@ -232,6 +343,11 @@
|
||||
});
|
||||
let changingPassword = $state(false);
|
||||
|
||||
// Password strength using zxcvbn
|
||||
let newPasswordStrength = $derived(passwordData.new ? zxcvbn(passwordData.new) : null);
|
||||
let isPasswordStrongEnough = $derived(!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2);
|
||||
let passwordsMatch = $derived(passwordData.confirm === '' || passwordData.new === passwordData.confirm);
|
||||
|
||||
async function changePassword() {
|
||||
if (passwordData.new !== passwordData.confirm) {
|
||||
toast.error('New passwords do not match');
|
||||
@@ -243,6 +359,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPasswordStrongEnough) {
|
||||
toast.error('Please choose a stronger password');
|
||||
return;
|
||||
}
|
||||
|
||||
changingPassword = true;
|
||||
const loadingToast = toast.loading('Changing password...');
|
||||
|
||||
@@ -493,28 +614,58 @@
|
||||
{:else if userData}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-600">First Name</label>
|
||||
<span class="text-sm font-medium text-gray-600">First Name</span>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
{userData.firstName}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-600">Last Name</label>
|
||||
<span class="text-sm font-medium text-gray-600">Last Name</span>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
{userData.lastName}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-600">Email</label>
|
||||
<span class="text-sm font-medium text-gray-600">Email</span>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
{userData.email}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-600">Phone</label>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
{userData.phone || '—'}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-600">Phone</span>
|
||||
{#if editingPhone}
|
||||
<div class="mt-1 space-y-2">
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
bind:value={phoneInput}
|
||||
placeholder="Enter phone number"
|
||||
class="font-medium"
|
||||
/>
|
||||
{#if phoneError}
|
||||
<p class="text-sm text-red-500">{phoneError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={savePhone} disabled={savingPhone}>
|
||||
{savingPhone ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onclick={cancelEditPhone} disabled={savingPhone}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
<span>{userData.phone || '—'}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditPhone}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -945,6 +1096,32 @@
|
||||
placeholder="Enter new password"
|
||||
class="mt-1"
|
||||
/>
|
||||
{#if passwordData.new && newPasswordStrength}
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="flex h-1.5 w-full overflow-hidden rounded bg-gray-200">
|
||||
<div
|
||||
class="transition-all duration-300"
|
||||
style="width: {(newPasswordStrength.score + 1) * 20}%; background-color: {newPasswordStrength.score < 2
|
||||
? '#ef4444'
|
||||
: newPasswordStrength.score === 2
|
||||
? '#f59e0b'
|
||||
: newPasswordStrength.score === 3
|
||||
? '#22c55e'
|
||||
: '#15803d'}"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs {newPasswordStrength.score < 2 ? 'text-red-500' : newPasswordStrength.score === 2 ? 'text-amber-500' : 'text-green-600'}">
|
||||
{newPasswordStrength.feedback.warning
|
||||
? newPasswordStrength.feedback.warning
|
||||
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][newPasswordStrength.score]}`}
|
||||
</p>
|
||||
{#if newPasswordStrength.feedback.suggestions.length > 0}
|
||||
<p class="text-xs text-gray-500">
|
||||
{newPasswordStrength.feedback.suggestions[0]}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="text-sm font-medium">Confirm New Password</label>
|
||||
@@ -955,6 +1132,9 @@
|
||||
placeholder="Confirm new password"
|
||||
class="mt-1"
|
||||
/>
|
||||
{#if !passwordsMatch}
|
||||
<p class="mt-1 text-xs text-red-500">Passwords do not match</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end gap-2">
|
||||
@@ -967,7 +1147,7 @@
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={changePassword} disabled={changingPassword}>
|
||||
<Button onclick={changePassword} disabled={changingPassword || !isPasswordStrongEnough}>
|
||||
{changingPassword ? 'Changing...' : 'Change Password'}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
@@ -1047,105 +1227,4 @@
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
font-size: 1.5em;
|
||||
padding: 0 2.85em;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bgColorMenu);
|
||||
}
|
||||
|
||||
.menu__item {
|
||||
all: unset;
|
||||
flex-grow: 1;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
align-items: center;
|
||||
will-change: transform;
|
||||
justify-content: center;
|
||||
padding: 0.55em 0 0.85em;
|
||||
transition: transform var(--duration);
|
||||
}
|
||||
|
||||
.menu__item::before {
|
||||
content: '';
|
||||
z-index: -1;
|
||||
width: 4.2em;
|
||||
height: 4.2em;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
transform: scale(0);
|
||||
transition:
|
||||
background-color var(--duration),
|
||||
transform var(--duration);
|
||||
}
|
||||
|
||||
.menu__item.active {
|
||||
transform: translate3d(0, -0.8em, 0);
|
||||
}
|
||||
|
||||
.menu__item.active::before {
|
||||
transform: scale(1);
|
||||
background-color: var(--bgColorItem);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 2.6em;
|
||||
height: 2.6em;
|
||||
stroke: white;
|
||||
fill: transparent;
|
||||
stroke-width: 1pt;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 400;
|
||||
}
|
||||
|
||||
@keyframes strok {
|
||||
100% {
|
||||
stroke-dashoffset: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.menu__border {
|
||||
left: 0;
|
||||
bottom: 99%;
|
||||
width: 10.9em;
|
||||
height: 2.4em;
|
||||
position: absolute;
|
||||
clip-path: url(#menu);
|
||||
will-change: transform;
|
||||
background-color: var(--bgColorMenu);
|
||||
transition: transform var(--duration);
|
||||
}
|
||||
|
||||
.svg-container {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 50em) {
|
||||
.menu {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user