fix: pre-launch review — security, money safety, privacy, legal, code quality
Security (P0): - IsJTIRevoked fails closed on DB error (previously accepted revoked tokens) - Remove dead consume parameter from SCA gate (prevented token replay) - Rate limiter map TTL-based eviction (prevented memory exhaustion) - 2FA attempt map already had LRU eviction (verified) Money Safety (P1): - Gift card transfer refuses expired destination cards - Gift card balance deduction has WHERE balance >= amount guard - Webhook clawback acquires till-sale advisory lock - Sweep/retry lock keys aligned Privacy/Cookies (P2): - Self-host Google Fonts (Playfair Display woff2) - Replace CARTO map tiles with OpenStreetMap raster tiles - Replace Wikimedia/icon-icons external images with local SVGs - Remove external image URLs from CSP Legal (P3): - Privacy policy: add 6 missing data categories (gift cards, 2FA, GDPR, notifications, technical, cookies) - Terms: add Tips section (optionality, non-refundable, same processing as bookings) Code Quality (P4): - twofa.Check accepts db.Querier for testability - depositPromotionMinPct uses literal 0.20 (not misleading alias) - HolidayHours.svelte uses proper type (not as any[]) - Remove stale TODO comments from main.go Testing (P5): - 94 new float64 money validity tests across 3 test files - Cover VAT, splits, refunds, gift cards, rounding, precision boundaries - All 27 backend test packages pass
@@ -23,7 +23,8 @@ const (
|
|||||||
// deposit REQUIRED at booking time, used by bookings.go): the promotion
|
// deposit REQUIRED at booking time, used by bookings.go): the promotion
|
||||||
// threshold is about already-paid money, not the amount to demand up front,
|
// threshold is about already-paid money, not the amount to demand up front,
|
||||||
// even though both are 20% today.
|
// even though both are 20% today.
|
||||||
depositPromotionMinPct = RequiredDepositPct
|
// Currently equals RequiredDepositPct, but intentionally independent for future divergence.
|
||||||
|
depositPromotionMinPct = 0.20
|
||||||
|
|
||||||
LoyaltyStampCost = 10
|
LoyaltyStampCost = 10
|
||||||
LoyaltyDiscountPercent = 10.0
|
LoyaltyDiscountPercent = 10.0
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ const (
|
|||||||
// flows clear the pending code themselves on success (enableTwoFA /
|
// flows clear the pending code themselves on success (enableTwoFA /
|
||||||
// disableTwoFA), so the code must stay valid through the whole handshake here.
|
// disableTwoFA), so the code must stay valid through the whole handshake here.
|
||||||
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
|
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
|
||||||
res, err := twofa.Check(r.Context(), userID, st, reqCode, false)
|
res, err := twofa.Check(r.Context(), db.Conn, userID, st, reqCode, false)
|
||||||
return twoFACodeCheckResult(res), err
|
return twoFACodeCheckResult(res), err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +378,7 @@ func VerifyTwoFACodeForUser(ctx context.Context, userID, code string) error {
|
|||||||
st.Mu.Lock()
|
st.Mu.Lock()
|
||||||
defer st.Mu.Unlock()
|
defer st.Mu.Unlock()
|
||||||
|
|
||||||
result, err := twofa.Check(ctx, userID, st, code, false)
|
result, err := twofa.Check(ctx, db.Conn, userID, st, code, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ const (
|
|||||||
// error is non-nil only for DB failures (callers return 500); a lockout's
|
// error is non-nil only for DB failures (callers return 500); a lockout's
|
||||||
// pending-code invalidation failure is logged here and still reported as a
|
// pending-code invalidation failure is logged here and still reported as a
|
||||||
// lockout.
|
// lockout.
|
||||||
func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
|
func Check(ctx context.Context, q db.Querier, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
|
||||||
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
|
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
|
||||||
st.Count.Store(0)
|
st.Count.Store(0)
|
||||||
st.SetLastActive(now)
|
st.SetLastActive(now)
|
||||||
@@ -384,7 +384,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
|
|||||||
|
|
||||||
var pendingHash sql.NullString
|
var pendingHash sql.NullString
|
||||||
var pendingExpires sql.NullTime
|
var pendingExpires sql.NullTime
|
||||||
err := db.Conn.QueryRow(ctx, `
|
err := q.QueryRow(ctx, `
|
||||||
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
|
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -406,7 +406,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
|
|||||||
if st.Count.Load() >= MaxAttempts {
|
if st.Count.Load() >= MaxAttempts {
|
||||||
// Lockout reached: destroy the pending code so a stolen digest
|
// Lockout reached: destroy the pending code so a stolen digest
|
||||||
// cannot be replayed against a fresh guessing loop.
|
// cannot be replayed against a fresh guessing loop.
|
||||||
if _, err := db.Conn.Exec(ctx, `
|
if _, err := q.Exec(ctx, `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET two_factor_pending_code_hash = NULL,
|
SET two_factor_pending_code_hash = NULL,
|
||||||
two_factor_pending_code_expires = NULL
|
two_factor_pending_code_expires = NULL
|
||||||
@@ -425,7 +425,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
|
|||||||
// code stays valid for the rest of the handshake — consume mode destroys
|
// code stays valid for the rest of the handshake — consume mode destroys
|
||||||
// the digest outright, so there is nothing to upgrade.
|
// the digest outright, so there is nothing to upgrade.
|
||||||
if legacy && !consume {
|
if legacy && !consume {
|
||||||
if _, err := db.Conn.Exec(ctx, `
|
if _, err := q.Exec(ctx, `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET two_factor_pending_code_hash = $2
|
SET two_factor_pending_code_hash = $2
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -453,7 +453,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
|
|||||||
// locked_until) — a successful 2FA challenge is a strong auth signal, and
|
// locked_until) — a successful 2FA challenge is a strong auth signal, and
|
||||||
// the only way to reach a 2FA verify is an already-authenticated session.
|
// the only way to reach a 2FA verify is an already-authenticated session.
|
||||||
// Best-effort: a failure only logs; the verify has already succeeded.
|
// Best-effort: a failure only logs; the verify has already succeeded.
|
||||||
if _, err := db.Conn.Exec(ctx, `
|
if _, err := q.Exec(ctx, `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET failed_attempts = 0, locked_until = NULL
|
SET failed_attempts = 0, locked_until = NULL
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -477,7 +477,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
|
|||||||
// it, but only the first conditional UPDATE can affect a row — the
|
// it, but only the first conditional UPDATE can affect a row — the
|
||||||
// loser sees 0 rows and must fail (MissingOrExpired), so one code
|
// loser sees 0 rows and must fail (MissingOrExpired), so one code
|
||||||
// authorizes exactly ONE operation even across instances.
|
// authorizes exactly ONE operation even across instances.
|
||||||
tag, err := db.Conn.Exec(ctx, `
|
tag, err := q.Exec(ctx, `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET two_factor_pending_code_hash = NULL,
|
SET two_factor_pending_code_hash = NULL,
|
||||||
two_factor_pending_code_expires = NULL
|
two_factor_pending_code_expires = NULL
|
||||||
@@ -590,7 +590,7 @@ func VerifyForUser(ctx context.Context, userID, code string, consume bool) error
|
|||||||
st.Mu.Lock()
|
st.Mu.Lock()
|
||||||
defer st.Mu.Unlock()
|
defer st.Mu.Unlock()
|
||||||
|
|
||||||
result, err := Check(ctx, userID, st, code, consume)
|
result, err := Check(ctx, db.Conn, userID, st, code, consume)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("2FA verify: %w", err)
|
return fmt.Errorf("2FA verify: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func TestVerifyForUser_DBAtomicConsume_Concurrent(t *testing.T) {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
st := &AttemptState{}
|
st := &AttemptState{}
|
||||||
st.SetLastActive(clock.Now())
|
st.SetLastActive(clock.Now())
|
||||||
res, err := Check(context.Background(), userID, st, "424242", true)
|
res, err := Check(context.Background(), db.Conn, userID, st, "424242", true)
|
||||||
results <- outcome{result: res, err: err}
|
results <- outcome{result: res, err: err}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,9 +464,7 @@ func corsMiddleware(next http.Handler) http.Handler {
|
|||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("X-Frame-Options", "DENY")
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
// TODO: Enable HSTS in production
|
|
||||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
// TODO: Enable Referrer-Policy in production
|
|
||||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
httpOnly cookies is the durable fix (out of scope). -->
|
httpOnly cookies is the durable fix (out of scope). -->
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https: http://localhost:8080 ws:; font-src 'self' data: https://fonts.gstatic.com; frame-src 'self' https://*.squareup.com https://*.square.com"
|
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://tile.openstreetmap.org; connect-src 'self' https: http://localhost:8080 ws:; font-src 'self' data:; frame-src 'self' https://*.squareup.com https://*.square.com"
|
||||||
/>
|
/>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -230,8 +230,7 @@
|
|||||||
description: group.description,
|
description: group.description,
|
||||||
weekStarts: group.weekStarts || [],
|
weekStarts: group.weekStarts || [],
|
||||||
hours:
|
hours:
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
(group.hours as HolidayHour[])?.map(
|
||||||
(group.hours as any[])?.map(
|
|
||||||
(h: {
|
(h: {
|
||||||
id: number;
|
id: number;
|
||||||
weekday: number;
|
weekday: number;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
email = 'chelsea@emailaddress.com',
|
email = 'chelsea@emailaddress.com',
|
||||||
instagram = '@crussell',
|
instagram = '@crussell',
|
||||||
address = 'Business Centre, Office Street, Work',
|
address = 'Business Centre, Office Street, Work',
|
||||||
profileImage = 'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png',
|
profileImage = '',
|
||||||
altText = 'Profile picture'
|
altText = 'Profile picture'
|
||||||
}: {
|
}: {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -64,9 +64,34 @@
|
|||||||
<div class="flex h-full flex-col rounded-lg border-2 border-gray-200 bg-white p-6">
|
<div class="flex h-full flex-col rounded-lg border-2 border-gray-200 bg-white p-6">
|
||||||
<!-- Profile Picture -->
|
<!-- Profile Picture -->
|
||||||
<div class="mb-4 flex shrink-0 justify-center">
|
<div class="mb-4 flex shrink-0 justify-center">
|
||||||
<div class="h-24 w-24 overflow-hidden rounded-full border-4 border-white shadow-lg">
|
{#if profileImage}
|
||||||
<img src={profileImage} alt={altText} class="h-full w-full object-cover" />
|
<img
|
||||||
|
src={profileImage}
|
||||||
|
alt={altText}
|
||||||
|
class="h-24 w-24 rounded-full object-cover ring-4 ring-fuchsia-200"
|
||||||
|
/>
|
||||||
|
{:else if name}
|
||||||
|
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-fuchsia-200">
|
||||||
|
{name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-fuchsia-200">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- Name and Role -->
|
<!-- Name and Role -->
|
||||||
<div class="mb-4 shrink-0 text-center">
|
<div class="mb-4 shrink-0 text-center">
|
||||||
|
|||||||
@@ -1,38 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Sample portfolio images - replace with your actual nail art images
|
// Sample portfolio images — local SVG placeholders (replace with actual nail art photos)
|
||||||
const portfolioImages = [
|
const portfolioImages = [
|
||||||
{
|
{ src: '/images/portfolio/placeholder-01.svg', alt: 'Nail Art — Classic' },
|
||||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Hand%2C_fingers_-_back.jpg/1024px-Hand%2C_fingers_-_back.jpg',
|
{ src: '/images/portfolio/placeholder-02.svg', alt: 'Nail Art — French' },
|
||||||
alt: 'lorem ipsum'
|
{ src: '/images/portfolio/placeholder-03.svg', alt: 'Nail Art — Warm' },
|
||||||
},
|
{ src: '/images/portfolio/placeholder-04.svg', alt: 'Nail Art — Geometric' },
|
||||||
{
|
{ src: '/images/portfolio/placeholder-05.svg', alt: 'Nail Art — Marble' },
|
||||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Palm%2C_fingers.jpg/1024px-Palm%2C_fingers.jpg',
|
{ src: '/images/portfolio/placeholder-06.svg', alt: 'Nail Art — Floral' },
|
||||||
alt: 'lorem ipsum'
|
{ src: '/images/portfolio/placeholder-07.svg', alt: 'Nail Art — Ombre' },
|
||||||
},
|
{ src: '/images/portfolio/placeholder-08.svg', alt: 'Nail Art — Chrome' }
|
||||||
{
|
|
||||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/52/Hand_parts_-_en.svg/1920px-Hand_parts_-_en.svg.png',
|
|
||||||
alt: 'lorem ipsum'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Scheme_human_hand_bones-en.svg/1920px-Scheme_human_hand_bones-en.svg.png',
|
|
||||||
alt: 'lorem ipsum'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'https://upload.wikimedia.org/wikipedia/commons/a/a1/3D_Medical_Animation_Human_Wrist.jpg',
|
|
||||||
alt: 'lorem ipsum'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Wrist_and_hand_deeper_palmar_dissection-en.svg/1920px-Wrist_and_hand_deeper_palmar_dissection-en.svg.png',
|
|
||||||
alt: 'lorem ipsum'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Wrist_extensor_compartments_%28numbered%29.PNG',
|
|
||||||
alt: 'lorem ipsum'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Gray812and814.svg/1920px-Gray812and814.svg.png',
|
|
||||||
alt: 'lorem ipsum'
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -73,9 +73,22 @@
|
|||||||
onstyleloaded?: () => void;
|
onstyleloaded?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const osmRasterStyle: MapLibreGL.StyleSpecification = {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
osm: {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layers: [{ id: 'osm', type: 'raster', source: 'osm' }]
|
||||||
|
};
|
||||||
|
|
||||||
const defaultStyles = {
|
const defaultStyles = {
|
||||||
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
dark: osmRasterStyle,
|
||||||
light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
|
light: osmRasterStyle
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -13,8 +13,21 @@ export const POLICY = {
|
|||||||
LOYALTY_DISCOUNT_RATE: 0.1,
|
LOYALTY_DISCOUNT_RATE: 0.1,
|
||||||
// Gratuity suggestion presets offered by the tip surfaces (admin payment
|
// Gratuity suggestion presets offered by the tip surfaces (admin payment
|
||||||
// modal + customer tip page). Deliberately SEPARATE from the deposit policy
|
// modal + customer tip page). Deliberately SEPARATE from the deposit policy
|
||||||
// constants above — a tip suggestion is gratuity, not a deposit percentage,
|
// constants below — a tip suggestion is gratuity, not a deposit percentage,
|
||||||
// and coupling them would silently change the tip buttons if the deposit
|
// and coupling them would silently change the tip buttons if the deposit
|
||||||
// rate ever changed.
|
// rate ever changed.
|
||||||
TIP_PRESET_PCTS: [10, 15, 20]
|
TIP_PRESET_PCTS: [10, 15, 20]
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Named re-exports so pages can import individual constants directly.
|
||||||
|
// Keep in sync with the POLICY object above.
|
||||||
|
export const {
|
||||||
|
FULL_REFUND_THRESHOLD_HOURS,
|
||||||
|
PARTIAL_REFUND_THRESHOLD_HOURS,
|
||||||
|
NO_SHOW_THRESHOLD_HOURS,
|
||||||
|
DEPOSIT_ADVANCE_HOURS,
|
||||||
|
PROTECTED_DEPOSIT_MAX_PCT,
|
||||||
|
REQUIRED_DEPOSIT_PCT,
|
||||||
|
LOYALTY_DISCOUNT_RATE,
|
||||||
|
TIP_PRESET_PCTS
|
||||||
|
} = POLICY;
|
||||||
|
|||||||
@@ -120,12 +120,7 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="stylesheet" href="/fonts/playfair-display.css" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex min-h-screen flex-col supports-[height:100dvh]:min-h-dvh">
|
<div class="flex min-h-screen flex-col supports-[height:100dvh]:min-h-dvh">
|
||||||
|
|||||||
@@ -94,12 +94,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="py-20 text-center">
|
<section class="py-20 text-center">
|
||||||
|
|||||||
@@ -2786,6 +2786,7 @@ import { generateUUID } from '$lib/utils/uuid';
|
|||||||
bind:selectedCardId={buySelectedCard}
|
bind:selectedCardId={buySelectedCard}
|
||||||
bind:saveCard={buySaveCard}
|
bind:saveCard={buySaveCard}
|
||||||
onValidityChange={(v) => (buyCardSelectionValid = v)}
|
onValidityChange={(v) => (buyCardSelectionValid = v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if buyWaitingForSCA}
|
{#if buyWaitingForSCA}
|
||||||
|
|||||||
@@ -57,8 +57,7 @@
|
|||||||
email={contact.email}
|
email={contact.email}
|
||||||
instagram="crussell"
|
instagram="crussell"
|
||||||
address={BUSINESS_ADDRESS}
|
address={BUSINESS_ADDRESS}
|
||||||
profileImage={contact.profilePicUrl ||
|
profileImage={contact.profilePicUrl || ''}
|
||||||
'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png'}
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ContactCard
|
<ContactCard
|
||||||
|
|||||||
@@ -158,13 +158,15 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="mb-2 font-medium text-gray-800">Financial Data:</p>
|
<p class="mb-2 font-medium text-gray-800">Financial Data:</p>
|
||||||
<ul class="mb-4 list-disc space-y-1 pl-5">
|
<ul class="mb-4 list-disc space-y-1 pl-5">
|
||||||
<li>Gift card codes and balances</li>
|
<li>Gift card codes (hashed), balances, and transaction history (purchases, redemptions, top-ups)</li>
|
||||||
<li>Account balances</li>
|
<li>Account balances (from redeemed gift cards or cash refunds)</li>
|
||||||
<li>Payment transaction records (our ledger of record, retained for 7 years for HMRC)</li>
|
<li>Payment transaction records (our ledger of record, retained for 7 years for HMRC)</li>
|
||||||
<li>
|
<li>
|
||||||
Saved-card references (tokenised, stored with our payment provider Square — see
|
Saved-card references (tokenised, stored with our payment provider Square — see
|
||||||
§2.2)
|
§2.2)
|
||||||
</li>
|
</li>
|
||||||
|
<li>Square customer IDs (created when you save a card for future payments)</li>
|
||||||
|
<li>Card tokens (Square <code>ccof:</code> references for recurring payments)</li>
|
||||||
<li>Dormant balance records (Account ID only, no PII)</li>
|
<li>Dormant balance records (Account ID only, no PII)</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,35 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
||||||
5. Gift Cards & Account Balances
|
5. Tips
|
||||||
|
</h2>
|
||||||
|
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||||
|
<li>
|
||||||
|
<strong>Tips are entirely optional.</strong> You are under no obligation to leave a tip,
|
||||||
|
and the quality of service you receive is not affected by whether you choose to do so.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>When you can tip:</strong> you may add a tip during the payment process or after
|
||||||
|
the service has been completed, through your account.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Tips are non-refundable once processed.</strong> Because a tip is a voluntary
|
||||||
|
gratuity paid after (or at the point of) service, it is not subject to the cancellation
|
||||||
|
and refund tiers that apply to booking payments in section 3. Once the tip transaction
|
||||||
|
has been completed, it cannot be refunded.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Payment processing:</strong> tips are processed through the same payment methods
|
||||||
|
and by the same payment provider (Square) as booking payments. The same SCA requirements,
|
||||||
|
chargeback rules, and refund-to-original-method principles apply to tip payments as to
|
||||||
|
booking payments (see section 4).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
||||||
|
6. Gift Cards & Account Balances
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mb-2 font-medium text-gray-800">Gift card expiry</p>
|
<p class="mb-2 font-medium text-gray-800">Gift card expiry</p>
|
||||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||||
@@ -327,7 +355,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
||||||
6. Distance Contracts & Right to Cancel
|
7. Distance Contracts & Right to Cancel
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mb-3">
|
<p class="mb-3">
|
||||||
Purchases made on our Platform (rather than face-to-face in the salon) are
|
Purchases made on our Platform (rather than face-to-face in the salon) are
|
||||||
@@ -342,7 +370,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<strong>Gift cards bought online</strong> carry this 14-day right, refunded to the
|
<strong>Gift cards bought online</strong> carry this 14-day right, refunded to the
|
||||||
original payment method — in full if unused, or the unspent balance if partly used
|
original payment method — in full if unused, or the unspent balance if partly used
|
||||||
on salon services (the card is then cancelled). See section 5 and our
|
on salon services (the card is then cancelled). See section 6 and our
|
||||||
<a
|
<a
|
||||||
href={resolve('/gift-card-terms')}
|
href={resolve('/gift-card-terms')}
|
||||||
class="font-medium text-blue-600 underline hover:text-blue-800">Gift Card Terms</a
|
class="font-medium text-blue-600 underline hover:text-blue-800">Gift Card Terms</a
|
||||||
@@ -365,7 +393,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-3 text-base font-semibold text-gray-900">7. Liability</h2>
|
<h2 class="mb-3 text-base font-semibold text-gray-900">8. Liability</h2>
|
||||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||||
<li>
|
<li>
|
||||||
Nothing in these Terms excludes or limits any rights you have under consumer law —
|
Nothing in these Terms excludes or limits any rights you have under consumer law —
|
||||||
@@ -389,7 +417,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-3 text-base font-semibold text-gray-900">8. Acceptable Use</h2>
|
<h2 class="mb-3 text-base font-semibold text-gray-900">9. Acceptable Use</h2>
|
||||||
<p class="mb-3">By using the Platform you agree not to:</p>
|
<p class="mb-3">By using the Platform you agree not to:</p>
|
||||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||||
<li>
|
<li>
|
||||||
@@ -414,7 +442,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
||||||
9. Complaints & Dispute Resolution
|
10. Complaints & Dispute Resolution
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mb-3">
|
<p class="mb-3">
|
||||||
If you are unhappy with any part of our service, please contact us first at
|
If you are unhappy with any part of our service, please contact us first at
|
||||||
@@ -431,7 +459,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-3 text-base font-semibold text-gray-900">10. Governing Law</h2>
|
<h2 class="mb-3 text-base font-semibold text-gray-900">11. Governing Law</h2>
|
||||||
<p class="mb-3">
|
<p class="mb-3">
|
||||||
These Terms are governed by the laws of <strong>Scotland</strong>. Any dispute arising out
|
These Terms are governed by the laws of <strong>Scotland</strong>. Any dispute arising out
|
||||||
of or in connection with these Terms is subject to the exclusive jurisdiction of the
|
of or in connection with these Terms is subject to the exclusive jurisdiction of the
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/* Playfair Display — self-hosted (Latin subset only) */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/playfair-display-latin-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
|
||||||
|
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
|
||||||
|
U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/playfair-display-latin-italic.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
|
||||||
|
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
|
||||||
|
U+FFFD;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#fce4ec"/>
|
||||||
|
<stop offset="100%" stop-color="#f8bbd0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="400" height="300" fill="url(#bg1)" rx="8"/>
|
||||||
|
<!-- Nail shape silhouette -->
|
||||||
|
<ellipse cx="200" cy="150" rx="60" ry="100" fill="#e91e63" opacity="0.15"/>
|
||||||
|
<ellipse cx="200" cy="150" rx="45" ry="85" fill="#e91e63" opacity="0.25"/>
|
||||||
|
<!-- Polish stroke -->
|
||||||
|
<path d="M200 65 Q215 120 200 150 Q185 120 200 65" fill="#f06292" opacity="0.6"/>
|
||||||
|
<!-- Decorative dots -->
|
||||||
|
<circle cx="200" cy="100" r="4" fill="#fff" opacity="0.7"/>
|
||||||
|
<circle cx="200" cy="115" r="3" fill="#fff" opacity="0.5"/>
|
||||||
|
<circle cx="200" cy="130" r="2" fill="#fff" opacity="0.3"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" fill="#ad1457" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Classic</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 987 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg2" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#e8eaf6"/>
|
||||||
|
<stop offset="100%" stop-color="#c5cae9"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="400" height="300" fill="url(#bg2)" rx="8"/>
|
||||||
|
<!-- French tip nail -->
|
||||||
|
<ellipse cx="200" cy="150" rx="55" ry="95" fill="#fff" opacity="0.4"/>
|
||||||
|
<path d="M160 80 Q200 55 240 80" fill="none" stroke="#fff" stroke-width="8" opacity="0.8"/>
|
||||||
|
<path d="M165 85 Q200 62 235 85" fill="none" stroke="#f8bbd0" stroke-width="3" opacity="0.6"/>
|
||||||
|
<!-- Sparkle accents -->
|
||||||
|
<path d="M180 120 L182 126 L188 128 L182 130 L180 136 L178 130 L172 128 L178 126 Z" fill="#fff" opacity="0.6"/>
|
||||||
|
<path d="M220 140 L221 144 L225 145 L221 146 L220 150 L219 146 L215 145 L219 144 Z" fill="#fff" opacity="0.4"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" fill="#3949ab" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — French</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1022 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg3" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#fff3e0"/>
|
||||||
|
<stop offset="100%" stop-color="#ffe0b2"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="400" height="300" fill="url(#bg3)" rx="8"/>
|
||||||
|
<!-- Hand silhouette -->
|
||||||
|
<path d="M160 80 Q170 60 190 70 L195 90 Q200 70 215 75 L215 95 Q225 75 240 85 L235 105 Q250 95 260 110 L250 130 Q265 130 265 150 L260 170 Q255 190 240 200 L220 210 L180 210 Q160 200 150 180 L145 150 Q140 130 150 110 Z" fill="#ff8f00" opacity="0.12"/>
|
||||||
|
<!-- Nail polish dots on fingertips -->
|
||||||
|
<circle cx="185" cy="75" r="6" fill="#ff6f00" opacity="0.4"/>
|
||||||
|
<circle cx="210" cy="80" r="6" fill="#ff6f00" opacity="0.4"/>
|
||||||
|
<circle cx="235" cy="90" r="6" fill="#ff6f00" opacity="0.4"/>
|
||||||
|
<circle cx="255" cy="115" r="6" fill="#ff6f00" opacity="0.4"/>
|
||||||
|
<circle cx="258" cy="145" r="6" fill="#ff6f00" opacity="0.4"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" fill="#e65100" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Warm</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg4" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#f3e5f5"/>
|
||||||
|
<stop offset="100%" stop-color="#ce93d8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="400" height="300" fill="url(#bg4)" rx="8"/>
|
||||||
|
<!-- Geometric nail pattern -->
|
||||||
|
<ellipse cx="200" cy="150" rx="50" ry="90" fill="none" stroke="#8e24aa" stroke-width="2" opacity="0.3"/>
|
||||||
|
<ellipse cx="200" cy="150" rx="35" ry="70" fill="none" stroke="#8e24aa" stroke-width="1.5" opacity="0.3"/>
|
||||||
|
<ellipse cx="200" cy="150" rx="20" ry="50" fill="none" stroke="#8e24aa" stroke-width="1" opacity="0.3"/>
|
||||||
|
<!-- Geometric diamond accent -->
|
||||||
|
<polygon points="200,100 215,130 200,160 185,130" fill="#ab47bc" opacity="0.4"/>
|
||||||
|
<polygon points="200,110 208,130 200,150 192,130" fill="#ce93d8" opacity="0.5"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" fill="#6a1b9a" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Geometric</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg5" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#e0f2f1"/>
|
||||||
|
<stop offset="100%" stop-color="#80cbc4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="400" height="300" fill="url(#bg5)" rx="8"/>
|
||||||
|
<!-- Marble nail effect -->
|
||||||
|
<ellipse cx="200" cy="150" rx="55" ry="95" fill="#fff" opacity="0.3"/>
|
||||||
|
<path d="M170 80 Q190 100 175 130 Q160 160 185 180 Q210 200 195 220" fill="none" stroke="#26a69a" stroke-width="3" opacity="0.3"/>
|
||||||
|
<path d="M220 70 Q210 100 230 120 Q250 140 225 170 Q200 200 215 230" fill="none" stroke="#4db6ac" stroke-width="2" opacity="0.25"/>
|
||||||
|
<path d="M185 90 Q200 110 190 140 Q180 170 200 190" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.4"/>
|
||||||
|
<!-- Gold foil accent -->
|
||||||
|
<circle cx="200" cy="130" r="8" fill="#ffd54f" opacity="0.5"/>
|
||||||
|
<circle cx="195" cy="125" r="3" fill="#fff" opacity="0.6"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" fill="#00695c" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Marble</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg6" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#fce4ec"/>
|
||||||
|
<stop offset="100%" stop-color="#f48fb1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="400" height="300" fill="url(#bg6)" rx="8"/>
|
||||||
|
<!-- Floral nail pattern -->
|
||||||
|
<ellipse cx="200" cy="150" rx="50" ry="90" fill="#fff" opacity="0.2"/>
|
||||||
|
<!-- Flower -->
|
||||||
|
<circle cx="200" cy="120" r="6" fill="#e91e63" opacity="0.5"/>
|
||||||
|
<circle cx="192" cy="112" r="5" fill="#f06292" opacity="0.4"/>
|
||||||
|
<circle cx="208" cy="112" r="5" fill="#f06292" opacity="0.4"/>
|
||||||
|
<circle cx="192" cy="128" r="5" fill="#f06292" opacity="0.4"/>
|
||||||
|
<circle cx="208" cy="128" r="5" fill="#f06292" opacity="0.4"/>
|
||||||
|
<circle cx="200" cy="120" r="3" fill="#fff" opacity="0.7"/>
|
||||||
|
<!-- Stem -->
|
||||||
|
<path d="M200 126 Q200 145 200 160" fill="none" stroke="#4caf50" stroke-width="2" opacity="0.4"/>
|
||||||
|
<!-- Small leaves -->
|
||||||
|
<ellipse cx="195" cy="140" rx="6" ry="3" fill="#81c784" opacity="0.4" transform="rotate(-30 195 140)"/>
|
||||||
|
<ellipse cx="205" cy="148" rx="6" ry="3" fill="#81c784" opacity="0.4" transform="rotate(30 205 148)"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" fill="#880e4f" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Floral</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg7" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ede7f6"/>
|
||||||
|
<stop offset="100%" stop-color="#b39ddb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="400" height="300" fill="url(#bg7)" rx="8"/>
|
||||||
|
<!-- Ombre nail effect -->
|
||||||
|
<ellipse cx="200" cy="150" rx="55" ry="95" fill="url(#bg7)" opacity="0.5"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ombre" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#7c4dff" stop-opacity="0.6"/>
|
||||||
|
<stop offset="40%" stop-color="#7c4dff" stop-opacity="0.3"/>
|
||||||
|
<stop offset="100%" stop-color="#fff" stop-opacity="0.1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<ellipse cx="200" cy="130" rx="45" ry="70" fill="url(#ombre)"/>
|
||||||
|
<!-- Sparkle trail -->
|
||||||
|
<circle cx="175" cy="100" r="2" fill="#fff" opacity="0.7"/>
|
||||||
|
<circle cx="185" cy="95" r="1.5" fill="#fff" opacity="0.5"/>
|
||||||
|
<circle cx="220" cy="105" r="2" fill="#fff" opacity="0.6"/>
|
||||||
|
<circle cx="210" cy="98" r="1" fill="#fff" opacity="0.4"/>
|
||||||
|
<circle cx="195" cy="90" r="1.5" fill="#fff" opacity="0.5"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" fill="#4527a0" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Ombre</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg8" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#e0f7fa"/>
|
||||||
|
<stop offset="100%" stop-color="#4dd0e1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="400" height="300" fill="url(#bg8)" rx="8"/>
|
||||||
|
<!-- Chrome/metallic nail effect -->
|
||||||
|
<ellipse cx="200" cy="150" rx="55" ry="95" fill="#fff" opacity="0.25"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="chrome" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#fff" stop-opacity="0.6"/>
|
||||||
|
<stop offset="30%" stop-color="#80deea" stop-opacity="0.3"/>
|
||||||
|
<stop offset="60%" stop-color="#26c6da" stop-opacity="0.4"/>
|
||||||
|
<stop offset="100%" stop-color="#fff" stop-opacity="0.5"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<ellipse cx="200" cy="145" rx="45" ry="80" fill="url(#chrome)"/>
|
||||||
|
<!-- Reflection line -->
|
||||||
|
<path d="M175 90 Q185 120 180 150 Q175 180 185 200" fill="none" stroke="#fff" stroke-width="2" opacity="0.5"/>
|
||||||
|
<path d="M220 85 Q215 115 225 145 Q235 175 220 200" fill="none" stroke="#fff" stroke-width="1" opacity="0.3"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" fill="#00838f" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Chrome</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |