Compare commits

...

5 Commits

Author SHA1 Message Date
c18497b3fa remove pocketbase 2025-07-01 13:17:16 +02:00
bf317d2e34 button with image 2025-07-01 11:47:25 +02:00
fd4b122936 login/checkout 2025-06-30 15:27:46 +02:00
012058b998 login 2025-06-27 16:02:00 +02:00
96dbc38c3a button with image 2025-06-26 21:04:01 +02:00
49 changed files with 1564 additions and 1920 deletions

10
app.vue
View File

@ -1,7 +1,5 @@
<template>
<div
class="main bg-bg-light text-text-light dark:text-text-dark dark:bg-bg-dark"
>
<div class="main bg-bg-light text-text-light dark:text-text-dark dark:bg-bg-dark">
<UApp>
<NuxtLayout>
<NuxtPage />
@ -9,4 +7,8 @@
</UApp>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
const userStore = useUserStore()
await userStore.checkIsLogged()
</script>

View File

@ -1,3 +1,4 @@
/* ===== Base toast style (applies to all toasts) ===== */
.Toastify__toast {
font-family: var(--font-inter);
background-color: var(--color-bg-light);
@ -7,29 +8,44 @@
box-shadow: none;
}
/* ===== Base toast style for dark mode ===== */
.dark .Toastify__toast {
background-color: var(--color-bg-dark);
border: 1px solid var(--color-block);
color: #ffffff;
}
/* ===== Error toast: red background and white text ===== */
.Toastify__toast--error {
background-color: #dc3545;
color: #fff;
background-color: var(--color-bg-light);
}
/* ===== Error toast in dark mode: darker red background ===== */
.dark .Toastify__toast--error {
background-color: #c82333;
background-color: var(--color-bg-dark);
}
/* ===== Default progress bar color (used for success and others) ===== */
.Toastify__progress-bar {
background-color: var(--color-accent-green-dark);
}
/* ===== Error toast: custom progress bar color ===== */
.Toastify__toast--error .Toastify__progress-bar {
background-color: #dc3545;
}
/* ===== Success toast: icon color ===== */
.Toastify__toast--success .Toastify__toast-icon svg {
fill: var(--color-accent-green-dark);
}
/* ===== Error toast: icon color ===== */
.Toastify__toast--error .Toastify__toast-icon svg {
fill: #dc3545;
}
/* ===== Close button color in dark mode ===== */
.dark .Toastify__close-button {
color: #fff;
}
}

View File

@ -1,82 +0,0 @@
export class TurnStilleCaptcha {
sitekey = "{{.SiteKey}}";
/**
* Initializes the TurnStilleCaptcha instance.
* Creates a container for the captcha and appends it to the target element.
* If the Cloudflare Turnstile script is already loaded, it runs the captcha.
* Otherwise, it loads the script and initializes the captcha with the given properties.
* @param {HTMLElement} target - The element to attach the captcha container to.
* @param {Object} [props={}] - Optional properties for captcha initialization, such as theme.
*/
constructor(target, props = {}) {
// create holder
this.holder = document.createElement("div");
this.holder.id = "turnstile-container";
this.theme = props.theme || "auto";
target.appendChild(this.holder);
// execute code
if (window.turnstile) {
this.runCaptcha();
} else {
this.loadCloudflareScript();
}
}
runCaptcha() {
setTimeout(() => {
if (globalThis.turnstileInstance) {
window.turnstile.remove(globalThis.turnstileInstance);
}
globalThis.turnstileInstance = window.turnstile.render(this.holder, {
sitekey: this.sitekey,
theme: this.theme,
callback: (token) => {
if (token) {
const event = new CustomEvent("token", {
detail: token,
bubbles: true,
});
this.holder.dispatchEvent(event);
}
},
error: (error) => {
const event = new CustomEvent("failure", {
detail: error,
bubbles: true,
});
this.holder.dispatchEvent(event);
window.turnstile.reset(globalThis.turnstileInstance);
},
});
}, 1000);
}
loadCloudflareScript() {
const script = document.createElement("script");
script.id = "turnstile-script";
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
// script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.defer = true;
script.onload = () => {
const event = new CustomEvent("loaded", {
detail: "Turnstile script loaded",
bubbles: true,
});
this.holder.dispatchEvent(event);
this.runCaptcha();
};
script.onerror = () => {
const event = new CustomEvent("failure", {
detail: "Failed to load Turnstile script",
bubbles: true,
});
this.holder.dispatchEvent(event);
};
document.head.appendChild(script);
}
}

View File

@ -1,95 +0,0 @@
package cloudflare
import (
"bytes"
"encoding/json"
"errors"
"html/template"
"io"
"net/http"
"strings"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
var VerifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
type TurnstileResponse struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes,omitempty"`
ChallengeTS string `json:"challenge_ts,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
type TurnStilleCaptcha struct {
SiteKey string
SecretKey string
}
func ServeTurnstilleCaptchaJS(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
return se.Router.GET("/api/email/script.js", func(e *core.RequestEvent) error {
// www.abrasive.ma-al.pl
// siteKey: "0x4AAAAAABdgeAdu4Pxxovj3"
// secretKey: "0x4AAAAAABdgeHJDjMwmeX5aXaXGh6HWZbw"
settings, err := GetSettings(app)
if err != nil {
return err
}
file, err := JS.ReadFile("TurnStilleCaptcha.js")
if err != nil {
return err
}
templ, err := template.New("test").Parse(string(file))
buf := bytes.Buffer{}
templ.Execute(&buf, map[string]interface{}{
"SiteKey": settings.SiteKey,
})
if err != nil {
return err
}
e.Response.Header().Set("Content-Type", "application/javascript")
return e.String(http.StatusOK, buf.String())
})
}
func GetSettings(app *pocketbase.PocketBase) (*TurnStilleCaptcha, error) {
record, err := app.FindFirstRecordByFilter("settings", "key='turnstile'", nil)
settings := TurnStilleCaptcha{}
json.Unmarshal([]byte(record.GetString("value")), &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
func VerifyTurnstile(app *pocketbase.PocketBase, token, ip string) error {
conf, err := GetSettings(app)
if err != nil {
return err
}
data := map[string]string{"secret": conf.SecretKey, "response": token, "remoteip": ip}
jsonData, _ := json.Marshal(data)
resp, err := http.Post(VerifyUrl, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return err
}
defer resp.Body.Close()
var turnstileResp TurnstileResponse
body, _ := io.ReadAll(resp.Body)
json.Unmarshal(body, &turnstileResp)
if !turnstileResp.Success {
return errors.New(turnstileResp.ChallengeTS + ": " + strings.Join(turnstileResp.ErrorCodes, " "))
}
return nil
}

View File

@ -1,8 +0,0 @@
package cloudflare
import (
"embed"
)
//go:embed TurnStilleCaptcha.js
var JS embed.FS

View File

@ -1,61 +0,0 @@
package custom
import (
"pocketbase/custom/cloudflare"
"pocketbase/custom/gtm"
"pocketbase/custom/mail"
"pocketbase/custom/manifest"
"pocketbase/custom/proxy"
"pocketbase/custom/seo"
"pocketbase/custom/supervise"
"pocketbase/custom/version"
webpconverter "pocketbase/custom/webpConverter"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func LoadCustomCode(app *pocketbase.PocketBase, se *core.ServeEvent) error {
// include supervised subprocess if command provided
supervise.ServeSubprocessSupervisor(app, se)
// include serving js code from cloudflare
cloudflare.ServeTurnstilleCaptchaJS(app, se)
// include sending emails service
mail.ServeMailSender(app, se)
// include robots.txt endpoint
seo.ServeRobotsTxt(app, se)
// include feeds endpoint
seo.ServeFeeds(app, se)
seo.ServeFeedsIndex(app, se)
// include proxy to serve nuxt app
proxy.ServeProxyPassingToNuxt(app, se)
// create endpoint to serve version information
version.ServeVersionInfo(app, se)
// include endpoint to server GTM script
gtm.ServeTagMangerJS(app, se)
// include endpoint serving manifest
manifest.ServeManifst(app, se)
return nil
}
func ExtendApp(app *pocketbase.PocketBase) *pocketbase.PocketBase {
app.RootCmd.PersistentFlags().String("proxy", "", "inner proxy target")
app.RootCmd.PersistentFlags().String("subcommand", "", "provide command with params like cli that will be executed and supervised by main process")
// include webp converter
app.OnRecordCreate().Bind(webpconverter.CreateEventHandler(app))
app.OnRecordUpdate().Bind(webpconverter.CreateEventHandler(app))
app.OnFileDownloadRequest().Bind(webpconverter.ThumbEventHandler(app))
return app
}

View File

@ -1,56 +0,0 @@
package gtm
import (
"bytes"
"encoding/json"
"net/http"
"text/template"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
func ServeTagMangerJS(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
return se.Router.GET("/api/gtm/script.js", func(e *core.RequestEvent) error {
settings, err := GetSettings(app)
if err != nil {
return err
}
file, err := JS.ReadFile("gtm.js")
if err != nil {
return err
}
templ, err := template.New("test").Parse(string(file))
buf := bytes.Buffer{}
templ.Execute(&buf, map[string]interface{}{
"GtagID": settings.GtagID,
})
if err != nil {
return err
}
e.Response.Header().Set("Content-Type", "application/javascript")
return e.String(http.StatusOK, buf.String())
})
}
type GTagSettings struct {
GtagID string `json:"gtagID"`
}
func GetSettings(app *pocketbase.PocketBase) (*GTagSettings, error) {
record, err := app.FindFirstRecordByFilter("settings", "key='gtagID'", nil)
settings := GTagSettings{}
json.Unmarshal([]byte(record.GetString("value")), &settings)
if err != nil {
return nil, err
}
return &settings, nil
}

View File

@ -1,29 +0,0 @@
export class GTM {
gtagID = "{{.GtagID}}";
constructor() {
this.insertScript(window, document, 'script', 'dataLayer', this.gtagID);
this.insertNoScript();
}
insertScript(w, d, s, l, i) {
w[l] = w[l] || []; w[l].push({
'gtm.start':
new Date().getTime(), event: 'gtm.js'
}); var f = d.getElementsByTagName(s)[0],
j = d.createElement(s), dl = l != 'dataLayer' ? '&l=' + l : ''; j.async = true; j.src =
'https://www.googletagmanager.com/gtm.js?id=' + i + dl; f.parentNode.insertBefore(j, f);
}
insertNoScript() {
const noscript = document.createElement("noscript");
const iframe = document.createElement("iframe");
iframe.src = "https://www.googletagmanager.com/ns.html?id=" + this.gtagID;
iframe.height = 0;
iframe.width = 0;
iframe.style = "display:none;visibility:hidden";
noscript.appendChild(iframe);
document.body.appendChild(noscript);
}
}

View File

@ -1,8 +0,0 @@
package gtm
import (
"embed"
)
//go:embed gtm.js
var JS embed.FS

View File

@ -1,175 +0,0 @@
package mail
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"net/mail"
"pocketbase/custom/cloudflare"
"strings"
"text/template"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/router"
)
type EmailData struct {
Name string `json:"name"`
Email string `json:"email"`
Token string `json:"token"`
Message string `json:"message"`
Phone string `json:"phone"`
LangIso string `json:"lang_iso"`
}
type MailsSettings struct {
ReceiverMail string `json:"receiver_mail"`
ReceiverName string `json:"receiver_name"`
Subject string `json:"subject"`
}
func ServeMailSender(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
return se.Router.POST("/api/email/send", func(e *core.RequestEvent) error {
// name := e.Request.PathValue("name")
data := EmailData{}
if err := e.BindBody(&data); err != nil {
e.Response.Header().Set("Content-Type", "application/json")
app.Logger().Error(err.Error(), "type", "mail")
return e.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if pass, err := validateMX(data.Email); !pass && err != nil {
e.Response.Header().Set("Content-Type", "application/json")
app.Logger().Error("Invalid email address.", "type", "mail", "error", err.Error())
return e.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid email address."})
}
// fmt.Printf("e.Request.Body: %v IP: %s\n", data, e.RealIP())
err := cloudflare.VerifyTurnstile(app, data.Token, e.RealIP())
if err != nil {
e.Response.Header().Set("Content-Type", "application/json")
app.Logger().Error("Captcha verification failed.", "type", "mail", "error", err.Error())
return e.JSON(http.StatusBadRequest, map[string]string{"error": "Captcha verification failed."})
}
eamil_body, err := app.FindFirstRecordByFilter("email_template", fmt.Sprintf("name='contact_form'&&id_lang='%s'", data.LangIso), nil)
if err != nil {
e.Response.Header().Set("Content-Type", "application/json")
app.Logger().Error("Template not available", "type", "mail", "error", err.Error())
return e.JSON(http.StatusBadRequest, map[string]string{"error": "Template not available"})
}
templ, err := template.New("test").Parse(eamil_body.GetString("template"))
if err != nil {
app.Logger().Error("Template parsing error.", "type", "mail", "error", err.Error())
e.Response.Header().Set("Content-Type", "application/json")
return e.JSON(http.StatusBadRequest, map[string]string{"error": "Template parsing error."})
}
buf := bytes.Buffer{}
templ.Execute(&buf, map[string]interface{}{
"Data": time.Now().Local().Format("2006-01-02 15:04:05"),
"Name": data.Name,
"Email": data.Email,
"Message": strings.ReplaceAll(data.Message, "\n", "<br>"),
"Phone": data.Phone,
})
mailsSettings, err := getSettings(app)
if err != nil {
app.Logger().Error("Mails settings corrupted", "type", "mail", "error", err.Error())
e.Response.Header().Set("Content-Type", "application/json")
return e.JSON(http.StatusBadRequest, map[string]string{"error": "Mails settings corrupted"})
}
cc := []mail.Address{{Address: data.Email, Name: data.Name}}
to := []mail.Address{{Address: mailsSettings.ReceiverMail, Name: mailsSettings.ReceiverName}}
message := &mailer.Message{
From: mail.Address{
Address: e.App.Settings().Meta.SenderAddress,
Name: e.App.Settings().Meta.SenderName,
},
Cc: cc,
To: to,
Subject: mailsSettings.Subject,
HTML: buf.String(),
}
err = e.App.NewMailClient().Send(message)
if err != nil {
app.Logger().Error("Mails sending error", "type", "mail", "error", err.Error())
e.Response.Header().Set("Content-Type", "application/json")
return e.JSON(http.StatusBadRequest, map[string]string{"error": "Mails sending error"})
}
receivers := formReceiversList(to, cc, []mail.Address{})
app.Logger().Info("Mail Sent", "type", "mail", "receivers", receivers)
return e.JSON(http.StatusOK, map[string]string{"status": "success", "message": "Email sent successfully. to " + receivers})
})
}
func formReceiversList(to []mail.Address, cc []mail.Address, bcc []mail.Address) string {
res := ""
for _, a := range to {
res = fmt.Sprintf("%s %s<%s>", res, a.Name, a.Address)
}
for _, a := range cc {
res = fmt.Sprintf("%s %s<%s>", res, a.Name, a.Address)
}
for _, a := range bcc {
res = fmt.Sprintf("%s %s<%s>", res, a.Name, a.Address)
}
return res
}
func getSettings(app *pocketbase.PocketBase) (*MailsSettings, error) {
record, err := app.FindFirstRecordByFilter("settings", "key='contact_page'", nil)
settings := MailsSettings{}
json.Unmarshal([]byte(record.GetString("value")), &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
func extractDomain(email string) string {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return ""
}
return strings.TrimSpace(parts[1])
}
// validateMX checks if the domain has valid MX records or A records as a fallback
func validateMX(email string) (bool, error) {
// Check MX records
domain := extractDomain(email)
mxRecords, err := net.LookupMX(domain)
if err != nil {
return false, fmt.Errorf("'MX' records for %s not found", domain)
} else if len(mxRecords) > 0 {
// At least one MX record exists
return true, nil
}
// Fallback: Check for A records (some domains accept mail via A records)
aRecords, err := net.LookupHost(domain)
if err != nil {
return false, fmt.Errorf("'A' record for %s not found", domain)
}
return len(aRecords) > 0, nil
}

View File

@ -1,50 +0,0 @@
package manifest
import (
"net/http"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
type Manifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Description string `json:"description"`
Icons []Icon `json:"icons"`
StartURL string `json:"start_url"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Lang string `json:"lang"`
Author string `json:"author"`
OgHost string `json:"ogHost"`
Orientation string `json:"orientation"`
}
type Icon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
}
func ServeManifst(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
return se.Router.GET("/api/manifest.json", func(e *core.RequestEvent) error {
manifest, err := GetSettings(app)
if err != nil {
return err
}
e.Response.Header().Add("content-type", "application/json")
return e.String(http.StatusOK, manifest)
})
}
func GetSettings(app *pocketbase.PocketBase) (string, error) {
record, err := app.FindFirstRecordByFilter("settings", "key='manifest'", nil)
if err != nil {
return "", err
}
return record.GetString("value"), nil
}

