Zastosowanie wszystkich poprawek UI i logiki kalkulatora

This commit is contained in:
AI Assistant
2026-08-10 23:26:52 +02:00
commit 7324a6f800
218 changed files with 33628 additions and 0 deletions
+68
View File
@@ -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' }
)
);
+35
View File
@@ -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),
}));
+94
View File
@@ -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 });
}
}));
+30
View File
@@ -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
View File
@@ -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,
};
}
+29
View File
@@ -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' }
)
);
+60
View File
@@ -0,0 +1,60 @@
import { z } from 'zod';
export function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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"),
});
+15
View File
@@ -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);
}