Zastosowanie wszystkich poprawek UI i logiki kalkulatora
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
image: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
items: CartItem[];
|
||||
addItem: (item: Omit<CartItem, 'id' | 'quantity'>) => void;
|
||||
removeItem: (id: string) => void;
|
||||
updateQuantity: (id: string, quantity: number) => void;
|
||||
clearCart: () => void;
|
||||
getTotal: () => number;
|
||||
getCount: () => number;
|
||||
}
|
||||
|
||||
export const useCartStore = create<CartState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
items: [],
|
||||
|
||||
addItem: (newItem) => {
|
||||
set((state) => {
|
||||
const id = newItem.slug;
|
||||
const existing = state.items.find((i) => i.id === id);
|
||||
if (existing) {
|
||||
return {
|
||||
items: state.items.map((i) =>
|
||||
i.id === id ? { ...i, quantity: i.quantity + 1 } : i
|
||||
),
|
||||
};
|
||||
}
|
||||
return { items: [...state.items, { ...newItem, id, quantity: 1 }] };
|
||||
});
|
||||
},
|
||||
|
||||
removeItem: (id) =>
|
||||
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
|
||||
|
||||
updateQuantity: (id, quantity) => {
|
||||
if (quantity < 1) return;
|
||||
set((state) => ({
|
||||
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
|
||||
}));
|
||||
},
|
||||
|
||||
clearCart: () => set({ items: [] }),
|
||||
|
||||
getTotal: () => {
|
||||
const { items } = get();
|
||||
return items.reduce((sum, i) => sum + i.price * i.quantity, 0);
|
||||
},
|
||||
|
||||
getCount: () => {
|
||||
const { items } = get();
|
||||
return items.reduce((sum, i) => sum + i.quantity, 0);
|
||||
},
|
||||
}),
|
||||
{ name: 'thermcool-cart' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface CompareItem {
|
||||
slug: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
price: number | null;
|
||||
image: string;
|
||||
specs: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CompareState {
|
||||
items: CompareItem[];
|
||||
addItem: (item: CompareItem) => void;
|
||||
removeItem: (slug: string) => void;
|
||||
clearAll: () => void;
|
||||
isAdded: (slug: string) => boolean;
|
||||
}
|
||||
|
||||
export const useCompareStore = create<CompareState>()((set, get) => ({
|
||||
items: [],
|
||||
|
||||
addItem: (item) => {
|
||||
const { items } = get();
|
||||
if (items.length >= 3 || items.find((i) => i.slug === item.slug)) return;
|
||||
set({ items: [...items, item] });
|
||||
},
|
||||
|
||||
removeItem: (slug) =>
|
||||
set((state) => ({ items: state.items.filter((i) => i.slug !== slug) })),
|
||||
|
||||
clearAll: () => set({ items: [] }),
|
||||
|
||||
isAdded: (slug) => get().items.some((i) => i.slug === slug),
|
||||
}));
|
||||
@@ -0,0 +1,94 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ConsentState {
|
||||
isInitialized: boolean;
|
||||
isOpen: boolean;
|
||||
analytics: boolean;
|
||||
analyticsConsent: boolean;
|
||||
marketing: boolean;
|
||||
advertising: boolean;
|
||||
initialize: () => void;
|
||||
openBanner: () => void;
|
||||
closeBanner: () => void;
|
||||
setConsent: (analytics: boolean, marketing: boolean, advertising: boolean) => void;
|
||||
setAnalyticsConsent: (v: boolean) => void;
|
||||
acceptAll: () => void;
|
||||
rejectOptional: () => void;
|
||||
}
|
||||
|
||||
const CONSENT_KEY = 'airproject_cookie_consent_v2';
|
||||
const OLD_CONSENT_KEY = 'cookie-consent';
|
||||
|
||||
export const useConsentStore = create<ConsentState>((set) => ({
|
||||
isInitialized: false,
|
||||
isOpen: false,
|
||||
analytics: false,
|
||||
analyticsConsent: false,
|
||||
marketing: false,
|
||||
advertising: false,
|
||||
|
||||
initialize: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
let stored = localStorage.getItem(CONSENT_KEY);
|
||||
|
||||
|
||||
if (!stored) {
|
||||
const oldStored = localStorage.getItem(OLD_CONSENT_KEY);
|
||||
if (oldStored) {
|
||||
try {
|
||||
const oldParsed = JSON.parse(oldStored);
|
||||
const newData = {
|
||||
essential: true,
|
||||
analytics: !!oldParsed.analytics,
|
||||
marketing: !!oldParsed.marketing,
|
||||
advertising: !!oldParsed.advertising,
|
||||
};
|
||||
stored = JSON.stringify(newData);
|
||||
localStorage.setItem(CONSENT_KEY, stored);
|
||||
localStorage.removeItem(OLD_CONSENT_KEY);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
set({
|
||||
isInitialized: true,
|
||||
isOpen: false,
|
||||
analytics: !!parsed.analytics,
|
||||
analyticsConsent: !!parsed.analytics,
|
||||
marketing: !!parsed.marketing,
|
||||
advertising: !!parsed.advertising,
|
||||
});
|
||||
} catch {
|
||||
set({ isInitialized: true, isOpen: true });
|
||||
}
|
||||
} else {
|
||||
set({ isInitialized: true, isOpen: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
openBanner: () => set({ isOpen: true }),
|
||||
|
||||
closeBanner: () => set({ isOpen: false }),
|
||||
|
||||
setConsent: (analytics, marketing, advertising) => {
|
||||
localStorage.setItem(CONSENT_KEY, JSON.stringify({ essential: true, analytics, marketing, advertising }));
|
||||
set({ analytics, analyticsConsent: analytics, marketing, advertising, isOpen: false });
|
||||
},
|
||||
|
||||
setAnalyticsConsent: (v: boolean) => set({ analyticsConsent: v }),
|
||||
|
||||
acceptAll: () => {
|
||||
localStorage.setItem(CONSENT_KEY, JSON.stringify({ essential: true, analytics: true, marketing: true, advertising: true }));
|
||||
set({ analytics: true, analyticsConsent: true, marketing: true, advertising: true, isOpen: false });
|
||||
},
|
||||
|
||||
rejectOptional: () => {
|
||||
localStorage.setItem(CONSENT_KEY, JSON.stringify({ essential: true, analytics: false, marketing: false, advertising: false }));
|
||||
set({ analytics: false, analyticsConsent: false, marketing: false, advertising: false, isOpen: false });
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha: {
|
||||
ready: (cb: () => void) => void;
|
||||
execute: (siteKey: string, options: { action: string }) => Promise<string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecaptchaToken(action: string = 'submit'): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const siteKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
|
||||
if (typeof window === 'undefined' || !siteKey || !window.grecaptcha) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.grecaptcha.ready(() => {
|
||||
window.grecaptcha
|
||||
.execute(siteKey, { action })
|
||||
.then((token: string) => resolve(token))
|
||||
.catch(() => resolve(null));
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { Resend } from 'resend';
|
||||
|
||||
export function getMailClient() {
|
||||
const apiKey = process.env.RESEND_API_KEY;
|
||||
const fromEmail = process.env.RESEND_FROM_EMAIL;
|
||||
const toEmail = process.env.RESEND_TO_EMAIL;
|
||||
|
||||
if (!apiKey || !fromEmail || !toEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resend: new Resend(apiKey),
|
||||
fromEmail,
|
||||
toEmail,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { Product } from '@/content/products';
|
||||
|
||||
interface RecentlyViewedState {
|
||||
items: Product[];
|
||||
addItem: (item: Product) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useRecentlyViewedStore = create<RecentlyViewedState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
|
||||
addItem: (newItem) => {
|
||||
set((state) => {
|
||||
// Usuń, jeśli już jest na liście (aby przenieść na początek)
|
||||
const filtered = state.items.filter((i) => i.slug !== newItem.slug);
|
||||
// Dodaj na początek, zachowaj tylko ostatnie 4
|
||||
return { items: [newItem, ...filtered].slice(0, 4) };
|
||||
});
|
||||
},
|
||||
|
||||
clear: () => set({ items: [] }),
|
||||
}),
|
||||
{ name: 'thermcool-recently-viewed' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export function escapeHtml(unsafe: string): string {
|
||||
if (typeof unsafe !== 'string') return '';
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const rateLimitMap = new Map<string, { count: number; expiresAt: number }>();
|
||||
const MAX_REQUESTS = 5;
|
||||
const WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
export function rateLimit(ip: string | undefined | null): boolean {
|
||||
if (!ip) return true;
|
||||
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(ip);
|
||||
|
||||
if (!entry || entry.expiresAt < now) {
|
||||
rateLimitMap.set(ip, { count: 1, expiresAt: now + WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.count >= MAX_REQUESTS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function verifyRecaptcha(token: string): Promise<boolean> {
|
||||
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
|
||||
if (!secretKey) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://www.google.com/recaptcha/api/siteverify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `secret=${secretKey}&response=${token}`,
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.success;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const baseContactSchema = z.object({
|
||||
name: z.string().min(2, "Imię musi mieć co najmniej 2 znaki").max(100, "Imię jest zbyt długie"),
|
||||
phone: z.string().regex(/^[+\d\s-]{9,20}$/, "Niepoprawny format telefonu"),
|
||||
email: z.string().email("Niepoprawny adres e-mail").max(150, "E-mail jest zbyt długi"),
|
||||
consent: z.string().refine(val => val === "on" || val === "true", "Wymagana zgoda"),
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('pl-PL', {
|
||||
style: 'currency',
|
||||
currency: 'PLN',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
Reference in New Issue
Block a user