View File

@ -1,121 +0,0 @@
package proxy
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
var Logger *slog.Logger
func ServeProxyPassingToNuxt(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
Logger = app.Logger()
proxyUrl, _ := app.RootCmd.Flags().GetString("proxy")
if len(proxyUrl) > 0 {
target, _ := url.Parse(proxyUrl) // Node.js app
proxy := httputil.NewSingleHostReverseProxy(target)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = target.Host
}
proxy.Transport = &loggingTransport{http.DefaultTransport}
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
if errors.Is(err, context.Canceled) {
return
}
app.Logger().Error(fmt.Sprintf("Proxy error: %v for %s %s", err, req.Method, req.URL.Path), "type", "proxy")
http.Error(rw, "Proxy error", http.StatusBadGateway)
}
return se.Router.Any("/", func(e *core.RequestEvent) error {
// Ping the backend with a HEAD request (or TCP dial)
backendUp := isBackendAlive(target)
if !backendUp {
app.Logger().Error(fmt.Sprintf("Backend %s is unreachable, sending 502", target), "type", "proxy", "path", e.Request.URL.Path, "remoteIP", e.Request.RemoteAddr)
e.Response.WriteHeader(http.StatusBadGateway)
e.Response.Write([]byte("502 Backend is unavailable"))
return nil
}
// Forward to Node.js
proxy.ServeHTTP(e.Response, e.Request)
return nil
})
} else {
return nil
}
}
func isBackendAlive(target *url.URL) bool {
conn, err := net.DialTimeout("tcp", target.Host, 500*time.Millisecond)
if err != nil {
return false
}
conn.Close()
return true
}
type loggingTransport struct {
rt http.RoundTripper
}
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
start := time.Now()
// Do the actual request
resp, err := t.rt.RoundTrip(req)
duration := time.Since(start)
// Prepare fields
remoteAddr := req.RemoteAddr
if ip := req.Header.Get("X-Real-IP"); len(ip) > 0 {
remoteAddr = ip
}
method := req.Method
uri := req.URL.RequestURI()
// proto := req.Proto
status := 0
size := 0
if resp != nil {
status = resp.StatusCode
if resp.ContentLength > 0 {
size = int(resp.ContentLength)
}
}
referer := req.Referer()
ua := req.UserAgent()
// timestamp := time.Now().Format("02/Jan/2006:15:04:05 -0700")
Logger.Info(
fmt.Sprintf("%s %s", method, uri),
"type", "proxy",
// "method", method,
// "url", uri,
"referer", referer,
"remoteIP", remoteAddr,
"userAgent", ua,
"execTime", fmt.Sprintf("%.4fms", float64(duration.Milliseconds())),
"status", status,
"size", size,
)
return resp, err
}

View File

@ -1,194 +0,0 @@
package seo
import (
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"path"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
type MenuRecord struct {
Active bool `json:"active"`
CollectionID string `json:"collectionId"`
CollectionName string `json:"collectionName"`
Created string `json:"created"`
ID string `json:"id"`
IDLang string `json:"id_lang"`
IDPage string `json:"id_page"`
IDParent string `json:"id_parent"`
IsDefault bool `json:"is_default"`
IsRoot bool `json:"is_root"`
LinkRewrite string `json:"link_rewrite"`
LinkTitle string `json:"link_title"`
MetaDescription string `json:"meta_description"`
MetaTitle string `json:"meta_title"`
Name string `json:"name"`
PageName string `json:"page_name"`
PositionID int `json:"position_id"`
Updated string `json:"updated"`
Url string `json:"url"`
}
type UrlSet struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
Urls []Url `xml:"url"`
}
type Url struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
ChangeFreq string `xml:"changefreq,omitempty"`
Priority string `xml:"priority,omitempty"`
}
func ServeFeeds(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
return se.Router.GET("/feeds/{lang}/sitemap.xml", func(e *core.RequestEvent) error {
lang := e.Request.PathValue("lang")
urls, err := getLocations(app, lang)
if err != nil {
return err
}
xx := UrlSet{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
Urls: urls,
}
bytes, err := xml.MarshalIndent(xx, "", " ")
if err != nil {
return err
}
e.Response.Header().Add("content-type", "text/xml")
return e.String(http.StatusOK, xml.Header+string(bytes))
})
}
func getLocations(app *pocketbase.PocketBase, lang string) ([]Url, error) {
records, err := app.FindRecordsByFilter("menu_view", fmt.Sprintf("id_lang='%s'&&active=true", lang), "position_id", 200, 0)
if err != nil {
return nil, err
}
baseUrl, err := getBaseUrl(app)
if err != nil {
return nil, err
}
locations := []Url{}
lastMod := time.Now().Add(time.Hour * 24 * 7 * -1).Format("2006-01-02")
for _, r := range records {
rec := MenuRecord{}
x, _ := r.MarshalJSON()
json.Unmarshal(x, &rec)
if rec.IsRoot {
continue
}
if len(rec.Url) > 0 {
continue
}
if rec.IsDefault {
locations = append(locations, Url{
Loc: baseUrl + path.Join(lang),
LastMod: lastMod,
ChangeFreq: "weekly",
Priority: "1.0",
})
}
locations = append(locations, Url{
Loc: baseUrl + path.Join(lang, rec.IDPage, rec.LinkRewrite),
LastMod: lastMod,
ChangeFreq: "weekly",
Priority: "1.0",
})
}
return locations, nil
}
type BaseUrl struct {
BaseURL string `json:"baseUrl"`
}
func getBaseUrl(app *pocketbase.PocketBase) (string, error) {
record, err := app.FindFirstRecordByFilter("settings", "key='baseUrl'", nil)
if err != nil {
return "", err
}
settings := BaseUrl{}
json.Unmarshal([]byte(record.GetString("value")), &settings)
if err != nil {
return "", err
}
return settings.BaseURL, nil
}
type SitemapIndex struct {
XMLName xml.Name `xml:"sitemapindex"`
Xmlns string `xml:"xmlns,attr"`
Sitemaps []Sitemap `xml:"sitemap"`
}
// Sitemap represents each <sitemap> entry
type Sitemap struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod"`
}
func ServeFeedsIndex(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
return se.Router.GET("/feeds/index.xml", func(e *core.RequestEvent) error {
index, err := makeSiteMapIndex(app)
if err != nil {
return err
}
bytes, err := xml.MarshalIndent(index, "", " ")
if err != nil {
return err
}
e.Response.Header().Add("content-type", "text/xml")
return e.String(http.StatusOK, xml.Header+string(bytes))
})
}
func makeSiteMapIndex(app *pocketbase.PocketBase) (*SitemapIndex, error) {
index := &SitemapIndex{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
}
bseUrl, err := getBaseUrl(app)
if err != nil {
return nil, err
}
langs, err := app.FindRecordsByFilter("lang", "active=true", "", 200, 0)
if err != nil {
return index, err
}
lastMod := time.Now().Add(time.Hour * 24 * 7 * -1).Format("2006-01-02")
for _, l := range langs {
index.Sitemaps = append(index.Sitemaps, Sitemap{
Loc: bseUrl + path.Join("feeds/", l.Id, "sitemap.xml"),
LastMod: lastMod,
})
}
return index, nil
}

View File

@ -1,50 +0,0 @@
package seo
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
type Robots struct {
Robots []string
}
func ServeRobotsTxt(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
return se.Router.GET("/robots.txt", func(e *core.RequestEvent) error {
text, err := getRobots(app)
if err != nil {
return err
}
return e.String(http.StatusOK, text)
})
}
func getRobots(app *pocketbase.PocketBase) (string, error) {
record, err := app.FindFirstRecordByFilter("settings", "key='robots_txt'", nil)
if err != nil {
return "", err
}
settings := Robots{}
json.Unmarshal([]byte(record.GetString("value")), &settings)
if err != nil {
return "", err
}
baseUrl, err := getBaseUrl(app)
if err != nil {
return "", err
}
settings.Robots = append(settings.Robots, fmt.Sprintf("\n\nSitemap: %s%s", baseUrl, "feeds/index.xml"))
return strings.Join(settings.Robots, "\n"), nil
}

View File

@ -1,88 +0,0 @@
package supervise
import (
"context"
"log"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
func ServeSubprocessSupervisor(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
command, _ := app.RootCmd.PersistentFlags().GetString("subcommand")
if len(command) > 0 {
cmdsub := strings.Split(command, " ")
if len(cmdsub) > 0 {
startNodeProcessSupervised(cmdsub[0], strings.Join(cmdsub[1:], " "))
}
}
return nil
}
func startNodeProcessSupervised(command string, args ...string) {
const maxRetries = 3
const retryDelay = 30 * time.Second
var (
cmdMu sync.Mutex
cmd *exec.Cmd
)
stopChan := make(chan os.Signal, 1)
signal.Notify(stopChan, os.Interrupt, syscall.SIGTERM)
go func() {
retries := 0
for {
select {
case <-stopChan:
log.Println("Received shutdown signal. Terminating subprocess...")
cmdMu.Lock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Signal(syscall.SIGTERM)
}
cmdMu.Unlock()
return
default:
ctx, cancel := context.WithCancel(context.Background())
cmdMu.Lock()
cmd = exec.CommandContext(ctx, command, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmdMu.Unlock()
log.Printf("Starting Process: %s %v\n", command, args)
err := cmd.Run()
cancel() // cancel the context when done
if err != nil {
log.Printf("Process exited with error: %v\n", err)
retries++
if retries >= maxRetries {
log.Printf("Process failed %d times. Shutting down application...", retries)
// _ = app.ResetBootstrapState()
os.Exit(1)
}
log.Printf("Retrying in %s (%d/%d)...", retryDelay, retries, maxRetries)
time.Sleep(retryDelay)
} else {
log.Printf("Process exited normally. Resetting retry count.")
retries = 0
}
}
}
}()
}

View File

@ -1,26 +0,0 @@
package version
import (
"net/http"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
var Version string
var BuildDate string
var Company string
var CompanyUrl string
func ServeVersionInfo(app *pocketbase.PocketBase, se *core.ServeEvent) *router.Route[*core.RequestEvent] {
return se.Router.GET("/api/version/send", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"version": Version,
"build_date": BuildDate,
"company": Company,
"company_url": CompanyUrl,
})
})
}

View File

@ -1,14 +0,0 @@
package webpconverter
import (
"bytes"
"io"
)
type memFileReader struct {
data []byte
}
func (m *memFileReader) Open() (io.ReadSeekCloser, error) {
return readSeekCloser{bytes.NewReader(m.data)}, nil
}

View File

@ -1,9 +0,0 @@
package webpconverter
import "bytes"
type readSeekCloser struct {
*bytes.Reader
}
func (r readSeekCloser) Close() error { return nil }

View File

@ -1,144 +0,0 @@
package webpconverter
import (
"bytes"
"image"
"image/png"
"io"
"path/filepath"
"strings"
"github.com/chai2010/webp"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
)
func CreateEventHandler(app *pocketbase.PocketBase) *hook.Handler[*core.RecordEvent] {
return &hook.Handler[*core.RecordEvent]{
Func: func(e *core.RecordEvent) error {
fieldsData := e.Record.FieldsData()
for _, v := range fieldsData {
if files, ok := v.([]interface{}); ok {
for _, f := range files {
if file, ok := f.(*filesystem.File); ok {
if shouldBeReplacedWithWebp(file.Name) {
convertToWebp(file)
}
}
}
}
if f, ok := v.(interface{}); ok {
if file, ok := f.(*filesystem.File); ok {
if shouldBeReplacedWithWebp(file.Name) {
convertToWebp(file)
}
}
}
}
return e.Next()
},
Priority: 100,
}
}
func ThumbEventHandler(app *pocketbase.PocketBase) *hook.Handler[*core.FileDownloadRequestEvent] {
return &hook.Handler[*core.FileDownloadRequestEvent]{
Func: func(fdre *core.FileDownloadRequestEvent) error {
if filepath.Ext(fdre.ServedName) == ".webp" {
filename := fdre.Request.PathValue("filename")
baseFilesPath := fdre.Record.BaseFilesPath()
fs, err := app.App.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
blob, err := fs.GetReader(baseFilesPath + "/thumbs_" + filename + "/" + fdre.ServedName)
if err != nil {
blob, err = fs.GetReader(baseFilesPath + "/" + fdre.ServedName)
if err != nil {
return err
}
}
defer blob.Close()
img, err := png.Decode(blob)
if err != nil {
return fdre.Next()
}
buf := bytes.Buffer{}
err = webp.Encode(&buf, img, &webp.Options{Quality: 80})
if err != nil {
return err
}
err = fs.Upload(buf.Bytes(), baseFilesPath+"/thumbs_"+filename+"/"+fdre.ServedName)
if err != nil {
_, err := fdre.Response.Write(buf.Bytes())
if err != nil {
return err
}
return nil
}
return fdre.Next()
}
return fdre.Next()
},
Priority: 99,
}
}
func convertToWebp(file *filesystem.File) error {
file.Name = replaceExtWithWebp(file.Name)
file.OriginalName = replaceExtWithWebp(file.OriginalName)
ff, err := file.Reader.Open()
if err != nil {
return err
}
defer ff.Close()
fff, err := io.ReadAll(ff)
if err != nil {
return err
}
img, _, err := image.Decode(bytes.NewReader(fff))
if err != nil {
return err
}
buf := bytes.Buffer{}
err = webp.Encode(&buf, img, &webp.Options{Quality: 80})
if err != nil {
return err
}
file.Reader = &memFileReader{data: buf.Bytes()}
file.Size = int64(buf.Len())
return nil
}
func replaceExtWithWebp(path string) string {
// List of image extensions to convert
exts := []string{".png", ".jpg", ".jpeg", ".bmp", ".tiff"}
for _, ext := range exts {
if strings.HasSuffix(strings.ToLower(path), ext) {
return strings.TrimSuffix(path, ext) + ".webp"
}
}
return path
}
func shouldBeReplacedWithWebp(path string) bool {
// List of image extensions to convert
exts := []string{".png", ".jpg", ".jpeg", ".bmp", ".tiff"}
for _, ext := range exts {
if strings.HasSuffix(strings.ToLower(path), ext) {
return true
}
}
return false
}

View File

@ -1,42 +0,0 @@
module pocketbase
go 1.23.2
require (
github.com/chai2010/webp v1.4.0
github.com/pocketbase/pocketbase v0.28.2
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pocketbase/dbx v1.11.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cast v1.8.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/image v0.27.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect
)

View File

@ -1,127 +0,0 @@
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.28.2 h1:b6cfUfr5d4whvUFGFhI8oHRzx/eB76GCUQGftqgv9lM=
github.com/pocketbase/pocketbase v0.28.2/go.mod h1:ElwIYS1b5xS9w0U7AK7tsm6FuC0lzw57H8p/118Cu7g=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q=
modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -1,27 +0,0 @@
package main
import (
"log"
"pocketbase/custom"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
app = custom.ExtendApp(app)
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
custom.LoadCustomCode(app, se)
return se.Next()
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}

View File

@ -11,10 +11,12 @@
"@pinia/nuxt": "^0.11.0",
"@tailwindcss/vite": "^4.1.8",
"@vueuse/core": "^13.3.0",
"gsap": "^3.13.0",
"nuxt": "^3.17.4",
"tailwindcss": "^4.1.7",
"vue": "^3.5.14",
"vue-router": "^4.5.1",
"vue3-toastify": "^0.2.8",
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
@ -1290,6 +1292,8 @@
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="],
"gzip-size": ["gzip-size@7.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA=="],
"h3": ["h3@1.15.3", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.4", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.0", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ=="],
@ -2360,6 +2364,8 @@
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
"vue3-toastify": ["vue3-toastify@0.2.8", "", { "peerDependencies": { "vue": ">=3.2.0" }, "optionalPeers": ["vue"] }, "sha512-8jDOqsJaBZEbGpCbhWDETJc11D1lZefvgFPq/IPdM+U7+qyXoVPDvK6uq/FIgyV7qV0NcNzvGBMEzjsLQqGROw=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],

BIN
bun.lockb Executable file

Binary file not shown.

View File

@ -7,7 +7,7 @@
{{ productStore.cart.cart_items.length }}</div>
</div>
<div v-if="openCart" @click.self="openCart = !openCart"
class="absolute left-1/2 transform -translate-x-1/2 w-full px-4 sm:max-w-[768px] sm:px-[17px] md:max-w-[1000px] md:px-6 xl:max-w-[1920px] xl:px-20 right-0 z-50 flex items-center justify-end top-[90px] sm:top-[100px] md:top-[140px]">
class="absolute left-1/2 transform -translate-x-1/2 w-full px-4 sm:max-w-[768px] sm:px-[17px] md:max-w-[1000px] md:px-6 xl:max-w-[1920px] xl:px-20 right-0 z-50 flex items-center justify-end top-[100px] sm:top-[100px] md:top-[140px]">
<div class="xl:w-[55%] md:w-[90%] w-full px-4 md:px-0">
<div v-if="productStore.cart.cart_items && productStore.cart.cart_items.length > 0"
class="w-full p-[25px] sm:p-[50px] bg-bg-light dark:bg-bg-dark border border-button rounded-xl sm:rounded-[32px] h-full space-25-55">
@ -36,7 +36,7 @@
class="text-accent-green-light dark:text-accent-green-dark font-inter text-[12px] sm:text-[21px] md:text-2xl leading-[150%] font-bold">
{{ item.total_price }}
</p>
<div class="flex items-center gap-[2px] sm:gap-4 text-xl">
<div class="flex items-center gap-[2px] sm:gap-2 text-xl">
<div
class="w-5 min-h-5 sm:w-11 sm:min-h-11 text-[10px] sm:text-lg flex items-center justify-center">
<i class="uil uil-minus cursor-pointer text-gray dark:text-button-disabled hover:text-gray-200 transition-all"
@ -65,15 +65,20 @@
{{ productStore.cart.total_value }}
</p>
</div>
<UiButtonArrow class="w-full" type="fill" :arrow="true" :full="true">{{ $t('to_checkout') }}
<UiButtonArrow @click="() => {
menuStore.navigateToItem(menuStore.menuItems?.find((item) => item.id === 12))
openCart = false
}" class="w-full" type="fill" :arrow="true" :full="true">{{
$t('to_checkout') }}
</UiButtonArrow>
</div>
<div v-else
class="w-full p-[50px] bg-bg-light dark:bg-bg-dark border border-button rounded-[32px] h-[400px] flex items-center justify-center">
class="w-full p-[25px] sm:p-[50px] bg-bg-light dark:bg-bg-dark border border-button rounded-xl sm:rounded-[32px] flex items-center justify-center">
<div
class="border border-block inline-flex items-center justify-center w-[30%] h-[200px] rounded-[8px]">
<h4 class="font-inter text-base leading-[150%] font-bold uppercase sm:text-[20px] md:text-xl">
košík je prázdný</h4>
class="border border-block inline-flex items-center justify-center h-[140px] sm:h-[200px] text-center rounded-[8px]">
<h4
class="font-inter text-base leading-[150%] font-bold uppercase sm:text-[20px] px-4 sm:px-10 md:text-xl">
{{ $t('empty_cart') }}</h4>
</div>
</div>
</div>
@ -83,10 +88,11 @@
<script lang="ts" setup>
import { onClickOutside } from "@vueuse/core";
const count = ref(1)
const productStore = useProductStore()
const openCart = ref(false);
const menuStore = useMenuStore()
const dropdownRef = ref(null);
onClickOutside(dropdownRef, () => {
openCart.value = false

View File

@ -12,7 +12,7 @@
<div class="flex flex-col items-start gap-1">
<p>{{ $t("country") }}</p>
<div
class="bg-inherit w-full ring-0 cursor-pointer focus:ring-0 outline-none focus-visible:ring-0 space-y-1">
class="w-full ring-0 cursor-pointer focus:ring-0 outline-none focus-visible:ring-0 space-y-1">
<div class="p-0" @click="openDrop('country')">
<div class="flex items-center gap-2 text-xl font-medium uppercase text-text-light dark:text-text-dark">
{{ $session.currentCountryIso }} <span> <i
@ -21,7 +21,7 @@
</div>
<div v-if="dropCountry"
class="rounded-[5px] data-highlighted:not-data-disabled:before:bg-button/50 bg-inherit ring-0 cursor-pointer w-full focus:ring-0 outline-none focus-visible:ring-0 border border-button py-[10px] px-[5px]">
class="bg-bg-light dark:bg-bg-dark rounded-[5px] data-highlighted:not-data-disabled:before:bg-button/50 ring-0 cursor-pointer w-full focus:ring-0 outline-none focus-visible:ring-0 border border-button py-[10px] px-[5px]">
<div class="overflow-auto h-[200px] w-full">
<p @click="() => { $session.setCountry(item.iso_code); $session.loadSession(); dropCountry = false }"
class="w-full hover:bg-block dark:hover:bg-button pl-2 py-2 text-base text-text-light dark:text-text-dark rounded-[5px]"
@ -35,7 +35,7 @@
<div class="flex flex-col items-start gap-[6px]">
<p>{{ $t("currency") }}</p>
<div
class="bg-inherit w-full ring-0 cursor-pointer focus:ring-0 outline-none focus-visible:ring-0 space-y-1">
class="w-full ring-0 cursor-pointer focus:ring-0 outline-none focus-visible:ring-0 space-y-1">
<div class="p-0" @click="openDrop()">
<div class="flex items-center gap-2 text-xl font-medium uppercase text-text-light dark:text-text-dark">
{{ $session.currentCurrencyIso }}<span> <i
@ -44,7 +44,7 @@
</div>
<div v-if="dropCurrency"
class="rounded-[5px] data-highlighted:not-data-disabled:before:bg-button/50 bg-inherit ring-0 cursor-pointer w-full focus:ring-0 outline-none focus-visible:ring-0 border border-button py-[10px] px-[5px]">
class="bg-bg-light dark:bg-bg-dark rounded-[5px] data-highlighted:not-data-disabled:before:bg-button/50 ring-0 cursor-pointer w-full focus:ring-0 outline-none focus-visible:ring-0 border border-button py-[10px] px-[5px]">
<div class="overflow-auto w-full">
<p @click="() => { $session.setCurrency(item.iso_code); $session.loadSession(); dropCurrency = false }"
class="w-full hover:bg-block dark:hover:bg-button pl-1 py-2 text-base text-text-light dark:text-text-dark rounded-[5px]"

View File

@ -18,7 +18,15 @@
</ClientOnly>
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-[30px]">
<i class="uil uil-user text-[31px] cursor-pointer"></i>
<div>
<i v-if="!userStore.isLogged"
@click="menuStore.navigateToItem(menuStore.menuItems?.find((item) => item.id === 11))"
class="uil uil-user text-[31px] cursor-pointer"></i>
<div v-else class="py-[6px] px-3 border border-block rounded-sm">
<!-- {{ userStore.user }} -->
Arina Yakovenko
</div>
</div>
<CartPopup />
</div>
<div class="flex">
@ -73,7 +81,7 @@
{{ item.front_menu_lang[0].name }}
</div>
<!-- <i class="uil uil-arrow-up-right text-[35px]"></i> -->
<svg class="" width="20" height="20" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="20" height="20" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M25.1274 1.87258C25.1274 1.3203 24.6797 0.872582 24.1274 0.872584L15.1274 0.872583C14.5751 0.872583 14.1274 1.3203 14.1274 1.87258C14.1274 2.42487 14.5751 2.87258 15.1274 2.87258L23.1274 2.87258L23.1274 10.8726C23.1274 11.4249 23.5751 11.8726 24.1274 11.8726C24.6797 11.8726 25.1274 11.4249 25.1274 10.8726L25.1274 1.87258ZM1.5 24.5L2.20711 25.2071L24.8345 2.57969L24.1274 1.87258L23.4203 1.16548L0.792893 23.7929L1.5 24.5Z"
fill="currentColor" />
@ -118,7 +126,7 @@
{{ item.front_menu_lang[0].name }}
</div>
<!-- <i class="uil uil-arrow-up-right text-[35px]"></i> -->
<svg class="" width="20" height="20" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="20" height="20" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M25.1274 1.87258C25.1274 1.3203 24.6797 0.872582 24.1274 0.872584L15.1274 0.872583C14.5751 0.872583 14.1274 1.3203 14.1274 1.87258C14.1274 2.42487 14.5751 2.87258 15.1274 2.87258L23.1274 2.87258L23.1274 10.8726C23.1274 11.4249 23.5751 11.8726 24.1274 11.8726C24.6797 11.8726 25.1274 11.4249 25.1274 10.8726L25.1274 1.87258ZM1.5 24.5L2.20711 25.2071L24.8345 2.57969L24.1274 1.87258L23.4203 1.16548L0.792893 23.7929L1.5 24.5Z"
fill="currentColor" />
@ -178,7 +186,7 @@
{{ item.front_menu_lang[0].name }}
</div>
<!-- <i class="uil uil-arrow-up-right text-[35px]"></i> -->
<svg class="" width="20" height="20" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="20" height="20" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M25.1274 1.87258C25.1274 1.3203 24.6797 0.872582 24.1274 0.872584L15.1274 0.872583C14.5751 0.872583 14.1274 1.3203 14.1274 1.87258C14.1274 2.42487 14.5751 2.87258 15.1274 2.87258L23.1274 2.87258L23.1274 10.8726C23.1274 11.4249 23.5751 11.8726 24.1274 11.8726C24.6797 11.8726 25.1274 11.4249 25.1274 10.8726L25.1274 1.87258ZM1.5 24.5L2.20711 25.2071L24.8345 2.57969L24.1274 1.87258L23.4203 1.16548L0.792893 23.7929L1.5 24.5Z"
fill="currentColor" />
@ -215,6 +223,7 @@ import CountryCurrencySelector from "./CountryCurrencySelector.vue";
import LangSwitcher from "./LangSwitcher.vue";
const menuStore = useMenuStore();
const userStore = useUserStore();
const productStore = useProductStore();
const open = ref(false);
const colorMode = useColorMode();

View File

@ -0,0 +1,6 @@
<template>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Modi perspiciatis adipisci quam odio natus odit excepturi
eveniet vitae. Fugit dicta officiis quos quia debitis perspiciatis porro ducimus earum placeat sunt?
</template>
<script lang="ts"></script>

View File

@ -1,7 +1,254 @@
<template>
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Veritatis dignissimos impedit eligendi quaerat. Doloribus
eius nisi facere suscipit pariatur reprehenderit reiciendis tempora est accusamus adipisci hic voluptate asperiores,
voluptas enim.
<UiContainer>
<div class="w-[85%] mx-auto">
<div v-if="userStore.isLogged" class="space-25-55">
<div class="w-full flex items-center justify-center">
<div class="flex justify-between">
<div class="flex items-center gap-[25px] text-gray dark:text-button-disabled">
<div class="px-6 py-3 mx-auto">
{{ $t("login") }}
</div>
<div
class="cursor-pointer transition-all text-inter hover:bg-button-hover bg-button text-white font-medium rounded-xl px-6 py-3">
{{ $t("address") }}
</div>
<div class="px-6 py-3 mx-auto">
{{ $t("summary") }}
</div>
<div class="px-6 py-3 mx-auto">
{{ $t("order_placed") }}
</div>
</div>
</div>
</div>
<div class="space-y-[30px]">
<h2 class="h2-bold-bounded">
{{ $t("Account address") }}
</h2>
<div class="flex flex-col gap-[30px] xl:flex-row">
<div class="flex flex-col w-1/2 gap-[30px]">
<CheckoutInput v-model="checkoutStore.userName" :id="1" disabled>{{ $t("first_name")
}} </CheckoutInput>
<CheckoutInput v-model="checkoutStore.lastName" :id="2" disabled>{{ $t("surname")
}} </CheckoutInput>
<CheckoutInput v-model="checkoutStore.address" :id="3" disabled>{{ $t("address")
}} </CheckoutInput>
<CheckoutInput v-model="checkoutStore.postCode" :id="4" disabled>{{ $t("post_code")
}} </CheckoutInput>
</div>
<div class="flex flex-col w-1/2 gap-[30px]">
<CheckoutInput v-model="checkoutStore.city" :id="5" disabled>{{ $t("city")
}} </CheckoutInput>
<CheckoutInput v-model="checkoutStore.country" :id="6" disabled>{{ $t("country")
}} </CheckoutInput>
<CheckoutInput v-model="checkoutStore.accountPhoneNumber" :id="7" disabled>{{ $t("phone")
}} </CheckoutInput>
</div>
</div>
</div>
<div class="space-y-[30px]">
<h2 class="h2-bold-bounded">
{{ $t("Shipping details") }}
</h2>
<div class="relative border border-block rounded-lg px-6 h-[67px] w-full">
<div class="flex items-center gap-[10px]">
<div class="flex items-center gap-[25px]" v-if="!checkoutStore.vUseAccountPhoneNumber">
<div class="flex flex-col items-start gap-[25px]">
<div ref="dropdownIsoRef"
class="pl-[25px] relative w-full ring-0 cursor-pointer focus:ring-0 outline-none focus-visible:ring-0">
<div class="p-0" @click="dropIso = !dropIso">
<div
class="flex items-center gap-2 text-xl font-medium uppercase text-text-light dark:text-text-dark">
{{ checkoutStore.selectedIso.name }} <span> <i
class="uil uil-angle-down text-2xl font-light cursor-pointer"></i></span>
</div>
</div>
<div v-if="dropIso"
class="absolute w-full mt-2 left-0 bg-bg-light dark:bg-bg-dark rounded-[5px] data-highlighted:not-data-disabled:before:bg-button/50 ring-0 cursor-pointer focus:ring-0 outline-none focus-visible:ring-0 border border-button py-[10px] px-[5px]">
<div class="overflow-auto h-[200px] w-full">
<p @click="() => { checkoutStore.selectedIso = item; dropIso = false; checkoutStore.changePrefix(item.call_prefix as string) }"
class="w-full hover:bg-block dark:hover:bg-button pl-2 py-2 text-base text-text-light dark:text-text-dark rounded-[5px]"
v-for="item in menuStore.countries" :key="item.iso_code">
{{ item?.name }}
</p>
</div>
</div>
</div>
</div>
<p class="text-xl">{{ checkoutStore.currentPrefix }}</p>
</div>
<input
:value="checkoutStore.vUseAccountPhoneNumber ? checkoutStore.accountPhoneNumber : checkoutStore.phoneNumber"
:disabled="checkoutStore.vUseAccountPhoneNumber" @input="(e) => {
if (!checkoutStore.vUseAccountPhoneNumber) {
checkoutStore.phoneNumber = (e.target as HTMLInputElement).value;
}
}" type="tel" placeholder="123 xxxx xxx"
class="placeholder:text-xl placeholder:text-gray placeholder:uppercase dark:placeholder:text-bg-light rounded-lg h-[67px] w-full focus:outline-none focus:ring-0 focus:border-0" />
</div>
<p v-if="checkoutStore.phoneValidation === false && !checkoutStore.vUseAccountPhoneNumber"
class="text-red-500">Invalid phone number</p>
</div>
<div class="flex items-center gap-2">
<input @change="(event: Event) => {
const target = event.target as HTMLInputElement
target.checked ? checkoutStore.vUseAccountPhoneNumber = true : checkoutStore.vUseAccountPhoneNumber = false
checkoutStore.phoneValidation = null
}" type="checkbox" class="border border-button !bg-inherit" />
<p>{{ $t('use_account_phone') }}</p>
</div>
</div>
<div class="space-y-[30px]">
<h2 class="h2-bold-bounded">
{{ $t("Select delivery address") }}
</h2>
<div class="flex items-center justify-center gap-[25px] h-[225px]">
<div class="w-[500px] flex flex-col gap-4 h-full">
<div v-for="(item, index) in checkoutStore.addressesList" :key="index"
:class="['flex h-full flex-col py-[15px] px-[25px] gap-[15px] rounded-lg border-2', checkoutStore.activeAddress === item ? 'border-button' : 'border-block']">
<div
:class="['flex flex-col justify-between mt-1 h-full', checkoutStore.activeAddress !== item && 'text-gray dark:text-button-disabled']">
<span>{{ item.address.name }} {{ item.address.surname }}</span>
<span>{{ item.address.street }}</span>
<span>{{ item.address.postcode }} {{ item.address.city }}</span>
<span>{{ item.address.country_iso }}</span>
</div>
<div class="flex items-center gap-2 border-t pt-[15px] border-block">
<input :checked="checkoutStore.activeAddress ? true : false" @change="(event: Event) => {
const target = event.target as HTMLInputElement
target.checked ? checkoutStore.activeAddress = item : checkoutStore.activeAddress = null;
checkoutStore.isOpen = false;
}" type="checkbox" class="border border-button !bg-inherit" />
<p>{{ $t('choose_default_address') }}</p>
</div>
</div>
</div>
<p class="uppercase">{{ $t("or") }}</p>
<div @click="() => {
checkoutStore.isOpen = !checkoutStore.isOpen
checkoutStore.activeAddress = null
}"
:class="['cursor-pointer w-[500px] py-[15px] px-[25px] rounded-lg border-2 flex flex-col items-center justify-center h-full', checkoutStore.isOpen ? 'border-button text-button' : 'text-gray border-block ']">
<h4
:class="['font-inter text-base leading-[150%] uppercase text-[16px] sm:text-[20px] border-b', checkoutStore.isOpen ? 'border-button' : 'border-gray']">
{{ $t("add_new_address") }}
</h4>
</div>
</div>
<div v-if="checkoutStore.isOpen"
class="flex flex-col items-center gap-[30px] justify-center w-full">
<div class="flex flex-col gap-[30px] xl:flex-row w-full">
<div class="flex flex-col w-1/2 gap-[30px]">
<CheckoutInput v-model="checkoutStore.vNewAddressName" :placeholder="$t('first_name')"
:id="8">{{ $t("first_name") }}
</CheckoutInput>
<CheckoutInput v-model="checkoutStore.vNewAddressAddress" :placeholder="$t('address')"
:id="9">{{ $t("address") }}
</CheckoutInput>
<CheckoutInput v-model="checkoutStore.vNewAddressCity" :placeholder="$t('city')"
:id="10">{{ $t("city") }}
</CheckoutInput>
</div>
<div class="flex flex-col w-1/2 gap-[30px]">
<CheckoutInput v-model="checkoutStore.vNewAddressSurname" :placeholder="$t('surname')"
:id="11">{{ $t("surname") }}
</CheckoutInput>
<div class="space-y-[15px]">
<p class="pl-6">
{{ $t("country") }}
</p>
<div ref="dropdownCountryRef"
class="relative w-full ring-0 cursor-pointer focus:ring-0 outline-none focus-visible:ring-0">
<div class="border border-block placeholder:text-gray dark:placeholder:text-button-disabled rounded-lg px-6 h-[67px] w-full focus:outline-none focus:ring-0 focus:border-2 flex items-center justify-start"
@click="dropCountry = !dropCountry">
<div
class="flex items-center gap-2 text-xl font-medium uppercase text-text-light dark:text-text-dark">
{{ checkoutStore.vNewAddressCountry ?
checkoutStore.vNewAddressCountry.name : '-' }} <span> <i
class="uil uil-angle-down text-2xl font-light cursor-pointer"></i></span>
</div>
</div>
<div v-if="dropCountry"
class="absolute z-50 w-full mt-2 left-0 bg-bg-light dark:bg-bg-dark rounded-[5px] data-highlighted:not-data-disabled:before:bg-button/50 ring-0 cursor-pointer focus:ring-0 outline-none focus-visible:ring-0 border border-button py-[10px] px-[5px]">
<div class="overflow-auto h-[200px] w-full">
<p @click="() => { checkoutStore.vNewAddressCountry = item; dropCountry = false }"
class="w-full hover:bg-block dark:hover:bg-button pl-2 py-2 text-base text-text-light dark:text-text-dark rounded-[5px]"
v-for="item in menuStore.countries" :key="item.iso_code">
{{ item?.name }}
</p>
</div>
</div>
</div>
</div>
<CheckoutInput v-model="checkoutStore.vNewAddressCode" :placeholder="$t('post_code')"
:id="13">{{ $t("post_code") }}
</CheckoutInput>
</div>
</div>
<div>
<span v-if="addressValidation === false" class="text-red"> {{
$t("Remember to select a shipping address") }}</span>
<div class="group flex cursor-pointer items-center justify-start gap-2 whitespace-nowrap">
<button @click="checkoutStore.uploadAddress()"
:class="['h-[40px] cursor-pointer min-w-40 rounded-[10px] px-[22px] transition-all sm:h-[50px] md:h-[65px] md:rounded-[15px] md:px-[42px] bg-button text-text-dark group-hover:bg-button-hover']">
{{ $t("add_new_address") }}
</button>
</div>
</div>
</div>
</div>
<div class="flex justify-center">
<div class="group flex cursor-pointer items-center justify-start gap-2 whitespace-nowrap">
<button @click="checkoutStore.sendForm()" :disabled="!checkoutStore.activeAddress"
:class="['h-[40px] cursor-pointer min-w-40 rounded-[10px] px-[22px] transition-all sm:h-[50px] md:h-[65px] md:rounded-[15px] md:px-[42px]', checkoutStore.activeAddress ? 'bg-button text-text-dark group-hover:bg-button-hover' : ' bg-button-disabled text-gray']">
{{ $t("continue") }}
</button>
<div
:class="['flex h-[40px] w-[40px] items-center justify-center rounded-[10px] p-2.5 transition-all sm:h-[50px] sm:w-[50px] md:h-[65px] md:w-[65px] md:rounded-[15px]', checkoutStore.activeAddress ? 'bg-button text-text-dark group-hover:bg-button-hover' : ' bg-button-disabled text-gray']">
<svg class="" width=" 26" height="26" viewBox="0 0 26 26" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M25.1274 1.87258C25.1274 1.3203 24.6797 0.872582 24.1274 0.872584L15.1274 0.872583C14.5751 0.872583 14.1274 1.3203 14.1274 1.87258C14.1274 2.42487 14.5751 2.87258 15.1274 2.87258L23.1274 2.87258L23.1274 10.8726C23.1274 11.4249 23.5751 11.8726 24.1274 11.8726C24.6797 11.8726 25.1274 11.4249 25.1274 10.8726L25.1274 1.87258ZM1.5 24.5L2.20711 25.2071L24.8345 2.57969L24.1274 1.87258L23.4203 1.16548L0.792893 23.7929L1.5 24.5Z"
fill="currentColor" />
</svg>
</div>
</div>
</div>
</div>
</div>
</UiContainer>
</template>
<script lang="ts" setup></script>
<script setup lang="ts">
import { LazyColorScheme } from '#components';
import CheckoutInput from '../ui/CheckoutInput.vue';
import { onClickOutside } from "@vueuse/core";
const checkoutStore = useCheckoutStore();
const userStore = useUserStore()
const menuStore = useMenuStore()
const dropIso = ref(false)
const dropCountry = ref(false)
const addressValidation = ref<null | boolean>(null);
const dropdownIsoRef = ref(null);
const dropdownCountryRef = ref(null);
onClickOutside(dropdownIsoRef, () => {
dropIso.value = false
});
onClickOutside(dropdownCountryRef, () => {
dropCountry.value = false
});
checkoutStore.getCheckout()
checkoutStore.getAddressList()
checkoutStore.getUserData()
</script>

View File

@ -1,7 +1,7 @@
<template>
<UiContainer class="flex py-20 sm:py-14">
<UiContainer v-if="!userStore.vCodeVerify" class="flex py-20 sm:py-14">
<div class="hidden xl:block rounded-2xl min-w-[60%] h-[830px]" :style="{
backgroundImage: `url('/api/files/${component.image_collection}/${component.section_id}/${component.section_img[0]}?thumb=1200x0')`,
backgroundImage: `url('/api/public/file/${component.img[0]}_l.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}" />
@ -9,46 +9,88 @@
<div class="space-25-55">
<div class="flex flex-wrap-reverse gap-y-4 justify-between">
<h2 class="h2-bold-bounded">{{ $t('login') }}</h2>
<button
<button @click="menuStore.navigateToItem()"
class="h-[40px] sm:h-[43px] px-[10px] sm:px-[17px] border border-gray dark:border-button-disabled text-gray dark:text-button-disabled hover:bg-gray transition-all hover:text-white rounded-[8px] cursor-pointer">{{
$t('back_to_home') }}</button>
</div>
<div class="space-y-[15px]">
<p class="pl-6">{{ $t('email') }}</p>
<input v-model="store.email" :placeholder="$t('email')" type="text"
<input v-model="userStore.email" :placeholder="$t('email')" type="text"
class="border-2 border-block placeholder:text-gray dark:placeholder:text-button-disabled text-bg-dark dark:text-bg-light rounded-lg px-6 h-[50px] sm:h-[67px] w-full focus:outline-none focus:ring-0 focus:border-2" />
</div>
<div class="space-y-[15px]">
<p class="pl-6">{{ $t('password') }}</p>
<input v-model="store.password" :placeholder="$t('placeholder_password')" type="text"
<input v-model="userStore.password" :placeholder="$t('placeholder_password')" type="text"
class="border-2 border-block placeholder:text-gray dark:placeholder:text-button-disabled text-bg-dark dark:text-bg-light rounded-lg px-6 h-[50px] sm:h-[67px] w-full focus:outline-none focus:ring-0 focus:border-2" />
</div>
</div>
<p class="text-button hover:text-button-hover transition-all font-medium mt-[30px] cursor-pointer">{{
$t('forgot_password_question')
}}</p>
}}</p>
<div class="py-[25px] sm:py-12 border-b border-gray flex justify-center w-full">
<UiButtonArrow @click="store.logIn()" type="fill" :arrow="true">{{ $t('login') }}</UiButtonArrow>
<UiButtonArrow @click="userStore.logIn()" type="fill" :arrow="true">{{ $t('login') }}</UiButtonArrow>
</div>
<div class="mt-[25px] sm:mt-[30px] w-full flex justify-center gap-3">
<p class="cursor-pointer hover:underline transition-all">{{
$t('no_account')
}}</p>
}}</p>
<p class="text-button cursor-pointer hover:text-button-hover">{{
$t('sign_up_now')
}}</p>
}}</p>
</div>
</div>
</UiContainer>
<UiContainer v-if="userStore.vCodeVerify" class="flex py-20 sm:py-14">
<div class="hidden xl:block rounded-2xl min-w-[60%] h-[830px]" :style="{
backgroundImage: `url('/api/public/file/${component.img[0]}_l.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}" />
<div class="w-full sm:w-[80%] mx-auto my-auto xl:w-full xl:px-12 ">
<div class="space-25-55">
<div class="flex flex-wrap-reverse gap-y-4 justify-between">
<h2 class="h2-bold-bounded">{{ $t('verify_login') }}</h2>
<button @click="menuStore.navigateToItem()"
class="h-[40px] sm:h-[43px] px-[10px] sm:px-[17px] border border-gray dark:border-button-disabled text-gray dark:text-button-disabled hover:bg-gray transition-all hover:text-white rounded-[8px] cursor-pointer">{{
$t('back_to_home') }}</button>
</div>
<div class="space-y-[30px]">
<p>{{ $t('send_code') }} <span class="text-button font-semibold">{{ userStore.email }}</span> {{
$t('by_email') }}
</p>
<div class="space-y-[15px]">
<p class="pl-6">{{ $t('code') }}</p>
<input v-model="userStore.vCode" :placeholder="$t('code')" type="text"
class="border-2 border-block placeholder:text-gray dark:placeholder:text-button-disabled text-bg-dark dark:text-bg-light rounded-lg px-6 h-[50px] sm:h-[67px] w-full focus:outline-none focus:ring-0 focus:border-2" />
</div>
</div>
</div>
<div class="py-[25px] sm:py-12 flex justify-center w-full">
<UiButtonArrow @click="userStore.sendFormCode(true)" type="fill" :arrow="true">{{ $t('confirm') }}
</UiButtonArrow>
</div>
</div>
</UiContainer>
</template>
<script lang="ts" setup>
defineProps<{ component: Component }>();
type Component = {
image_collection: string;
section_id: string;
section_img: string;
section_lang_data: {};
};
const store = useStore()
defineProps<{
component: {
id: number
name: string
img: string[]
component_name: string
is_no_lang: boolean
page_name: string
front_section_lang: {
data: {
}
id_front_section: number
id_lang: number
}[]
}
}>();
const userStore = useUserStore()
const menuStore = useMenuStore()
</script>

View File

@ -17,22 +17,12 @@
<p>{{ item.description }}</p>
<h4 class="h4-uppercase-bold-inter">{{ item.sub_title }}</h4>
</div>
<div
class="h-[235px] sm:h-[350px] w-full rounded-[20px] bg-cover bg-center transition-transform duration-300 group-hover:scale-105 xl:block relative"
:style="{
backgroundImage: `url('/api/public/file/${component.img[index]}_l.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}">
<div
class="hidden sm:block absolute bottom-0 right-0 pt-2 pl-2 bg-bg-light dark:bg-bg-dark rounded-tl-2xl">
<UiImgWrapper :src="`/api/public/file/${component.img[index]}_l.webp`">
<template #button>
<UiButtonArrow :arrow="true">{{ item.title }}</UiButtonArrow>
</div>
</div>
<UiButtonArrow :arrow="true" class="sm:hidden mx-auto">{{
item.title
}}</UiButtonArrow>
</template>
</UiImgWrapper>
</div>
<!-- Map block with same layout rules -->

View File

@ -10,18 +10,15 @@
backgroundImage: `url('/api/public/file/${component.img[0]}_l.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}"
/>
<div
class="flex flex-col items-center space-y-[30px] sm:flex-row sm:items-start md:space-y-0 xl:items-center"
>
}" />
<div class="flex flex-col items-center space-y-[30px] sm:flex-row sm:items-start md:space-y-0 xl:items-center">
<h3 class="h4-uppercase-bold-inter sm:min-w-[45%]">
{{ component.front_section_lang[0].data.title_second }}
</h3>
<div class="flex w-full items-start justify-center sm:justify-end">
<UiButtonArrow :arrow="true" type="fill">{{
component.front_section_lang[0].data.button
}}</UiButtonArrow>
}}</UiButtonArrow>
</div>
</div>
</div>
@ -29,22 +26,25 @@
</template>
<script lang="ts" setup>
defineProps<{ component: {
id: number
name: string
img: string[]
component_name: string
is_no_lang: boolean
page_name: string
front_section_lang: {
data: {
title: string
button: string
title_second: string
}
id_front_section: number
id_lang: number
}[]
} }>();
defineProps<{
component: {
id: number
name: string
img: string[]
component_name: string
is_no_lang: boolean
page_name: string
front_section_lang: {
data: {
title: string
button: string
title_second: string
}
id_front_section: number
id_lang: number
}[]
}
}>();
</script>

View File

@ -2,7 +2,7 @@
<div
class="w-[150px] sm:w-[260px] md:w-[330px] px-2 py-3 sm:py-5 sm:px-[15px] bg-block rounded-2xl flex flex-col items-center gap-[15px] sm:gap-[50px]">
<img :src="`https://www.yourgold.cz/api/public/file/${props.product?.cover_picture_uuid}.webp`" alt="Product Image"
class="max-h-[95px] sm:max-h-[180px] md:max-h-[205px] rounded-[5px]"
class="h-[95px] sm:h-[180px] md:h-[205px] rounded-[5px]"
onerror="this.onerror=null; this.src='/photo.svg';" />
<div class="flex flex-col justify-between h-full w-full gap-[7px] sSm:gap-[15px]">

View File

@ -1,91 +1,135 @@
<template>
<SectionShopPageCurrencyRatesBar class="mb-[25px] sm:mb-[55px] xl:mb-[75px]" />
<UiContainer>
<div class="flex flex-col gap-[25px] sm:gap-10 xl:flex-row">
<!-- button to open categories -->
<div class="xl:hidden flex items-center w-full">
<button @click="openCategories = !openCategories"
class="h-[40px] w-full cursor-pointer rounded-[10px] px-[22px] transition-all sm:h-[50px] md:h-[65px] md:rounded-[15px] md:px-[42px] bg-button text-text-dark group-hover:bg-button-hover">
Otevřené kategorie a filtry
</button>
</div>
<SectionShopPageCurrencyRatesBar
class="mb-[25px] sm:mb-[55px] xl:mb-[75px]"
/>
<UiContainer>
<div class="flex flex-col gap-[25px] sm:gap-10 xl:flex-row">
<!-- button to open categories -->
<div class="xl:hidden flex items-center w-full">
<button
class="h-[40px] w-full cursor-pointer rounded-[10px] px-[22px] transition-all sm:h-[50px] md:h-[65px] md:rounded-[15px] md:px-[42px] bg-button text-text-dark group-hover:bg-button-hover"
@click="openCategories = !openCategories"
>
Otevřené kategorie a filtry
</button>
</div>
<Transition>
<div v-if="openCategories" class="min-w-[250px] px-5 sm:p-0 xl:hidden">
<h1 class="font-bounded leading-[140%] font-bold text-[24px] mb-[25px]">
{{ $t("category") }}
</h1>
<div class="flex flex-col gap-[25px]">
<div>
<div v-if="categoriesList && categoriesList.length < 1" class="animate-pulse">
<div
class="flex items-center justify-between mt-4 text-white rounded-lg cursor-pointer xl:pr-24">
<div class="w-32 h-4 bg-gray-200 rounded"></div>
<div class="w-4 h-4 bg-gray-200 rounded-full"></div>
</div>
</div>
<CategoryTree :data="categoriesList" @change-category="changeCategory($event)"
:active="categoryId" />
</div>
<div>
<p class="mb-[25px] text-lg font-extrabold text-black dark:text-white">
{{ $t("filtered_by") }}
</p>
<div v-for="(item, itemIndex) in filters" :key="itemIndex"
:class="['mb-[30px]', visibleFeatures[item.feature] && 'border-b border-block pb-2']">
<span
class="flex justify-between items-center font-bold cursor-pointer mb-[25px] text-base"
@click="toggleFeature(item.feature)">
{{ item.feature }}
<span :class="[visibleFeatures[item.feature] && 'rotate-180', 'transition-all']"><i
class="iconify i-lucide:chevron-down text-button shrink-0 size-6 ms-auto"></i></span>
</span>
<ul v-show="visibleFeatures[item.feature]" class="flex flex-col gap-5">
<li v-for="filter in item.feature_values" :key="filter.value_id"
class="flex items-center gap-[10px] cursor-pointer">
<input :id="`${filter.value_id}`" :value="`${filter.parent}.${filter.value_id}`"
v-model="selectedFilters" type="checkbox"
class="border-button !bg-inherit" />
<label :for="`${filter.value_id}`"
class="cursor-pointer flex items-center justify-between w-full text-base">
<span>{{ filter.value }}</span>
<span>12</span>
</label>
</li>
</ul>
</div>
</div>
</div>
<Transition>
<div v-if="openCategories" class="min-w-[250px] px-5 sm:p-0 xl:hidden">
<h1
class="font-bounded leading-[140%] font-bold text-[24px] mb-[25px]"
>
{{ $t("category") }}
</h1>
<div class="flex flex-col gap-[25px]">
<div>
<div
v-if="categoriesList && categoriesList.length < 1"
class="animate-pulse"
>
<div
class="flex items-center justify-between mt-4 text-white rounded-lg cursor-pointer xl:pr-24"
>
<div class="w-32 h-4 bg-gray-200 rounded" />
<div class="w-4 h-4 bg-gray-200 rounded-full" />
</div>
</Transition>
</div>
<CategoryTree
:data="categoriesList"
:active="categoryId"
@change-category="changeCategory($event)"
/>
</div>
<div>
<p
class="mb-[25px] text-lg font-extrabold text-black dark:text-white"
>
{{ $t("filtered_by") }}
</p>
<!-- categories -->
<div class="min-w-[250px] hidden xl:block">
<h1 class="font-bounded leading-[140%] font-bold text-[40px] mb-[55px]">
{{ $t("category") }}
</h1>
<div class="flex flex-col gap-12">
<div>
<div
v-for="(item, itemIndex) in filters"
:key="itemIndex"
:class="[
'mb-[30px]',
visibleFeatures[item.feature] && 'border-b border-block pb-2',
]"
>
<span
class="flex justify-between items-center font-bold cursor-pointer mb-[25px] text-base"
@click="toggleFeature(item.feature)"
>
{{ item.feature }}
<span
:class="[
visibleFeatures[item.feature] && 'rotate-180',
'transition-all',
]"
><i
class="iconify i-lucide:chevron-down text-button shrink-0 size-6 ms-auto"
/></span>
</span>
<ul
v-show="visibleFeatures[item.feature]"
class="flex flex-col gap-5"
>
<li
v-for="filter in item.feature_values"
:key="filter.value_id"
class="flex items-center gap-[10px] cursor-pointer"
>
<input
:id="`${filter.value_id}`"
v-model="selectedFilters"
:value="`${filter.parent}.${filter.value_id}`"
type="checkbox"
class="border-button !bg-inherit"
/>
<label
:for="`${filter.value_id}`"
class="cursor-pointer flex items-center justify-between w-full text-base"
>
<span>{{ filter.value }}</span>
<span>12</span>
</label>
</li>
</ul>
</div>
</div>
</div>
</div>
</Transition>
<div v-if="categoriesList && categoriesList.length < 1" class="animate-pulse">
<div
class="flex items-center justify-between mt-4 text-white rounded-lg cursor-pointer xl:pr-24">
<div class="w-32 h-4 bg-gray-200 rounded"></div>
<div class="w-4 h-4 bg-gray-200 rounded-full"></div>
</div>
</div>
<CategoryTree :data="categoriesList" @change-category="changeCategory($event)"
:active="categoryId" />
</div>
<div>
<p class="mb-10 text-2xl font-extrabold text-black dark:text-white">
{{ $t("filtered_by") }}
</p>
<!-- categories -->
<div class="min-w-[250px] hidden xl:block">
<h1 class="font-bounded leading-[140%] font-bold text-[40px] mb-[55px]">
{{ $t("category") }}
</h1>
<div class="flex flex-col gap-12">
<div>
<div
v-if="categoriesList && categoriesList.length < 1"
class="animate-pulse"
>
<div
class="flex items-center justify-between mt-4 text-white rounded-lg cursor-pointer xl:pr-24"
>
<div class="w-32 h-4 bg-gray-200 rounded" />
<div class="w-4 h-4 bg-gray-200 rounded-full" />
</div>
</div>
<CategoryTree
:data="categoriesList"
:active="categoryId"
@change-category="changeCategory($event)"
/>
</div>
<div>
<p class="mb-10 text-2xl font-extrabold text-black dark:text-white">
{{ $t("filtered_by") }}
</p>
<!-- <div v-if="filters.length < 1" class="mb-8 text-white animate-pulse">
<!-- <div v-if="filters.length < 1" class="mb-8 text-white animate-pulse">
<div v-for="i in 5"
class="mt-10 flex justify-between font-bold text-black cursor-pointer 2xl:pr-24 dark:text-gray">
<div class="w-32 h-4 bg-gray-200 rounded"></div>
@ -93,110 +137,162 @@
</div>
</div> -->
<div v-for="(item, itemIndex) in filters" :key="itemIndex"
:class="['mb-[30px]', visibleFeatures[item.feature] && 'border-b border-block pb-2']">
<span class="flex justify-between items-center font-bold cursor-pointer mb-[25px]"
@click="toggleFeature(item.feature)">
{{ item.feature }}
<span :class="[visibleFeatures[item.feature] && 'rotate-180', 'transition-all']"><i
class="iconify i-lucide:chevron-down text-button shrink-0 size-6 ms-auto"></i></span>
</span>
<ul v-show="visibleFeatures[item.feature]" class="flex flex-col gap-5">
<li v-for="filter in item.feature_values" :key="filter.value_id"
class="flex items-center gap-[10px] cursor-pointer">
<!-- <input :id="`${filter.value_id}`" :value="`${filter.parent}.${filter.value_id}`"
<div
v-for="(item, itemIndex) in filters"
:key="itemIndex"
:class="[
'mb-[30px]',
visibleFeatures[item.feature] && 'border-b border-block pb-2',
]"
>
<span
class="flex justify-between items-center font-bold cursor-pointer mb-[25px]"
@click="toggleFeature(item.feature)"
>
{{ item.feature }}
<span
:class="[
visibleFeatures[item.feature] && 'rotate-180',
'transition-all',
]"
><i
class="iconify i-lucide:chevron-down text-button shrink-0 size-6 ms-auto"
/></span>
</span>
<ul
v-show="visibleFeatures[item.feature]"
class="flex flex-col gap-5"
>
<li
v-for="filter in item.feature_values"
:key="filter.value_id"
class="flex items-center gap-[10px] cursor-pointer"
>
<!-- <input :id="`${filter.value_id}`" :value="`${filter.parent}.${filter.value_id}`"
v-model="selectedFilters" type="checkbox" class="border-button !bg-inherit" />
<label :for="`${filter.value_id}`" class="cursor-pointer">{{ filter.value }}</label> -->
<input :id="`${filter.value_id}`" :value="`${filter.parent}.${filter.value_id}`"
v-model="selectedFilters" type="checkbox" class="border-button !bg-inherit" />
<label :for="`${filter.value_id}`"
class="cursor-pointer flex items-center justify-between w-full">
<span>{{ filter.value }}</span>
<span>12</span>
</label>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="w-full space-y-10">
<!-- pop-up -->
<div v-if="isInfo"
class="w-full xl:w-[70%] mx-auto border-y border-block py-[15px] sm:p-[30px] flex gap-[55px] relative">
<UButton @click="closeElement()" size="xl" icon="i-lucide-x" variant="ghost"
class="p-0 absolute right-0 top-2 sm:right-2 sm:top-2 cursor-pointer text-button font-light hover:bg-inherit hover:text-button-hover" />
<div class="flex flex-col sm:flex-row gap-[25px]">
<div class="flex flex-col justify-between gap-[25px]">
<h4 class="font-inter text-lg sm:text-[24px] leading-[150%] md:leading-[120%] font-bold">
{{ component.front_section_lang[0].data.title }}
</h4>
<p>{{ component.front_section_lang[0].data.description }}</p>
</div>
<img class="max-w-[150px] mx-auto" :src="`/api/public/file/${component.img[0]}_m.webp')`" />
</div>
</div>
<div v-if="products.length < 1" class="grid gap-12 pt-32 pb-16 md:grid-cols-2 2xl:grid-cols-3">
<TheProductSkeleton v-for="index in 6"></TheProductSkeleton>
</div>
<!-- products -->
<div v-else ref="loadingElement" class="flex flex-wrap justify-center gap-5 sm:gap-10">
<Product v-for="product in products" :key="product.id" :product="product" />
</div>
<div v-if="reachedEnd" class="w-full flex justify-center">
<p>
{{ $t("FrontTranslations", "You reached end of the list.") }}
</p>
</div>
<input
:id="`${filter.value_id}`"
v-model="selectedFilters"
:value="`${filter.parent}.${filter.value_id}`"
type="checkbox"
class="border-button !bg-inherit"
/>
<label
:for="`${filter.value_id}`"
class="cursor-pointer flex items-center justify-between w-full"
>
<span>{{ filter.value }}</span>
<span>12</span>
</label>
</li>
</ul>
</div>
</div>
</div>
</UiContainer>
</div>
<div class="w-full space-y-10">
<!-- pop-up -->
<div
v-if="isInfo"
class="w-full xl:w-[70%] mx-auto border-y border-block py-[15px] sm:p-[30px] flex gap-[55px] relative"
>
<UButton
variant="ghost"
class="p-0 absolute right-0 top-2 sm:right-2 sm:top-2 cursor-pointer text-button font-light hover:bg-inherit hover:text-button-hover"
size="xl"
icon="i-lucide-x"
@click="closeElement()"
/>
<div class="flex flex-col sm:flex-row gap-[25px]">
<div class="flex flex-col justify-between gap-[25px]">
<h4
class="font-inter text-lg sm:text-[24px] leading-[150%] md:leading-[120%] font-bold"
>
{{ component.front_section_lang[0].data.title }}
</h4>
<p>{{ component.front_section_lang[0].data.description }}</p>
</div>
<img
class="max-w-[150px] mx-auto"
:src="`/api/public/file/${component.img[0]}_m.webp')`"
/>
</div>
</div>
<div
v-if="products.length < 1"
class="grid gap-12 pt-32 pb-16 md:grid-cols-2 2xl:grid-cols-3"
>
<TheProductSkeleton v-for="index in 6" :key="index" />
</div>
<!-- products -->
<div
v-else
ref="loadingElement"
class="flex flex-wrap justify-center gap-5 sm:gap-10"
>
<Product
v-for="product in products"
:key="product.id"
:product="product"
/>
</div>
<div v-if="reachedEnd" class="w-full flex justify-center">
<p>
{{ $t("FrontTranslations", "You reached end of the list.") }}
</p>
</div>
</div>
</div>
</UiContainer>
</template>
<script setup lang="ts">
import { ref } from "vue";
import Product from "./Product.vue";
import type { Feature, GenericResponse, GenericResponseChildren, GenericResponseItems, ProductType } from "~/types";
import type {
Feature,
GenericResponse,
GenericResponseChildren,
GenericResponseItems,
ProductType,
} from "~/types";
import CategoryTree from "./CategoryTree.vue";
const { $session } = useNuxtApp();
watch(() => $session.cookieData, async () => await getProducts(), { deep: true });
const props = defineProps<{
component: {
id: number
name: string
img: string[]
component_name: string
is_no_lang: boolean
page_name: string
front_section_lang: {
data: {
title: string;
description: string
}
id_front_section: number
id_lang: number
}[]
}
watch(
() => $session.cookieData,
async () => await getProducts(),
{ deep: true }
);
defineProps<{
component: {
id: number;
name: string;
img: string[];
component_name: string;
is_no_lang: boolean;
page_name: string;
front_section_lang: {
data: {
title: string;
description: string;
};
id_front_section: number;
id_lang: number;
}[];
};
}>();
const openCategories = ref(false);
const isInfo = ref<boolean>(true);
const selectedFilters = ref<any>([]);
const categoryId = ref<number>(1);
const itemsCount = ref(0);
const loading = ref(false);
const reachedEnd = ref(false);
@ -209,249 +305,253 @@ const maxElements = ref(0);
const products = ref([] as ProductType[]);
async function getProducts() {
try {
const { data } = await useMyFetch<GenericResponseItems<ProductType[]>>(
`/api/public/products/category/${categoryId.value}?p=${page.value}&elems=${elems.value}`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
try {
const { data } = await useMyFetch<GenericResponseItems<ProductType[]>>(
`/api/public/products/category/${categoryId.value}?p=${page.value}&elems=${elems.value}`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
products.value = data.items;
maxElements.value = data.items_count + 1;
} catch (error) {
console.error("getProducts error:", error);
}
products.value = data.items;
maxElements.value = data.items_count + 1;
} catch (error) {
console.error("getProducts error:", error);
}
}
const filters = ref([] as Feature[]);
async function getCategory() {
try {
const { data } = await useMyFetch<GenericResponse<object>>(
`/api/public/products/category/1/classification`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
try {
const { data } = await useMyFetch<GenericResponse<object>>(
`/api/public/products/category/1/classification`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
filters.value = data as Feature[];
filters.value.forEach((el) => {
const parentId = el.feature_id;
el.feature_values.forEach((el) => {
el.parent = parentId;
});
});
} catch (error) {
console.error("getCategory error:", error);
}
filters.value = data as Feature[];
filters.value.forEach((el: Feature) => {
const parentId = el.feature_id;
el.feature_values.forEach((el) => {
el.parent = parentId;
});
});
} catch (error) {
console.error("getCategory error:", error);
}
}
const categoriesList = ref();
async function getCategoryTree() {
try {
const { data } = await useMyFetch<GenericResponseChildren<ProductType[]>>(
`/api/public/categories/tree`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
try {
const { data } = await useMyFetch<GenericResponseChildren<ProductType[]>>(
`/api/public/categories/tree`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
categoriesList.value = data.children;
} catch (error) {
console.error("getCategory error:", error);
}
categoriesList.value = data.children;
} catch (error) {
console.error("getCategory error:", error);
}
}
getProducts()
getCategory()
getCategoryTree()
getProducts();
getCategory();
getCategoryTree();
const closeElement = () => {
isInfo.value = false;
isInfo.value = false;
};
onMounted(() => {
window.addEventListener("scroll", scrollEvent);
window.addEventListener("scroll", scrollEvent);
});
onUnmounted(() => {
window.removeEventListener("scroll", scrollEvent);
window.removeEventListener("scroll", scrollEvent);
});
async function scrollEvent(e: Event) {
let maxScrollY = window.scrollY || document.documentElement.scrollHeight - document.documentElement.clientHeight;
async function scrollEvent() {
const maxScrollY =
window.scrollY ||
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
if (window.scrollY >= maxScrollY - 500 && !reachedEnd.value && !loading.value) {
loading.value = true;
await loadMoreProducts();
loading.value = false;
}
if (
window.scrollY >= maxScrollY - 500 &&
!reachedEnd.value &&
!loading.value
) {
loading.value = true;
await loadMoreProducts();
loading.value = false;
}
}
filters.value.forEach((item) => {
visibleFeatures[item.feature] = false;
visibleFeatures[item.feature] = false;
});
const visibleFeatures = reactive<any>({});
function toggleFeature(feature: any) {
if (visibleFeatures.hasOwnProperty(feature)) {
visibleFeatures[feature] = !visibleFeatures[feature];
} else {
visibleFeatures[feature] = true;
}
const visibleFeatures = reactive<Record<string, boolean>>({});
function toggleFeature(feature: string) {
if (feature in visibleFeatures) {
visibleFeatures[feature] = !visibleFeatures[feature];
} else {
visibleFeatures[feature] = true;
}
}
class FilteredQueryString extends URLSearchParams {
override append(name: string, value: string): void {
if (value == null) {
return;
}
super.append(name, value);
override append(name: string, value: string): void {
if (value == null) {
return;
}
super.append(name, value);
}
}
async function loadMoreProducts() {
let qParams = new FilteredQueryString();
const qParams = new FilteredQueryString();
page.value = page.value + 1;
page.value = page.value + 1;
qParams.append("p", `${page.value}`);
qParams.append("elems", `${elems.value}`);
qParams.append("features", selectedFilters.value.length > 0 ? selectedFilters.value : null);
qParams.append("p", `${page.value}`);
qParams.append("elems", `${elems.value}`);
qParams.append(
"features",
selectedFilters.value.length > 0 ? selectedFilters.value : null
);
try {
const { data } = await useMyFetch<GenericResponseItems<ProductType[]>>(
`/api/public/products/category/${categoryId.value}?${qParams.toString()}`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
try {
const { data } = await useMyFetch<GenericResponseItems<ProductType[]>>(
`/api/public/products/category/${categoryId.value}?${qParams.toString()}`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
maxElements.value = data.items_count;
maxElements.value = data.items_count;
if (data.items) {
products.value.push(...(data.items as ProductType[]));
} else {
reachedEnd.value = true;
}
if (products.value.length >= maxElements.value) {
reachedEnd.value = true;
}
} catch (error) {
console.error("getCategory error:", error);
if (data.items) {
products.value.push(...(data.items as ProductType[]));
} else {
reachedEnd.value = true;
}
if (products.value.length >= maxElements.value) {
reachedEnd.value = true;
}
} catch (error) {
console.error("getCategory error:", error);
}
}
const changeCategory = (item: any) => {
categoryId.value = item.id;
categoryId.value = item.id;
};
watch(selectedFilters, async (newQuestion) => {
if (newQuestion) {
page.value = 1;
reachedEnd.value = false;
loadingElement.value?.scrollIntoView();
watch(selectedFilters, async (newQuestion: string) => {
if (newQuestion) {
page.value = 1;
reachedEnd.value = false;
loadingElement.value?.scrollIntoView();
let qParams = new FilteredQueryString();
const qParams = new FilteredQueryString();
qParams.append("p", `${page.value}`);
qParams.append("elems", `${elems.value}`);
qParams.append("features", selectedFilters.value.length > 0 ? selectedFilters.value : null);
qParams.append("p", `${page.value}`);
qParams.append("elems", `${elems.value}`);
qParams.append(
"features",
selectedFilters.value.length > 0 ? selectedFilters.value : null
);
try {
const { data } = await useMyFetch<GenericResponseItems<ProductType[]>>(
`/api/public/products/category/1?${qParams.toString()}`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
products.value = data.items;
maxElements.value = data.items_count;
} catch (error) {
console.error("selectedFilters error:", error);
try {
const { data } = await useMyFetch<GenericResponseItems<ProductType[]>>(
`/api/public/products/category/1?${qParams.toString()}`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
products.value = data.items;
maxElements.value = data.items_count;
} catch (error) {
console.error("selectedFilters error:", error);
}
}
});
watch(categoryId, async (newQuestion) => {
if (newQuestion) {
page.value = 1;
reachedEnd.value = false;
loadingElement.value?.scrollIntoView();
watch(categoryId, async (newCategoryId) => {
if (newCategoryId) {
page.value = 1;
reachedEnd.value = false;
loadingElement.value?.scrollIntoView();
let qParams = new FilteredQueryString();
const qParams = new FilteredQueryString();
qParams.append("p", `${page.value}`);
qParams.append("elems", `${elems.value}`);
qParams.append(
"features",
selectedFilters.value.length > 0 ? selectedFilters.value : null
);
qParams.append("p", `${page.value}`);
qParams.append("elems", `${elems.value}`);
qParams.append("features", selectedFilters.value.length > 0 ? selectedFilters.value : null);
try {
const { data } = await useMyFetch<GenericResponseItems<ProductType[]>>(
`api/public/products/category/${categoryId.value}?${qParams.toString()}`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
products.value = data.items;
maxElements.value = data.items_count;
} catch (error) {
console.error("getCategory error:", error);
try {
const { data } = await useMyFetch<GenericResponseItems<ProductType[]>>(
`api/public/products/category/${newCategoryId}?${qParams.toString()}`,
{
headers: { "Content-Type": "application/json" },
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
products.value = data.items;
maxElements.value = data.items_count;
} catch (error) {
console.error("getCategory error:", error);
}
}
});
</script>
<style scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
opacity: 0;
}
</style>
</style>

View File

@ -1,41 +1,26 @@
<template>
<div
class="group flex cursor-pointer items-center justify-start gap-2 whitespace-nowrap"
>
<button
:class="[
'h-[40px] cursor-pointer rounded-[10px] px-[22px] transition-all sm:h-[50px] md:h-[65px] md:rounded-[15px] md:px-[42px]',
type === 'fill'
? 'bg-button text-text-dark group-hover:bg-button-hover'
: type === 'border'
<div class="group flex cursor-pointer items-center justify-start gap-2 whitespace-nowrap">
<button :class="[
'h-[40px] cursor-pointer min-w-40 rounded-[10px] px-[22px] transition-all sm:h-[50px] md:h-[65px] md:rounded-[15px] md:px-[42px]',
type === 'fill'
? 'bg-button text-text-dark group-hover:bg-button-hover'
: type === 'border'
? 'border-button text-button group-hover:border-button-hover group-hover:text-button-hover border'
: 'border-button text-button dark:border-block dark:text-block group-hover:border-button-hover group-hover:text-button-hover border',
full && 'w-full'
]"
>
full && 'w-full'
]">
<slot />
</button>
<div
v-if="arrow"
:class="[
'flex h-[40px] w-[40px] items-center justify-center rounded-[10px] p-2.5 transition-all sm:h-[50px] sm:w-[50px] md:h-[65px] md:w-[65px] md:rounded-[15px]',
type === 'fill'
? 'bg-button text-text-dark group-hover:bg-button-hover'
: 'border-button text-button dark:border-block dark:text-block group-hover:border-button-hover group-hover:text-button-hover border',
]"
>
<svg
class=""
width="26"
height="26"
viewBox="0 0 26 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<div v-if="arrow" :class="[
'flex h-[40px] w-[40px] items-center justify-center rounded-[10px] p-2.5 transition-all sm:h-[50px] sm:w-[50px] md:h-[65px] md:w-[65px] md:rounded-[15px]',
type === 'fill'
? 'bg-button text-text-dark group-hover:bg-button-hover'
: 'border-button text-button dark:border-block dark:text-block group-hover:border-button-hover group-hover:text-button-hover border',
]">
<svg class="" width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M25.1274 1.87258C25.1274 1.3203 24.6797 0.872582 24.1274 0.872584L15.1274 0.872583C14.5751 0.872583 14.1274 1.3203 14.1274 1.87258C14.1274 2.42487 14.5751 2.87258 15.1274 2.87258L23.1274 2.87258L23.1274 10.8726C23.1274 11.4249 23.5751 11.8726 24.1274 11.8726C24.6797 11.8726 25.1274 11.4249 25.1274 10.8726L25.1274 1.87258ZM1.5 24.5L2.20711 25.2071L24.8345 2.57969L24.1274 1.87258L23.4203 1.16548L0.792893 23.7929L1.5 24.5Z"
fill="currentColor"
/>
fill="currentColor" />
</svg>
</div>
</div>

View File

@ -0,0 +1,57 @@
<template>
<div class="space-y-[15px]">
<p :for="`base-input-${id}`" class="pl-6">
<slot />
</p>
<div class="flex flex-col">
<div class="flex relative">
<input :id="`base-input-${id}`" :value="modelValue" :type="!isPasswordVisible ? type : 'text'"
:placeholder="placeholder" :disabled="disabled"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@focus="$emit('focus')" @blur="$emit('blur')"
class="border border-block placeholder:text-gray dark:placeholder:text-button-disabled rounded-lg px-6 h-[67px] w-full focus:outline-none focus:ring-0 focus:border-2" />
<i v-if="disabled"
class="uil uil-lock-alt text-[22px] absolute right-6 top-1/2 -translate-y-1/2 text-gray" />
<div v-if="type === 'password'" class="order-2 ml-1.5 cursor-pointer" :title="!isPasswordVisible ? $t('show_password') : $t('hide_password')
" @click="isPasswordVisible = !isPasswordVisible">
<FaceObserver class="ml-4 text-xl leading-6" :isPasswordVisible="isPasswordVisible" />
</div>
</div>
<!-- <p class="mt-2 text-xs text-red-600">{{ validationText }}</p> -->
<!-- <p v-if="!validation && validation != null" class="mt-2 text-xs text-red-600">
{{ validationText }}
</p> -->
</div>
</div>
</template>
<script setup lang="ts">
import FaceObserver from './FaceObserver.vue';
defineEmits(["update:modelValue", "focus", "blur"]);
defineProps<{
modelValue?: string | any;
modelModifiers?: object;
id: number;
type?: string;
disabled?: boolean;
placeholder?: string;
validation?: boolean | null;
validationText?: string;
}>();
const isPasswordVisible = ref(false);
</script>
<style>
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
transition: background-color 5000s ease-in-out 0s;
}
</style>

View File

@ -0,0 +1,15 @@
<template>
<svg v-if="isPasswordVisible" xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 -960 960 960" width="16px" fill="#000000"><path d="m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 -960 960 960" width="16px" fill="#000000"><path d="M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z"/></svg>
</template>
<script>
export default {
props: {
isPasswordVisible: {
type: Boolean,
required: true
}
}
};
</script>

View File

@ -0,0 +1,29 @@
<template>
<div class="hidden md:block">
<svg width="100%" height="100%" viewBox="0 0 870 350" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="customClip">
<path
d="M20 0.5H847.666C858.366 0.5 867.067 9.12193 867.165 19.8213L869.315 254.821C869.415 265.66 860.656 274.5 849.816 274.5H653C641.678 274.5 632.5 283.678 632.5 295V330C632.5 340.77 623.77 349.5 613 349.5H20C9.23045 349.5 0.5 340.77 0.5 330V20C0.5 9.23045 9.23045 0.5 20 0.5Z" />
</clipPath>
</defs>
<image :href="src" clip-path="url(#customClip)" preserveAspectRatio="xMidYMid slice" width="100%"
height="100%" />
<foreignObject x="640" y="285" width="calc(100% - 640px - 1px)" height="calc(100% - 285px)">
<slot name="button" />
</foreignObject>
</svg>
</div>
<div class="block md:hidden">
<img :src="src" width="100%" height="100%" class="object-contain rounded-2xl my-4" />
<div class="flex justify-center">
<slot name="button" />
</div>
</div>
</template>
<script lang="ts" setup>
defineProps<{ src: string }>()
</script>

View File

@ -3,18 +3,6 @@ import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({
compatibilityDate: "2024-11-01",
devtools: { enabled: false },
// app: {
// pageTransition: { name: "page", mode: "out-in" },
// },
// nitro: {
// routeRules: {
// "/api/**": {
// proxy: {
// to: `${process.env.POCKETBASE_URL || "http://127.0.0.1:8090"}/api/**`,
// },
// },
// },
// },
modules: [
"@pinia/nuxt",
@ -53,6 +41,7 @@ export default defineNuxtConfig({
ignored: ["**/backend/pb_data/**"],
},
hmr: {
host: "127.0.0.1",
clientPort: 3000, // useful if proxying
},
},

View File

@ -17,6 +17,7 @@
"@pinia/nuxt": "^0.11.0",
"@tailwindcss/vite": "^4.1.8",
"@vueuse/core": "^13.3.0",
"gsap": "^3.13.0",
"nuxt": "^3.17.4",
"tailwindcss": "^4.1.7",
"vue": "^3.5.14",

View File

@ -6,6 +6,62 @@
</template>
<script setup>
import { gsap } from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
watch(useColorMode(), (color) => {
console.log(color);
})
onMounted(() => {
const anim = gsap.fromTo(
'h1',
{
opacity: 0,
zoom: 0.95
},
{
opacity: 1,
duration: 1,
zoom: 1,
ease: 'power2.out',
}
)
ScrollTrigger.create({
trigger: 'h1',
start: 'top 80%',
onEnter: () => anim.restart(), // play when scrolling down
onEnterBack: () => anim.restart(), // play again when scrolling up
})
const animh2 = gsap.fromTo(
'h2',
{
// opacity: 0,
// color: 'var(--color-accent-green-light)',
},
{
// opacity: 1,
// duration: 1,
ease: 'power2.out',
}
)
ScrollTrigger.create({
trigger: 'h2',
start: 'top 80%',
onEnter: () => animh2.restart(), // play when scrolling down
onEnterBack: () => animh2.restart(), // play again when scrolling up
})
})
const route = useRoute();
const store = useStore();
const menuStore = useMenuStore();

258
stores/checkoutStore.ts Normal file
View File

@ -0,0 +1,258 @@
import type { GenericResponse } from "~/types";
import type { AddressesList, UserAddressOfficial } from "~/types/checkout";
import { validation } from "../utils/validation";
import { REGEX_PHONE } from "../utils/regex";
export const useCheckoutStore = defineStore("checkoutStore", () => {
const { $toast } = useNuxtApp();
const menuStore = useMenuStore();
const selectedIso = ref(menuStore.selectedCountry);
// get address list
const addressesList = ref<AddressesList[]>();
const activeAddress = ref<AddressesList | null>();
async function getAddressList() {
try {
const { data } = await useMyFetch<GenericResponse<AddressesList[]>>(
`/api/restricted/user/addresses`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: async (_, status) => {
throw createError({
statusCode: status,
statusMessage: `HTTP error: ${status}`,
});
},
}
);
addressesList.value = data;
activeAddress.value = addressesList.value[0];
} catch (error) {
console.error("restrictedAddress error:", error);
}
}
// get user data
const userName = ref("");
const lastName = ref("");
const address = ref("");
const postCode = ref("");
const city = ref("");
const country = ref("");
const phoneNumber = ref("");
const accountPhoneNumber = ref("");
async function getUserData() {
try {
const { data } = await useMyFetch<GenericResponse<UserAddressOfficial>>(
`/api/restricted/user/address/official`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: async (_, status) => {
throw createError({
statusCode: status,
statusMessage: `HTTP error: ${status}`,
});
},
}
);
userName.value = data.address.name;
lastName.value = data.address.surname;
address.value = data.address.street;
postCode.value = data.address.postcode;
city.value = data.address.city;
country.value = data.address.country.country_lang[0].name;
} catch (error) {
console.error("getUserData error:", error);
}
}
// upload new address
const vNewAddressName = ref("");
const vNewAddressSurname = ref("");
const vNewAddressAddress = ref("");
const vNewAddressCode = ref("");
const vNewAddressCity = ref("");
const vNewAddressCountry = ref();
const vUseAccountPhoneNumber = ref(false);
const isOpen = ref<boolean>(false);
async function uploadAddress() {
try {
const res = await useMyFetch<GenericResponse<object>>(
`/api/restricted/user/address`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
address: {
city: vNewAddressCity.value,
country_iso: vNewAddressCountry.value.iso_code,
name: vNewAddressName.value,
postcode: vNewAddressCode.value,
street: vNewAddressAddress.value,
surname: vNewAddressSurname.value,
},
}),
onErrorOccured: async (_, status) => {
throw createError({
statusCode: status,
statusMessage: `HTTP error: ${status}`,
});
},
}
);
if (res.status === 200) {
$toast.success("Address successfully added", {
autoClose: 5000,
dangerouslyHTMLString: true,
});
isOpen.value = false;
getAddressList();
} else {
$toast.error("Failed to add address. Please try again.", {
autoClose: 5000,
dangerouslyHTMLString: true,
});
}
} catch (error) {
console.error("uploadAddress error:", error);
}
}
const currentPrefix = ref<string | number>(
menuStore.selectedCountry.call_prefix
);
const changePrefix = (item: string) => {
currentPrefix.value = item;
};
const phoneValidation = ref<boolean | null>(null);
const userStore = useUserStore();
// send form
async function sendForm() {
let phoneNum = vUseAccountPhoneNumber.value
? accountPhoneNumber.value
: `${currentPrefix.value}${phoneNumber.value}`.replaceAll(" ", "").trim();
// if (vUseAccountPhoneNumber.value) {
// phoneNum = phoneNumber.value;
// }
phoneValidation.value = validation(phoneNum, 1, 49, REGEX_PHONE);
if (!phoneValidation.value && !vUseAccountPhoneNumber.value) {
return;
}
try {
const res = await useMyFetch<GenericResponse<object>>(
`/api/restricted/cart/checkout/delivery`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
address: {
city: activeAddress.value?.address.city,
country_iso: activeAddress.value?.address.country_iso,
name: activeAddress.value?.address.name,
postcode: activeAddress.value?.address.postcode,
street: activeAddress.value?.address.street,
surname: activeAddress.value?.address.surname,
},
phone_number: phoneNum,
email: userStore.fullUserData?.email,
}),
onErrorOccured: async (_, status) => {
throw createError({
statusCode: status,
statusMessage: `HTTP error: ${status}`,
});
},
}
);
if (res.status === 200) {
$toast.success("Form successfully sent", {
autoClose: 5000,
dangerouslyHTMLString: true,
});
// redirectToSummary();
} else {
$toast.error("Failed to send form. Please try again.", {
autoClose: 5000,
dangerouslyHTMLString: true,
});
}
} catch (error) {
console.error("uploadAddress error:", error);
}
}
const changeActive = (item: any) => {
activeAddress.value = item;
};
async function getCheckout() {
try {
const res = await useMyFetch<GenericResponse<object>>(
`/api/restricted/cart/checkout`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
onErrorOccured: async (_, status) => {
throw createError({
statusCode: status,
statusMessage: `HTTP error: ${status}`,
});
},
}
);
} catch (error) {
console.error("uploadAddress error:", error);
}
}
return {
addressesList,
activeAddress,
isOpen,
selectedIso,
phoneValidation,
userName,
lastName,
address,
postCode,
city,
country,
phoneNumber,
accountPhoneNumber,
vUseAccountPhoneNumber,
currentPrefix,
vNewAddressName,
vNewAddressSurname,
vNewAddressAddress,
vNewAddressCode,
vNewAddressCity,
vNewAddressCountry,
changePrefix,
getCheckout,
getAddressList,
getUserData,
changeActive,
uploadAddress,
sendForm,
};
});

View File

@ -56,7 +56,6 @@ export const useMenuStore = defineStore("menuStore", () => {
const currencies = ref([] as Currency[]);
const languages = ref([] as Language[]);
const getLocales = async () => {
const { data: countriesList } = await useMyFetch<GenericResponse<Country[]>>(`/api/public/country/list`);
countries.value = countriesList;
@ -96,7 +95,6 @@ export const useMenuStore = defineStore("menuStore", () => {
console.warn("Root menu item not found");
menu.value = [];
}
} catch (error) {
console.log(error);
}

View File

@ -8,7 +8,7 @@ import type { FrontPageSection } from "~/types/frontSection";
export const useStore = defineStore("store", () => {
const currentPageID = ref("");
const { $toast } = useNuxtApp();
// calculator
const monthlySavings = ref(137);
@ -16,19 +16,13 @@ export const useStore = defineStore("store", () => {
const totalInvestment: Ref<number> = ref(0);
const minValue = ref();
// login
const email = ref();
const password = ref();
const components = ref({} as FrontPageSection[]);
const getSections = async (id: string) => {
const { data } = await useMyFetch<GenericResponse<FrontPageSection[]>>(
`/api/public/front/sections/${id}`
)
components.value = data
);
components.value = data;
};
async function getComponents(): Promise<componentsListType[]> {
@ -56,7 +50,6 @@ export const useStore = defineStore("store", () => {
name: componentName,
component: child.front_section,
componentInstance: nonReactiveComponent,
});
} catch (error) {
console.error(`Failed to load component ${componentName}`, error);
@ -103,39 +96,12 @@ export const useStore = defineStore("store", () => {
}
);
minValue.value = data;
} catch (error) {
console.error("getList error:", error);
}
}
async function logIn() {
try {
const { data } = await useMyFetch<GenericResponse<object>>(
`/api/public/user/session/start`,
{
method: "POST",
body: JSON.stringify({
mail: email.value,
password: password.value,
}),
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
minValue.value = data;
} catch (error) {
console.error("getList error:", error);
}
}
return {
currentPageID,
components,
@ -143,9 +109,6 @@ export const useStore = defineStore("store", () => {
monthlySavings,
storagePeriod,
minValue,
email,
password,
logIn,
getCalculator,
getComponents,
getSections,

148
stores/userStore.ts Normal file
View File

@ -0,0 +1,148 @@
import type { GenericResponse } from "~/types";
import type { Customer } from "~/types/user";
export const useUserStore = defineStore("userStore", () => {
const store = useStore();
const menuStore = useMenuStore();
const checkoutStore = useCheckoutStore();
const { $toast } = useNuxtApp();
const fullUserData = ref<Customer | null>(null);
const isLogged = ref<boolean>(true);
const user = ref<string | null>(null);
async function checkIsLogged() {
try {
const { data } = await useMyFetch<GenericResponse<Customer>>(
`/api/public/user`,
{
headers: {
"Content-Type": "application/json",
},
onErrorOccured: async (_, status) => {
throw createError({
statusCode: status,
statusMessage: `HTTP error: ${status}`,
});
},
}
);
if ("loggedin" in data && data.loggedin === true) {
isLogged.value = true;
user.value = `${data.first_name} ${data.last_name}` as any;
fullUserData.value = data;
checkoutStore.accountPhoneNumber = fullUserData.value.phone_number;
} else {
isLogged.value = false;
user.value = null;
fullUserData.value = null;
}
} catch (error) {
console.error("checkIsLogged error:", error);
}
}
// login
const email = ref();
const password = ref();
const vLogin = ref<boolean>(true);
const vCodeVerify = ref<boolean>(false);
const vCode = ref<number | null>(null);
async function logIn() {
try {
const data = await useMyFetch<GenericResponse<object>>(
`/api/public/user/session/start`,
{
method: "POST",
body: JSON.stringify({
mail: email.value,
password: password.value,
}),
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
if (data.status === 200 || data.status === 201) {
console.log(vCodeVerify.value);
$toast.success("Code successfully sent to your email", {
autoClose: 5000,
dangerouslyHTMLString: true,
});
vLogin.value = false;
vCodeVerify.value = true;
} else {
$toast.error("Failed to sent code to your email. Please try again.", {
autoClose: 5000,
dangerouslyHTMLString: true,
});
}
store.minValue = data;
} catch (error) {
console.error("getList error:", error);
}
}
const sendFormCode = async (redirect?: boolean) => {
try {
const data = await useMyFetch<GenericResponse<object>>(
`/api/public/user/session/confirm`,
{
method: "POST",
body: JSON.stringify({
code: vCode.value,
mail: email.value,
}),
headers: {
"Content-Type": "application/json",
},
onErrorOccured: (_, status) => {
throw new Error(`HTTP error: ${status}`);
},
}
);
await checkIsLogged();
if (isLogged.value) {
if (redirect) {
console.log(isLogged.value);
menuStore.navigateToItem();
} else {
// window.location.href = atob(redirect);
}
} else {
useNuxtApp().$toast.error(`Error occurred: Failed to confirm code`, {
autoClose: 5000,
dangerouslyHTMLString: true,
});
}
} catch (e) {
useNuxtApp().$toast.error(`Invalid code provided`, {
autoClose: 5000,
dangerouslyHTMLString: true,
});
}
};
return {
isLogged,
user,
fullUserData,
vCodeVerify,
vCode,
email,
password,
logIn,
checkIsLogged,
sendFormCode,
};
});

32
types/checkout.ts Normal file
View File

@ -0,0 +1,32 @@
export interface AddressesList {
address: {
city: string;
country_iso: string;
name: string;
postcode: string;
street: string;
surname: string;
};
address_id: number;
alias: string;
customer_id: number;
is_default: boolean;
is_official: boolean;
}
export interface UserAddressOfficial {
address: {
name: string;
surname: string;
street: string;
postcode: string;
city: string;
country: {
country_lang: [
{
name: string;
}
];
};
};
}

49
types/user.ts Normal file
View File

@ -0,0 +1,49 @@
export interface Customer {
active: boolean;
agreed_for_newsletter: boolean;
bank_accounts: {
bank_currency_iso: string;
bank_name: string;
customer_id: number;
iban: string;
swift: string;
verified: boolean;
}[];
birthday_date: string;
communication_languag_id: number;
document_verified: boolean;
documents: {
file: string;
id: number;
name: string;
size: number;
typ: string;
}[];
email: string;
entity: {
city: string;
country_iso: string;
customer_id: number;
extra_enitity_id: string;
name: string;
national_court_register_number: string;
postcode: string;
statistical_number: string;
street: string;
vat_number: string;
web_pages_list: string;
}[];
first_name: string;
is_entity: boolean;
is_partner: boolean;
is_root: boolean;
last_name: string;
metadata: {
id: number;
metadata: string;
type: string;
}[];
partner_code: string;
phone_number: string;
taxes_country_iso: string;
}

34
utils/regex.js Normal file
View File

@ -0,0 +1,34 @@
const REGEX_EMAIL = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/i,
// Minimum eight characters, at least one uppercase letter, one lowercase letter, one number and one special character
// REGEX_PASSWORD = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
// REGEX_PASSWORD = /^(?=.*[A-Za-z])(?=.*\d)(?=.*\W)[A-Za-z\d^\S]{8,}$/,
REGEX_PASSWORD = new RegExp(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*\W)[A-Za-z\d^\S]{8,}$/),
REGEX_CODE = /.{6}/,
// REGEX_PHONE = /^\+?[1-9][0-9]{7,14}$/,
REGEX_PHONE = new RegExp(/^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/im),
REGEX_DATE = /^[+-]?\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/,
// Only numbers
REGEX_ONLYNUMBERS = /^[0-9]*$/,
// Number (price)
REGEX_NUMBER = /^(?!0*[.,]0*$|[.,]0*$|0*$)\d+[,.]?\d{0,2}$/,
REGEX_NUMBER_WITH_ZERO = /^[0-9]{1,10}([.][0-9]{1,2})?$/,
REGEX_URL = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/
// URL
// REGEX_URL = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/
export {
REGEX_EMAIL,
REGEX_PASSWORD,
REGEX_CODE,
REGEX_PHONE,
REGEX_ONLYNUMBERS,
REGEX_NUMBER,
REGEX_NUMBER_WITH_ZERO,
REGEX_DATE,
REGEX_URL,
}

12
utils/validation.ts Normal file
View File

@ -0,0 +1,12 @@
const validation = function (item:string, min:number, max:number, regEx = /.*/) {
if (
item == undefined ||
item.length < min ||
item.length > max ||
!regEx.test(item)
) return false;
else return true;
};
export { validation };