Merge pull request #174 from goteo/develop

Sync main to develop
This commit is contained in:
2025-11-24 09:23:56 +01:00
committed by GitHub
175 changed files with 10235 additions and 2074 deletions
+12 -25
View File
@@ -6,7 +6,8 @@ on:
jobs:
cypress-run:
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
environment:
name: ${{ github.ref_name == 'main' && 'main' || github.ref_name == 'develop' && 'develop' || 'working-branch' }}
steps:
- name: Checkout code
@@ -42,29 +43,15 @@ jobs:
- name: Verify Cypress installation
run: pnpm exec cypress verify
- name: Set API URL based on branch
id: api-url
run: |
if [[ "${{ github.ref_name }}" == "main" ]]; then
echo "API_URL=https://api.v4.goteo.org" >> $GITHUB_OUTPUT
echo "ENVIRONMENT=production" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref_name }}" == "develop" ]]; then
echo "API_URL=https://api-staging.v4.goteo.org" >> $GITHUB_OUTPUT
echo "ENVIRONMENT=staging" >> $GITHUB_OUTPUT
else
echo "API_URL=https://api-testing01.v4.goteo.org" >> $GITHUB_OUTPUT
echo "ENVIRONMENT=development" >> $GITHUB_OUTPUT
fi
- name: Set env vars
run: |
echo "CLOUDFLARE_INCLUDE_PROCESS_ENV='true'" >> .env
echo "PUBLIC_API_URL=${{ steps.api-url.outputs.API_URL }}" >> .env
echo "PUBLIC_API_VERSION='v4'" >> .env
echo "PUBLIC_DEFAULT_CURRENCY='EUR'" >> .env
echo "PUBLIC_DEFAULT_LANGUAGE='es'" >> .env
echo "PUBLIC_PLATONIQ_ACCOUNTING_ID='375592'" >> .env
echo "PUBLIC_FACEBOOK_APP_ID=''" >> .env
echo "CLOUDFLARE_INCLUDE_PROCESS_ENV='${{ vars.CLOUDFLARE_INCLUDE_PROCESS_ENV }}'" >> .env
echo "PUBLIC_API_URL=${{ vars.PUBLIC_API_URL }}" >> .env
echo "PUBLIC_API_VERSION='${{ vars.PUBLIC_API_VERSION }}'" >> .env
echo "PUBLIC_DEFAULT_CURRENCY='${{ vars.PUBLIC_DEFAULT_CURRENCY }}'" >> .env
echo "PUBLIC_DEFAULT_LANGUAGE='${{ vars.PUBLIC_DEFAULT_LANGUAGE }}'" >> .env
echo "PUBLIC_PLATONIQ_ACCOUNTING_ID='${{ vars.PUBLIC_PLATONIQ_ACCOUNTING_ID }}'" >> .env
echo "PUBLIC_FACEBOOK_APP_ID='${{ vars.PUBLIC_FACEBOOK_APP_ID }}'" >> .env
- name: Start server
run: |
@@ -90,7 +77,7 @@ jobs:
NODE_TLS_REJECT_UNAUTHORIZED: 0
CYPRESS_CONFIG_FILE: cypress.github.js
CYPRESS_MOCK_AUTH: true
CYPRESS_ENVIRONMENT: ${{ steps.api-url.outputs.ENVIRONMENT }}
CYPRESS_ENVIRONMENT: ${{ vars.ENVIRONMENT }}
- name: Debug server logs if tests failed
if: failure()
@@ -115,7 +102,7 @@ jobs:
uses: actions/upload-artifact@v4
if: failure() && hashFiles('cypress/screenshots/**/*') != ''
with:
name: cypress-screenshots-${{ steps.api-url.outputs.ENVIRONMENT }}
name: cypress-screenshots-${{ vars.ENVIRONMENT }}
path: cypress/screenshots
retention-days: 7
@@ -123,6 +110,6 @@ jobs:
uses: actions/upload-artifact@v4
if: failure() && hashFiles('cypress/videos/**/*') != ''
with:
name: cypress-videos-${{ steps.api-url.outputs.ENVIRONMENT }}
name: cypress-videos-${{ vars.ENVIRONMENT }}
path: cypress/videos
retention-days: 7
+3
View File
@@ -4,6 +4,9 @@ export default defineConfig({
e2e: {
baseUrl: "http://localhost:4321",
pageLoadTimeout: 120000,
defaultCommandTimeout: 60000,
requestTimeout: 60000,
responseTimeout: 60000,
setupNodeEvents(/* on, config */) {
// implement node event listeners here
},
@@ -5,15 +5,29 @@ describe("Reward for incorrect amount", () => {
cy.loginAs("user");
cy.on("uncaught:exception", () => false);
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/100", {
statusCode: 200,
@@ -83,7 +97,14 @@ describe("Reward for incorrect amount", () => {
cy.intercept("GET", "**/v4/**", {
statusCode: 200,
body: { accountingId: 123, id: 1 },
body: {
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
id: 1,
},
}).as("otherApiCalls");
cy.window().then((win) => {
@@ -92,8 +113,13 @@ describe("Reward for incorrect amount", () => {
JSON.stringify({
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
}),
);
});
@@ -102,7 +128,11 @@ describe("Reward for incorrect amount", () => {
"access-token",
JSON.stringify({
token: "mock-access-token-cypress-123",
accountingId: 123,
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
userId: 1,
}),
);
+23 -14
View File
@@ -2,30 +2,39 @@
describe("Correct Display of Total Fundraising Counter", () => {
beforeEach(() => {
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("POST", "**/api/auth/login", {
statusCode: 200,
cy.intercept("POST", "**/v4/user_tokens", {
statusCode: 201,
body: {
access_token: "mock-access-token-cypress-123",
refresh_token: "mock-refresh-token-cypress-456",
user: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
},
id: 1,
token: "mock-access-token-cypress-123",
owner: "/v4/users/1",
},
}).as("loginRequest");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/100", {
statusCode: 200,
body: {
+35 -30
View File
@@ -2,35 +2,44 @@
describe("Project Page - Donation and Reward Verification", () => {
beforeEach(() => {
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("POST", "**/api/auth/login", {
statusCode: 200,
cy.intercept("POST", "**/v4/user_tokens", {
statusCode: 201,
body: {
access_token: "mock-access-token-cypress-123",
refresh_token: "mock-refresh-token-cypress-456",
user: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
},
id: 1,
token: "mock-access-token-cypress-123",
owner: "/v4/users/1",
},
}).as("loginRequest");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/100", {
statusCode: 200,
body: {
id: 100,
title: "Proyecto de Prueba",
title: "Test Project",
amount: 12500,
minimal: 5000,
optimal: 25000,
@@ -38,7 +47,7 @@ describe("Project Page - Donation and Reward Verification", () => {
num_investors: 45,
status: "active",
currency: "EUR",
description: "Descripción del proyecto de prueba",
description: "Test project description",
owner: {
id: 1,
name: "Owner Test",
@@ -54,7 +63,7 @@ describe("Project Page - Donation and Reward Verification", () => {
id: 3827,
project: "/v4/projects/100",
title: 'CD "Al Paso de los Caracoles" + 2 Camisetas',
description: "CD físico del álbum junto con 2 camisetas oficiales",
description: "Physical CD of the album with 2 official t-shirts",
money: { amount: 4000, currency: "EUR" },
isFinite: true,
unitsTotal: 5,
@@ -114,12 +123,10 @@ describe("Project Page - Donation and Reward Verification", () => {
},
);
} else {
cy.log(
"Contenedor de recompensas específico no encontrado, verificando elementos alternativos",
);
cy.log("Specific rewards container not found, checking alternative elements");
cy.get("body").then(($body) => {
const expectedTexts = ["CD", "Caracoles", "Camisetas", "Dona", "40"];
const expectedTexts = ["CD", "Caracoles", "T-shirts", "Donate", "40"];
let foundTexts = 0;
expectedTexts.forEach((text) => {
@@ -130,20 +137,18 @@ describe("Project Page - Donation and Reward Verification", () => {
if (foundTexts >= 2) {
cy.log(
`✅ Encontrados ${foundTexts} de ${expectedTexts.length} textos de recompensa esperados`,
`✅ Found ${foundTexts} of ${expectedTexts.length} expected reward texts`,
);
if ($body.find("button:contains('Dona')").length > 0) {
cy.contains("button", "Dona").should("be.visible");
if ($body.find("button:contains('Donate')").length > 0) {
cy.contains("button", "Donate").should("be.visible");
}
if ($body.find("h3").length > 0) {
cy.get("h3").first().should("be.visible");
}
} else {
cy.log(
"ℹ️ Elementos específicos de recompensa no encontrados, pero la página cargó",
);
cy.log("ℹ️ Specific reward elements not found, but page loaded");
cy.get("body").should("not.contain", "Error 500");
}
});
@@ -160,13 +165,13 @@ describe("Project Page - Donation and Reward Verification", () => {
cy.get("body").then(($body) => {
if ($body.text().includes("CD") && $body.text().includes("Caracoles")) {
cy.log("✅ Datos de recompensa mock encontrados en la página");
cy.log("✅ Mock reward data found on page");
if ($body.find("button").length > 0) {
cy.get("button").should("have.length.greaterThan", 0);
}
} else {
cy.log("ℹ️ Verificando que los datos mock son correctos");
cy.log("ℹ️ Verifying that mock data is correct");
const mockReward = {
title: 'CD "Al Paso de los Caracoles" + 2 Camisetas',
amount: 4000,
@@ -176,7 +181,7 @@ describe("Project Page - Donation and Reward Verification", () => {
expect(mockReward.title).to.include("CD");
expect(mockReward.title).to.include("Caracoles");
expect(mockReward.amount).to.equal(4000);
cy.log("✅ Datos mock verificados correctamente");
cy.log("✅ Mock data verified correctly");
}
});
});
@@ -14,18 +14,14 @@ describe("Contribution with reward", () => {
cy.get("body").then(($body) => {
const text = $body.text();
if (text.includes("CD") || text.includes("Camiseta") || text.includes("físico")) {
cy.get("body").should("contain.text", text.includes("CD") ? "CD" : "Camiseta");
if (text.includes("CD") || text.includes("T-shirt") || text.includes("physical")) {
cy.get("body").should("contain.text", text.includes("CD") ? "CD" : "T-shirt");
}
if (
text.includes("Donar") ||
text.includes("contribuir") ||
text.includes("recompensa")
) {
if (text.includes("Donate") || text.includes("contribute") || text.includes("reward")) {
cy.get("body").should(
"contain.text",
text.includes("Donar") ? "Donar" : "contribuir",
text.includes("Donate") ? "Donate" : "contribute",
);
}
@@ -37,10 +33,10 @@ describe("Contribution with reward", () => {
cy.get("button").should("exist");
}
if (text.includes("proyecto") || text.includes("project")) {
if (text.includes("project") || text.includes("project")) {
cy.get("body").should(
"contain.text",
text.includes("proyecto") ? "proyecto" : "project",
text.includes("project") ? "project" : "project",
);
}
});
@@ -2,12 +2,25 @@
describe("Login with Incorrect Password", () => {
beforeEach(() => {
cy.intercept("POST", "**/v4/user_tokens", {
statusCode: 401,
body: {
"@context": "/v4/contexts/Error",
"@type": "hydra:Error",
"hydra:title": "An error occurred",
"hydra:description": "Invalid credentials",
},
}).as("loginFailure");
cy.on("uncaught:exception", () => false);
});
it("Login with valid email but incorrect password (Negative Case)", () => {
cy.visit("/login", { failOnStatusCode: false });
cy.wait(2000);
cy.visit("/login", {
failOnStatusCode: false,
timeout: 60000,
});
cy.wait(3000);
cy.get("body").should("exist");
@@ -28,27 +41,60 @@ describe("Login with Incorrect Password", () => {
cy.wait(3000);
cy.get("body").then(($bodyAfter) => {
if ($bodyAfter.find("#login-error-content").length > 0) {
cy.get("#login-error-content").should("be.visible");
cy.log("✅ Error de login mostrado correctamente");
} else {
cy.log(
"ℹ️ Error de login no encontrado en el DOM específico, verificando página",
);
const textAfter = $bodyAfter.text();
const errorIndicators = [
"#login-error-content",
".error",
".alert-error",
"[data-error]",
".text-red",
".text-error",
];
let errorElementFound = false;
errorIndicators.forEach((selector) => {
if (
$bodyAfter.find(selector).length > 0 &&
$bodyAfter.find(selector).is(":visible")
) {
errorElementFound = true;
cy.get(selector, { timeout: 10000 }).should("be.visible");
cy.log("✅ Login error displayed correctly");
}
});
if (!errorElementFound) {
cy.url().then((url) => {
if (url.includes("login")) {
cy.log(
"✅ Permanece en página de login, indicando fallo de autenticación",
"✅ Remains on login page, indicating authentication failure",
);
expect(url).to.include("login");
} else {
cy.log("ℹ️ Navegación inesperada después del login incorrecto");
cy.log("ℹ️ Checking error content in page text");
// Check for error text in page content
const hasErrorText =
textAfter.includes("error") ||
textAfter.includes("incorrect") ||
textAfter.includes("invalid") ||
textAfter.includes("incorrect");
if (hasErrorText) {
cy.log("✅ Error text found in page content");
expect(hasErrorText).to.be.true;
} else {
cy.log(
"ℹ️ No specific error indicators found, but login appears to have failed",
);
expect(true).to.be.true; // At least the page loaded
}
}
});
}
});
} else {
cy.log("ℹ️ Formulario de login no encontrado, página cargada de forma diferente");
cy.log("ℹ️ Login form not found, page loaded differently");
cy.get("body").should("not.contain", "Error 500");
}
});
@@ -5,15 +5,29 @@ describe("View project rewards", () => {
cy.loginAs("user");
cy.on("uncaught:exception", () => false);
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/project/100", {
statusCode: 200,
@@ -100,7 +114,14 @@ describe("View project rewards", () => {
cy.intercept("GET", "**/v4/**", {
statusCode: 200,
body: { accountingId: 123, id: 1 },
body: {
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
id: 1,
},
}).as("otherApiCalls");
cy.window().then((win) => {
@@ -109,8 +130,13 @@ describe("View project rewards", () => {
JSON.stringify({
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
}),
);
});
@@ -119,7 +145,11 @@ describe("View project rewards", () => {
"access-token",
JSON.stringify({
token: "mock-access-token-cypress-123",
accountingId: 123,
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
userId: 1,
}),
);
+6 -19
View File
@@ -2,7 +2,7 @@
describe("Homepage", () => {
beforeEach(() => {
cy.visit("/es");
cy.visit("/");
});
it("should display the main header components", () => {
@@ -19,19 +19,6 @@ describe("Homepage", () => {
cy.log("ℹ️ Main h1 not found but main exists");
}
});
cy.get("body").then(($body) => {
if ($body.find("select#language-select").length > 0) {
cy.get("select#language-select", { timeout: 5000 })
.should("exist")
.and("have.value", "es");
cy.changeLanguage("en");
cy.url().should("include", "/en");
} else {
cy.log("ℹ️ Language selector not found");
}
});
});
it("should have working language selector", () => {
@@ -50,8 +37,8 @@ describe("Homepage", () => {
it("should have a working cart button", () => {
cy.get("body").then(($body) => {
if ($body.find('header button[aria-label="Ir al checkout"]').length > 0) {
cy.get('header button[aria-label="Ir al checkout"]', { timeout: 5000 }).click();
if ($body.find('header button[aria-label="Go to checkout"]').length > 0) {
cy.get('header button[aria-label="Go to checkout"]', { timeout: 5000 }).click();
} else {
cy.log("ℹ️ Cart button not found");
}
@@ -60,11 +47,11 @@ describe("Homepage", () => {
it("should have proper accessibility attributes", () => {
cy.get("body").then(($body) => {
if ($body.find('header button[aria-label="Ir al checkout"]').length > 0) {
cy.get('header button[aria-label="Ir al checkout"]', { timeout: 5000 }).should(
if ($body.find('header button[aria-label="Go to checkout"]').length > 0) {
cy.get('header button[aria-label="Go to checkout"]', { timeout: 5000 }).should(
"have.attr",
"aria-label",
"Ir al checkout",
"Go to checkout",
);
} else {
cy.log("ℹ️ Checkout button not found");
+16 -24
View File
@@ -12,29 +12,27 @@ describe("Login Page", () => {
cy.get("body").then(($body) => {
const text = $body.text();
if (text.includes("Indícanos tus datos personales")) {
cy.contains("Indícanos tus datos personales", { timeout: 10000 }).should(
"be.visible",
);
if (text.includes("Tell us your personal data")) {
cy.contains("Tell us your personal data", { timeout: 10000 }).should("be.visible");
}
if (text.includes("Entra en tu cuenta")) {
cy.contains("Entra en tu cuenta", { timeout: 10000 }).should("be.visible");
if (text.includes("Sign in to your account")) {
cy.contains("Sign in to your account", { timeout: 10000 }).should("be.visible");
}
if (text.includes("Regístrate")) {
cy.contains("Regístrate", { timeout: 10000 }).should("be.visible");
if (text.includes("Sign up")) {
cy.contains("Sign up", { timeout: 10000 }).should("be.visible");
}
});
cy.get("body").then(($body) => {
if ($body.find("form#login").length > 0) {
cy.get("form#login", { timeout: 10000 }).within(() => {
cy.get("input#identifier", { timeout: 5000 }).should("exist");
cy.get("input#password", { timeout: 5000 }).should("exist");
cy.get('input[name="identifier"]', { timeout: 5000 }).should("exist");
cy.get('input[name="password"]', { timeout: 5000 }).should("exist");
});
cy.get('button[form="login"]', { timeout: 5000 }).should("be.visible");
cy.get('button[form="login"]', { timeout: 5000 }).should("exist");
} else {
cy.log("ℹ️ Login form not found");
}
@@ -43,26 +41,20 @@ describe("Login Page", () => {
cy.get("body").then(($body) => {
const text = $body.text();
if (text.includes("También puedes acceder")) {
cy.contains("También puedes acceder", { timeout: 5000 }).should("be.visible");
if (text.includes("You can also access")) {
cy.contains("You can also access", { timeout: 5000 }).should("be.visible");
}
if (text.includes("¿Olvidaste tu contraseña?")) {
cy.contains("¿Olvidaste tu contraseña?", { timeout: 5000 }).should("be.visible");
if (text.includes("Forgot your password?")) {
cy.contains("Forgot your password?", { timeout: 5000 }).should("be.visible");
}
});
});
it("should display floating labels correctly", () => {
cy.get('label[for="identifier"]').should("exist");
cy.get("input#identifier").should("be.visible");
cy.get("input#identifier").type("root@goteo.org");
cy.get("input#identifier").should("have.value", "root@goteo.org");
cy.get('label[for="identifier"]').should("be.visible");
cy.get('input[name="identifier"]').should("be.visible");
cy.get('input[name="identifier"]').type("root@goteo.org");
cy.get('input[name="identifier"]').should("have.value", "root@goteo.org");
});
it("should validate form fields appropriately", () => {
+23 -14
View File
@@ -2,30 +2,39 @@
describe("PayPal Payment Flow", () => {
beforeEach(() => {
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("POST", "**/api/auth/login", {
statusCode: 200,
cy.intercept("POST", "**/v4/user_tokens", {
statusCode: 201,
body: {
access_token: "mock-access-token-cypress-123",
refresh_token: "mock-refresh-token-cypress-456",
user: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
},
id: 1,
token: "mock-access-token-cypress-123",
owner: "/v4/users/1",
},
}).as("loginRequest");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/100", {
statusCode: 200,
body: {
+18 -4
View File
@@ -2,15 +2,29 @@
describe("Project Donation Button - Simple", () => {
beforeEach(() => {
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
});
}).as("getUserData");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/**", {
statusCode: 200,
@@ -2,16 +2,30 @@
describe("Error in progress display", () => {
beforeEach(() => {
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
isAuthenticated: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/100", {
statusCode: 200,
@@ -41,7 +55,14 @@ describe("Error in progress display", () => {
cy.intercept("GET", "**/v4/**", {
statusCode: 200,
body: { accountingId: 123, id: 1 },
body: {
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
id: 1,
},
}).as("otherApiCalls");
cy.window().then((win) => {
@@ -50,9 +71,14 @@ describe("Error in progress display", () => {
JSON.stringify({
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
handle: "test",
displayName: "Cypress Test User",
isAuthenticated: true,
accountingId: 123,
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
}),
);
});
@@ -61,7 +87,11 @@ describe("Error in progress display", () => {
"access-token",
JSON.stringify({
token: "mock-access-token-cypress-123",
accountingId: 123,
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
userId: 1,
}),
);
@@ -1,12 +1,36 @@
/// <reference types="cypress" />
describe("Objective achieved", () => {
describe("Goal Reached Project", () => {
beforeEach(() => {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("getUserData");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/100*", {
statusCode: 200,
body: {
id: 100,
title: "Proyecto Objetivo Alcanzado",
title: "Goal Reached Project",
amount: 7500,
minimal: 5000,
optimal: 10000,
@@ -14,7 +38,12 @@ describe("Objective achieved", () => {
num_investors: 45,
status: "active",
currency: "EUR",
description: "Descripción del proyecto de prueba",
description: "Test project description",
budget: {
minimum: { money: { amount: 5000, currency: "EUR" } },
optimum: { money: { amount: 10000, currency: "EUR" } },
},
accounting: "/v4/accountings/100",
owner: {
id: 1,
name: "Owner Test",
@@ -38,7 +67,7 @@ describe("Objective achieved", () => {
statusCode: 200,
body: {
id: 100,
title: "Proyecto Objetivo Alcanzado",
title: "Goal Reached Project",
amount: 7500,
minimal: 5000,
optimal: 10000,
@@ -46,7 +75,7 @@ describe("Objective achieved", () => {
num_investors: 45,
status: "active",
currency: "EUR",
description: "Descripción del proyecto de prueba",
description: "Test project description",
},
}).as("projectDataAlt");
@@ -54,7 +83,7 @@ describe("Objective achieved", () => {
statusCode: 200,
body: {
id: 100,
title: "Proyecto Objetivo Alcanzado",
title: "Goal Reached Project",
amount: 7500,
minimal: 5000,
optimal: 10000,
@@ -71,7 +100,7 @@ describe("Objective achieved", () => {
id: 3827,
project: "/v4/project/100",
title: 'CD "Al Paso de los Caracoles" + 2 Camisetas',
description: "CD físico del álbum junto con 2 camisetas oficiales",
description: "Physical CD of the album with 2 official t-shirts",
money: { amount: 4000, currency: "EUR" },
hasUnits: true,
unitsTotal: 5,
@@ -81,6 +110,33 @@ describe("Objective achieved", () => {
],
}).as("projectRewards");
cy.intercept("GET", "**/v4/accountings/100", {
statusCode: 200,
body: {
id: 100,
balance: {
amount: 7500,
currency: "EUR",
},
accounting_balance_points: "/v4/accounting_balance_points?accounting=100",
},
}).as("accountingData");
cy.intercept("GET", "**/v4/accounting_balance_points**", {
statusCode: 200,
body: [
{
start: "2024-10-01T00:00:00Z",
end: "2024-10-08T23:59:59Z",
balance: {
amount: 7500,
currency: "EUR",
},
length: 1,
},
],
}).as("accountingBalancePoints");
cy.intercept("GET", "**/v4/**", {
statusCode: 200,
body: {
@@ -93,20 +149,44 @@ describe("Objective achieved", () => {
},
}).as("otherApiCalls");
cy.window().then((win) => {
win.localStorage.setItem(
"user",
JSON.stringify({
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
isAuthenticated: true,
accountingId: 123,
}),
);
});
cy.setCookie(
"access-token",
JSON.stringify({
token: "mock-access-token-cypress-123",
accountingId: 123,
userId: 1,
}),
);
cy.mockLogin();
cy.on("uncaught:exception", () => false);
});
it("should display goal reached status when minimum is exceeded", () => {
cy.visitAs("user", "/es/project/100");
cy.visit("/es/project/100", {
failOnStatusCode: false,
timeout: 60000,
});
cy.get("body", { timeout: 30000 }).should("exist");
// Wait for page to load and verify it's not a 500 error page
cy.get("body", { timeout: 20000 }).should("exist");
// Check if page loaded with error or if it's completely empty
cy.get("body").then(($body) => {
const text = $body.text();
// If body is completely empty or shows errors, skip test gracefully in CI
if (!text || text.trim().length === 0) {
cy.log("⚠️ Empty body detected in CI - skipping test");
cy.log("This may indicate API connectivity issues in CI environment");
@@ -122,30 +202,30 @@ describe("Objective achieved", () => {
const text = $body.text().toLowerCase();
const hasFinancingContent =
text.includes("obtenido") ||
text.includes("recaudado") ||
text.includes("financiado") ||
text.includes("conseguido") ||
text.includes("alcanzado") ||
text.includes("mínimo") ||
text.includes("óptimo") ||
text.includes("obtained") ||
text.includes("raised") ||
text.includes("funded") ||
text.includes("achieved") ||
text.includes("reached") ||
text.includes("minimum") ||
text.includes("optimal") ||
text.includes("€") ||
text.includes("eur") ||
/\d+/.test(text);
if (hasFinancingContent) {
const goalIndicators = [
"conseguido",
"alcanzado",
"financiado",
"logrado",
"completado",
"achieved",
"reached",
"funded",
"accomplished",
"completed",
"100%",
"150%",
"✓",
"success",
"meta",
"objetivo",
"target",
"objective",
"goal",
"achieved",
];
@@ -167,7 +247,7 @@ describe("Objective achieved", () => {
cy.get("body").then(($bodyAgain) => {
const textAgain = $bodyAgain.text().toLowerCase();
const hasContentNow =
textAgain.includes("proyecto") ||
textAgain.includes("project") ||
textAgain.includes("€") ||
/\d+/.test(textAgain);
@@ -175,7 +255,7 @@ describe("Objective achieved", () => {
expect(hasContentNow, "Should have some project content after waiting").to
.be.true;
} else {
cy.log("ℹ️ Página cargada pero con contenido limitado");
cy.log("ℹ️ Page loaded but with limited content");
expect(true, "Page loaded without critical errors").to.be.true;
}
});
@@ -185,16 +265,16 @@ describe("Objective achieved", () => {
cy.get("body").then(($body) => {
const text = $body.text();
if (text.includes("Obtenido") || text.includes("Recaudado")) {
cy.contains(/Obtenido|Recaudado/i).should("be.visible");
if (text.includes("Obtained") || text.includes("Raised")) {
cy.contains(/Obtained|Raised/i).should("be.visible");
}
if (text.includes("Mínimo")) {
cy.contains("Mínimo").should("be.visible");
if (text.includes("Minimum")) {
cy.contains("Minimum").should("be.visible");
}
if (text.includes("Óptimo")) {
cy.contains("Óptimo").should("be.visible");
if (text.includes("Optimal")) {
cy.contains("Optimal").should("be.visible");
}
if (text.includes("€")) {
@@ -204,20 +284,19 @@ describe("Objective achieved", () => {
});
it("should display project page with basic content", () => {
cy.visitAs("user", "/es/project/100");
cy.visit("/es/project/100", {
failOnStatusCode: false,
timeout: 60000,
});
cy.get("body", { timeout: 30000 }).should("exist");
// Wait for page to load and verify it's not a 500 error page
cy.get("body", { timeout: 20000 }).should("exist");
// Check if page loaded with error or if it's completely empty
cy.get("body").then(($body) => {
const text = $body.text();
// If body is completely empty or shows errors, skip test gracefully in CI
if (!text || text.trim().length === 0) {
cy.log("⚠️ Empty body detected in CI - skipping test");
cy.log("This may indicate API connectivity issues in CI environment");
// Don't fail the test, just log and continue
return;
} else if (text.includes("500") || text.includes("Internal Server Error")) {
cy.log("❌ Server error detected");
@@ -229,18 +308,18 @@ describe("Objective achieved", () => {
const text = $body.text().toLowerCase();
const contentIndicators = [
"proyecto",
"project",
"título",
"project",
"title",
"descripción",
"title",
"description",
"description",
"€",
"eur",
"obtenido",
"recaudado",
"mínimo",
"óptimo",
"obtained",
"raised",
"minimum",
"optimal",
];
let foundContent = 0;
@@ -252,11 +331,11 @@ describe("Objective achieved", () => {
if (foundContent >= 2 || text.length > 1000) {
cy.log(
`✅ Encontrado contenido del proyecto (${foundContent} indicadores, ${text.length} chars)`,
`✅ Found project content (${foundContent} indicators, ${text.length} chars)`,
);
expect(true, "Project content found").to.be.true;
} else {
cy.log("ℹ️ Página básica cargada, verificando elementos mínimos");
cy.log("ℹ️ Basic page loaded, checking minimum elements");
expect(text).to.not.include("error 500");
expect(text).to.not.include("internal server error");
@@ -2,16 +2,29 @@
describe("Viewing without contributions", () => {
beforeEach(() => {
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
isAuthenticated: true,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/100", {
statusCode: 200,
@@ -26,6 +39,11 @@ describe("Viewing without contributions", () => {
status: "active",
currency: "EUR",
description: "Descripción del proyecto sin contribuciones",
budget: {
minimum: { money: { amount: 5000, currency: "EUR" } },
optimum: { money: { amount: 10000, currency: "EUR" } },
},
accounting: "/v4/accountings/100",
owner: {
id: 1,
name: "Owner Test",
@@ -39,6 +57,23 @@ describe("Viewing without contributions", () => {
body: [],
}).as("projectRewards");
cy.intercept("GET", "**/v4/accountings/100", {
statusCode: 200,
body: {
id: 100,
balance: {
amount: 0,
currency: "EUR",
},
accounting_balance_points: "/v4/accounting_balance_points?accounting=100",
},
}).as("accountingData");
cy.intercept("GET", "**/v4/accounting_balance_points**", {
statusCode: 200,
body: [],
}).as("accountingBalancePoints");
cy.intercept("GET", "**/v4/**", {
statusCode: 200,
body: { accountingId: 123, id: 1 },
@@ -71,10 +106,13 @@ describe("Viewing without contributions", () => {
});
it("should display progress elements correctly", () => {
cy.visit("/es/project/100", { failOnStatusCode: false });
cy.visit("/es/project/100", {
failOnStatusCode: false,
timeout: 60000,
});
cy.wait(3000);
cy.get("body").should("exist");
cy.get("body", { timeout: 30000 }).should("exist");
cy.get("body").should("not.contain", "Error 500");
cy.get("body").should("not.contain", "Internal Server Error");
+23 -14
View File
@@ -2,30 +2,39 @@
describe("Stripe Payment Flow", () => {
beforeEach(() => {
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("POST", "**/api/auth/login", {
statusCode: 200,
cy.intercept("POST", "**/v4/user_tokens", {
statusCode: 201,
body: {
access_token: "mock-access-token-cypress-123",
refresh_token: "mock-refresh-token-cypress-456",
user: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
},
id: 1,
token: "mock-access-token-cypress-123",
owner: "/v4/users/1",
},
}).as("loginRequest");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/100", {
statusCode: 200,
body: {
@@ -2,30 +2,39 @@
describe("Checkout Flow - Multiple Contributions", () => {
beforeEach(() => {
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("POST", "**/api/auth/login", {
statusCode: 200,
cy.intercept("POST", "**/v4/user_tokens", {
statusCode: 201,
body: {
access_token: "mock-access-token-cypress-123",
refresh_token: "mock-refresh-token-cypress-456",
user: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
},
id: 1,
token: "mock-access-token-cypress-123",
owner: "/v4/users/1",
},
}).as("loginRequest");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/10", {
statusCode: 200,
body: {
@@ -5,15 +5,29 @@ describe("View project rewards", () => {
cy.loginAs("user");
cy.on("uncaught:exception", () => false);
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.intercept("GET", "**/v4/projects/100", {
statusCode: 200,
@@ -114,8 +128,13 @@ describe("View project rewards", () => {
JSON.stringify({
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
}),
);
});
@@ -124,7 +143,11 @@ describe("View project rewards", () => {
"access-token",
JSON.stringify({
token: "mock-access-token-cypress-123",
accountingId: 123,
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
userId: 1,
}),
);
+114 -50
View File
@@ -8,7 +8,7 @@
// https://on.cypress.io/custom-commands
// ***********************************************
// ========== INTERFACES EXISTENTES ==========
// ========== EXISTING INTERFACES ==========
interface User {
email: string;
password: string;
@@ -19,12 +19,12 @@ interface Users {
invalidUser: User;
}
// ========== COMANDOS ESCALABLES NUEVOS ==========
// NOTA: Los tipos UserProfile y UserRole están definidos en index.d.ts
// ========== NEW SCALABLE COMMANDS ==========
// NOTE: UserProfile and UserRole types are defined in index.d.ts
/**
* Comando escalable para autenticación por rol
* Configura automáticamente intercepts, localStorage y cookies
* Scalable command for role-based authentication
* Automatically configures intercepts, localStorage and cookies
*/
Cypress.Commands.add("loginAs", (role: UserRole) => {
cy.fixture("user-profiles").then((profiles) => {
@@ -34,54 +34,77 @@ Cypress.Commands.add("loginAs", (role: UserRole) => {
throw new Error(`Profile '${role}' not found in user-profiles.json`);
}
// Si es guest, no configurar autenticación
// If it's guest, don't configure authentication
if (role === "guest") {
cy.clearCookies();
cy.clearLocalStorage();
return;
}
// 1. Configurar intercept para /api/auth/me
cy.intercept("GET", "**/api/auth/me", {
// 1. Configure intercept to get user data
cy.intercept("GET", `**/v4/users/${profile.id}`, {
statusCode: 200,
body: {
id: profile.id,
email: profile.email,
name: profile.name,
accountingId: profile.accountingId,
handle: profile.email.split("@")[0],
displayName: profile.name,
roles: profile.roles,
isAdmin: profile.isAdmin,
accounting: `/v4/accountings/${profile.accountingId}`,
person: `/v4/users/${profile.id}/person`,
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
// 2. Configurar intercept para login (opcional)
cy.intercept("POST", "**/api/auth/login", {
statusCode: 200,
// 2. Configure intercept for login
cy.intercept("POST", "**/v4/user_tokens", {
statusCode: 201,
body: {
access_token: profile.token,
refresh_token: `refresh-${profile.token}`,
user: {
id: profile.id,
email: profile.email,
name: profile.name,
accountingId: profile.accountingId,
roles: profile.roles,
},
id: 1,
token: profile.token,
owner: `/v4/users/${profile.id}`,
},
}).as("loginRequest");
// 3. Configurar cookie access-token
// 3. Configure intercept for person data
cy.intercept("GET", `**/v4/users/${profile.id}/person`, {
statusCode: 200,
body: {
id: profile.id,
name: profile.name,
email: profile.email,
},
}).as("getPersonData");
// 4. Configure access-token cookie
cy.setCookie(
"access-token",
JSON.stringify({
id: profile.id,
user: {
id: profile.id,
email: profile.email,
handle: profile.email.split("@")[0],
displayName: profile.name,
roles: profile.roles,
accounting: `/v4/accountings/${profile.accountingId}`,
person: `/v4/users/${profile.id}/person`,
emailConfirmed: true,
active: true,
},
person: {
id: profile.id,
name: profile.name,
email: profile.email,
},
token: profile.token,
accountingId: profile.accountingId,
isAdmin: profile.isAdmin,
}),
);
// 4. Configurar localStorage
// 5. Configure localStorage
cy.window().then((win) => {
win.localStorage.setItem(
"user",
@@ -96,16 +119,16 @@ Cypress.Commands.add("loginAs", (role: UserRole) => {
);
});
// 5. Configurar intercepts adicionales comunes
// 6. Configure additional common intercepts
cy.setupCommonIntercepts(profile);
// 6. Manejar excepciones no capturadas
// 7. Handle uncaught exceptions
cy.on("uncaught:exception", () => false);
});
});
/**
* Configurar intercepts comunes basados en el perfil del usuario
* Configure common intercepts based on user profile
*/
Cypress.Commands.add("setupCommonIntercepts", (profile: UserProfile) => {
cy.intercept("GET", "**/v4/projects/**", {
@@ -173,7 +196,7 @@ Cypress.Commands.add("setupCommonIntercepts", (profile: UserProfile) => {
});
/**
* Comando para verificar que el usuario tiene los roles correctos
* Command to verify that the user has the correct roles
*/
Cypress.Commands.add("verifyUserRole", (expectedRole: UserRole) => {
cy.fixture("user-profiles").then((profiles) => {
@@ -188,14 +211,14 @@ Cypress.Commands.add("verifyUserRole", (expectedRole: UserRole) => {
});
/**
* Comando para visitar página con autenticación automática
* Command to visit page with automatic authentication
*/
Cypress.Commands.add("visitAs", (role: UserRole, url: string) => {
cy.loginAs(role);
cy.visit(url, { failOnStatusCode: false });
});
// ========== COMANDOS EXISTENTES (MANTENER COMPATIBILIDAD) ==========
// ========== EXISTING COMMANDS (MAINTAIN COMPATIBILITY) ==========
Cypress.Commands.add(
"login",
@@ -228,36 +251,61 @@ Cypress.Commands.add(
Cypress.Commands.add("mockLogin", () => {
console.warn('⚠️ cy.mockLogin() is deprecated. Use cy.loginAs("admin") instead');
cy.intercept("POST", "**/api/auth/login", {
statusCode: 200,
cy.intercept("POST", "**/v4/user_tokens", {
statusCode: 201,
body: {
access_token: "mock-access-token-cypress-123",
refresh_token: "mock-refresh-token-cypress-456",
user: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
},
id: 1,
token: "mock-access-token-cypress-123",
owner: "/v4/users/1",
},
}).as("loginRequest");
cy.intercept("GET", "**/api/auth/me", {
cy.intercept("GET", "**/v4/users/1", {
statusCode: 200,
body: {
id: 1,
email: "test@cypress.local",
name: "Cypress Test User",
accountingId: 123,
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
}).as("authMe");
}).as("getUserData");
cy.intercept("GET", "**/v4/users/1/person", {
statusCode: 200,
body: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
}).as("getPersonData");
cy.setCookie(
"access-token",
JSON.stringify({
id: 1,
user: {
id: 1,
email: "test@cypress.local",
handle: "test",
displayName: "Cypress Test User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/123",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
person: {
id: 1,
name: "Cypress Test User",
email: "test@cypress.local",
},
token: "mock-access-token-cypress-123",
accountingId: 123,
userId: 1,
}),
);
@@ -280,9 +328,25 @@ Cypress.Commands.add("loginBypass", () => {
cy.setCookie(
"access-token",
JSON.stringify({
id: 1,
user: {
id: 1,
email: "cypress@test.local",
handle: "cypress",
displayName: "Cypress Bypass User",
roles: ["ROLE_USER"],
accounting: "/v4/accountings/999",
person: "/v4/users/1/person",
emailConfirmed: true,
active: true,
},
person: {
id: 1,
name: "Cypress Bypass User",
email: "cypress@test.local",
},
token: "cypress-bypass-token",
accountingId: 999,
userId: 1,
}),
);
@@ -316,7 +380,7 @@ Cypress.Commands.add("checkHeaderElements", () => {
}
if ($body.find('header button[aria-label="Ir al checkout"]').length > 0) {
cy.get('header button[aria-label="Ir al checkout"]', { timeout: 5000 }).should(
cy.get('header button[aria-label="Go to checkout"]', { timeout: 5000 }).should(
"be.visible",
);
} else {
@@ -328,7 +392,7 @@ Cypress.Commands.add("checkHeaderElements", () => {
Cypress.Commands.add("changeLanguage", (language: string) => {
cy.get("body").then(($body) => {
if ($body.find("select#language-select").length > 0) {
cy.get("select#language-select", { timeout: 5000 }).select(language);
cy.get("select#language-select", { timeout: 5000 }).first().select(language);
} else {
cy.log("ℹ️ Language selector not found");
}
+11 -11
View File
@@ -1,6 +1,6 @@
/// <reference types="cypress" />
// ========== TIPOS GLOBALES ==========
// ========== GLOBAL TYPES ==========
interface UserProfile {
id: number | null;
email: string | null;
@@ -13,14 +13,14 @@ interface UserProfile {
type UserRole = "admin" | "user" | "userWithAdmin" | "moderator" | "guest";
// ========== EXTENSIÓN DE CYPRESS ==========
// ========== CYPRESS EXTENSION ==========
declare namespace Cypress {
interface Chainable<Subject = any> {
// ========== COMANDOS ESCALABLES NUEVOS ==========
// ========== NEW SCALABLE COMMANDS ==========
/**
* 🚀 NUEVO: Autenticación escalable por rol
* Configura automáticamente intercepts, localStorage y cookies
* 🚀 NEW: Scalable role-based authentication
* Automatically configures intercepts, localStorage and cookies
* @example cy.loginAs('admin')
* @example cy.loginAs('user')
* @example cy.loginAs('userWithAdmin')
@@ -30,28 +30,28 @@ declare namespace Cypress {
loginAs(role: UserRole): Chainable<void>;
/**
* 🚀 NUEVO: Visitar página con autenticación automática
* 🚀 NEW: Visit page with automatic authentication
* @example cy.visitAs('admin', '/es/admin/charges')
* @example cy.visitAs('user', '/es/project/123')
*/
visitAs(role: UserRole, url: string): Chainable<void>;
/**
* 🚀 NUEVO: Verificar que el usuario tiene los roles correctos
* 🚀 NEW: Verify that the user has the correct roles
* @example cy.verifyUserRole('admin')
*/
verifyUserRole(expectedRole: UserRole): Chainable<void>;
/**
* 🚀 NUEVO: Configurar intercepts comunes automáticamente
* 🚀 NEW: Configure common intercepts automatically
* @example cy.setupCommonIntercepts(profile)
*/
setupCommonIntercepts(profile: UserProfile): Chainable<void>;
// ========== COMANDOS EXISTENTES ==========
// ========== EXISTING COMMANDS ==========
/**
* ⚠️ DEPRECATED: Usar cy.loginAs('user') en su lugar
* ⚠️ DEPRECATED: Use cy.loginAs('user') instead
* Custom command to log in to the application
* @example cy.login()
* @example cy.login('user@example.com', 'password123')
@@ -59,7 +59,7 @@ declare namespace Cypress {
login(username?: string, password?: string): Chainable<void>;
/**
* ⚠️ DEPRECATED: Usar cy.loginAs('admin') en su lugar
* ⚠️ DEPRECATED: Use cy.loginAs('admin') instead
* Custom command to mock authentication (useful for CI)
* @example cy.mockLogin()
*/
+5
View File
@@ -3,6 +3,7 @@ import importPlugin from "eslint-plugin-import";
import globals from "globals";
import tseslint from "typescript-eslint";
import cypressPlugin from "eslint-plugin-cypress";
import unusedImportsPlugin from "eslint-plugin-unused-imports";
/** @type {import('eslint').Linter.Config[]} */
export default [
@@ -19,6 +20,8 @@ export default [
".astro/**",
".astro/**/*",
"openapi/client/**",
".github/**",
"env.d.ts",
],
},
{ files: ["**/*.{js,mjs,cjs,ts}"] },
@@ -28,6 +31,7 @@ export default [
{
plugins: {
import: importPlugin,
"unused-imports": unusedImportsPlugin,
},
rules: {
"import/order": [
@@ -48,6 +52,7 @@ export default [
},
],
"@typescript-eslint/no-explicit-any": "off",
"unused-imports/no-unused-imports": "error",
},
},
// Cypress configuration
+3
View File
@@ -22,6 +22,7 @@
"dev:worker": "npx astro build && npx wrangler dev --port 4321 --ip 0.0.0.0"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/cloudflare": "^12.6.0",
"@astrojs/node": "^9.2.2",
"@astrojs/svelte": "^7.1.0",
@@ -38,6 +39,7 @@
"marked": "^15.0.12",
"media-icons": "^1.1.5",
"svelte": "^5.33.13",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3"
},
@@ -51,6 +53,7 @@
"eslint": "^9.28.0",
"eslint-plugin-cypress": "^5.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-unused-imports": "^4.2.0",
"flowbite": "^3.1.2",
"flowbite-svelte": "1.5.2",
"flowbite-svelte-icons": "^2.2.0",
+621 -34
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+31
View File
@@ -0,0 +1,31 @@
<svg width="295" height="282" viewBox="0 0 295 282" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.08">
<path fill-rule="evenodd" clip-rule="evenodd" d="M120.109 31.9265C110.135 23.6406 96.3826 32.1682 106.105 43.3659C108.6 46.0524 112.612 44.67 115.761 44.2083C120.75 43.0594 121.739 36.1097 120.109 31.9265Z" fill="#E6E5F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M121.868 64.0244C122.981 61.0275 121.692 56.7639 118.479 55.8291C114.308 55.9183 107.537 53.5095 105.885 58.7691C100.634 72.6453 118.236 78.4349 121.868 64.0244Z" fill="#E6E5F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M123.971 49.4763C120.871 62.715 142.56 62.64 140.147 49.0991C140.316 39.5291 124.981 41.7674 123.971 49.4763Z" fill="#E6E5F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M125.968 78.5543C129.268 80.2997 132.224 82.912 136.09 80.8513C138.035 80.2721 140.275 80.0149 140.72 77.6157C144.005 66.275 122.458 64.3122 122.949 75.3832C123.187 77.8351 123.512 78.2651 125.968 78.5543Z" fill="#E6E5F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M135.981 131.357C123.652 128.758 126.616 114.431 138.502 115.117C152.867 116.022 150.218 135.565 135.981 131.357ZM244.615 186.98C244.304 181.64 241.984 176.274 244.973 171.449C245.334 167.419 249.696 166.141 250.528 162.246C251.827 155.7 254.828 149.164 254.119 142.414C253.975 141.345 254.216 140.935 255.169 140.372C257.601 138.935 257.648 135.568 255.247 134.134C234.592 125.283 213.375 117.333 191.99 110.374C188.729 108.679 190.121 114.664 187.252 113.339C173.594 108.713 159.542 105.37 145.872 100.817C140.902 100.463 119.547 90.9796 118.686 95.5297C112.101 106.425 109.218 118.956 105.54 131.017C99.3226 152.454 100.089 165.618 126.555 156.373C132.219 153.984 158.86 139.192 161.409 147.693C161.852 158.187 168.458 165.296 178.486 158.561C183.888 154.643 189.543 149.439 196.395 148.892C212.966 149.208 223.686 166.995 226.158 181.595C227.908 188.667 225.462 200.708 234.748 202.725C242.242 205.587 241.436 191.79 244.615 186.98Z" fill="#E6E5F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M244.71 187.135C238.524 204.797 233.617 222.802 227.055 240.342C224.303 245.237 222.322 259.267 217.858 261.118C175.269 251.638 133.233 238.827 91.7187 225.282C86.2673 223.747 78.4741 218.338 74.984 225.503C72.8561 230.232 80.2535 231.53 82.5843 234.633C84.8302 236.671 87.9751 237.114 90.8908 237.422C115.208 241.595 138.561 250.287 162.347 256.775C179.154 261.328 194.69 269.632 212.121 271.862C215.584 272.677 212.292 276.513 217.863 275.708C221.796 276.219 221.763 276.206 223.301 272.836C227.562 264.52 230.876 255.783 234.042 247.013C243.494 219.332 253.788 191.863 261.364 163.566C265.468 149.954 269.61 136.431 271.83 122.358C271.924 120.474 273.218 117.951 271.473 116.553C260.947 115.874 250.351 176.395 244.71 187.135Z" fill="#E6E5F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M115.675 180.572C109.692 178.195 99.7262 179.711 101.941 187.864C104.169 192.369 93.4512 193.944 99.0013 200.025C104.402 205.144 112.225 206.749 118.949 209.581C124.331 210.464 126.286 219.008 132.056 217.306C136.284 211.908 135.71 204.167 137.629 197.764C139.475 192.125 137.149 187.601 131.12 186.404C126.161 184.485 120.62 182.191 115.675 180.572Z" fill="#E6E5F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M135.906 131.247C150.286 135.489 152.863 116.115 138.37 115.149C126.377 114.415 123.46 128.611 135.906 131.247Z" fill="#E6E5F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M247.564 177.115C245.516 180.82 241.845 204.397 238.074 202.532C235.41 200.458 229.927 199.961 229.951 195.881C229.493 184.033 227.342 172.015 221.981 161.521C215.596 148.526 204.115 138.66 189.633 147.132C149.32 168.079 174.709 159.12 152.813 141.52C145.67 137.532 131.087 149.081 123.875 152.053C115.944 155.202 118.988 159.305 126.066 156.426C129.915 154.654 133.809 152.978 137.646 151.183C142.041 150.006 148.772 142.609 152.089 148.607C156.482 154.405 156.793 168.287 166.795 165.887C179.594 162.27 190.333 149.934 203.975 150.228C220.36 158.036 222.944 178.927 226.301 194.82C226.583 196.27 226.012 196.764 224.615 196.322C187.772 183.828 150.919 171.486 113.42 161.067C91.6751 155.043 101.623 154.724 108.559 133.055C113.205 122.091 116.965 110.806 121.199 99.6949C121.876 94.8322 124.849 98.1902 127.786 98.8993C146.078 104.253 164.533 108.958 182.595 115.11C207.037 124.001 232.323 130.281 256.727 139.26C257.963 139.826 258.227 140.347 257.825 141.709C254.296 153.494 250.184 165.073 247.564 177.115ZM207.813 195.266C208.669 197.585 212.005 196.456 213.462 197.803C220.664 201.48 228.867 203.464 236.454 206.522C240.473 208.215 236.09 214.137 236.253 217.363C233.438 231.952 228.48 246.006 223.949 260.125C180.398 248.443 133.204 234.865 87.7899 225.143C65.2134 219.564 74.2741 222.211 83.0018 194.754C87.4005 184.278 91.1121 173.472 95.8027 163.12C98.3461 157.859 97.5411 158.246 102.82 159.93C106.128 160.986 109.436 162.041 112.751 163.074C138.847 170.951 164.898 180.606 190.112 189.684C191.285 190.687 193.359 190.689 194.868 190.922C198.639 192.856 203.301 196.545 207.855 195.26C207.389 195.408 206.97 195.416 206.54 195.16C206.972 195.106 207.391 195.17 207.813 195.266ZM124.244 93.3376C123.829 90.2003 129.36 78.5974 130.759 74.8361C140.818 77.8373 150.825 81.2641 160.827 84.489C195.797 95.6428 230.586 107.366 265.85 117.576C270.043 118.041 275.421 121.458 279.192 119.208C280.278 117.931 278.265 117.099 277.356 116.577C240.704 101.867 202.572 90.9135 165.074 78.522C156.246 75.6871 147.422 72.8414 138.595 70.0018C133.09 68.227 125.656 64.4823 122.086 71.243C101.009 118.576 87.0664 167.976 68.3474 216.171C66.8999 219.799 66.1926 225.558 71.2082 226.608C79.4816 227.898 87.6682 229.626 95.745 231.872C113.509 236.237 131.092 241.326 148.764 246.061C174.143 252.568 199.309 260.996 225.413 263.817C229.584 266.22 229.974 255.732 231.726 253.392C234.282 245.888 237.129 238.466 239.284 230.835C245.461 205.861 253.972 181.554 260.717 156.731C264.18 147.055 265.876 136.453 270.928 127.486C273.741 121.61 265.055 121.507 263.753 126.56C256.507 145.773 259.266 132.649 230.273 125.389C211.224 119.869 192.619 111.871 173.553 107.47C158.332 101.208 140.441 98.5324 124.244 93.3376Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M265.965 55.6624C260.061 54.6241 252.332 53.1452 246.003 54.9743C242.175 55.6614 236.57 59.5435 243.828 59.9722C253.099 60.0291 262.094 62.2246 271.254 63.1157C282.709 61.1625 271.516 56.8235 265.965 55.6624Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.8879 174.897C24.5454 172.396 -5.9317 205.235 10.2 197.011C13.2727 194.513 38.1266 177.738 29.8879 174.897Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.0139 193.69C49.4795 197.048 56.707 185.541 61.1834 182.526C63.3326 179.722 68.9589 177.033 67.6753 173.023C66.8079 172.129 65.3074 170.585 64.0276 171.682C56.2849 177.91 50.2646 185.97 44.0139 193.69Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M241.532 82.5928C245.62 82.7063 274.876 90.6654 272.572 83.5225C270.64 79.574 223.607 77.5679 241.532 82.5928Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M219.582 68.0201C216.139 67.4729 197.542 64.4246 200.375 71.3021C203.733 74.0138 234.885 74.2893 219.582 68.0201Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M147.88 86.3342C145.819 83.6601 142.727 86.9921 140.939 84.1861C140.24 83.0903 139.157 82.3581 137.862 82.1578C134.497 82.5982 140.258 86.988 137.303 88.0059C135.985 88.8603 133.329 88.9498 133.189 90.8277C134.14 93.0311 137.385 90.5697 139.063 90.3374C141.177 89.3115 141.823 91.9743 143.134 92.9982C143.965 93.7522 146.455 94.6328 146.293 92.7328C144.658 89.1863 143.25 87.9879 147.88 86.3342Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M244 121.54C245.587 117.236 239.335 114.848 236.514 117.571C232.33 121.947 242.544 127.46 244 121.54Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M252.171 119.672C247.4 118.543 248.676 129.299 253.51 126.923C257.335 125.386 256.16 119.942 252.171 119.672Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M225.647 115.447C225.105 117.742 228.164 120.257 230.22 118.534C233.777 114.641 227.916 110.185 225.647 115.447Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M137.981 129.255C128.059 126.19 139.669 119.44 144.707 119.116C151.937 119.973 144.438 132.039 137.981 129.255ZM135.322 134.772C143.529 137.638 151.816 133.358 152.901 125.06C158.478 112.067 138.598 110.675 131.041 116.605C123.368 122.197 125.91 133.248 135.322 134.772Z" fill="#462949"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M107.66 205.79C107.639 205.852 107.617 205.914 107.596 205.977C101.009 203.746 95.6254 205.365 101.539 197.177C113.586 173.092 113.617 179.572 136.111 188.186C138.971 190.932 132.934 205.18 132.4 209.709C131.286 212.367 130.273 214.422 127.186 213.072C120.634 210.778 114.176 208.192 107.66 205.79ZM143.635 197.435C145.619 190.864 146.043 185.51 138.594 183.094C131.155 180.695 123.23 176.348 115.278 177.424C113.784 178.269 112.423 178.101 110.858 177.593C104.805 174.04 93.4843 200.597 94.2541 205.215C94.3808 207.018 96.4011 207.191 97.7904 207.599C104.863 209.196 111.415 212.253 118.142 214.82C137.644 224.836 138.639 213.516 143.635 197.435Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M209.841 219.93C210.252 214.153 208.674 207.43 203.792 203.815C198.867 199.411 193.873 212.215 189.738 207.65C181.52 186.141 179.577 205.122 174.22 201.179C172.065 198.098 168.598 189.268 163.979 193.384C163.047 195.989 159.814 198.747 160.528 201.507C162.33 202.515 164.253 200.755 165.432 199.512C169.117 198.528 170.672 211.237 177.266 207.191C179.015 206.705 181.3 202.66 182.903 204.958C189.634 226.105 199.417 204.394 202.434 211.025C204.286 213.718 205.183 224.988 209.841 219.93Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M170.371 218.011C167.313 217.747 161.113 213.89 159.545 217.488C159.438 218.266 159.871 218.827 161.162 219.239C174.069 222.662 186.497 227.714 199.488 230.822C205.615 227.836 173.421 219.256 170.427 217.811L170.371 218.011Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M176.906 231.032C153.632 225.304 186.748 239.423 192.766 238.737C195.416 236.27 179.232 231.752 176.9 231.054C176.902 231.046 176.904 231.039 176.906 231.032Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M110.411 197.899C108 205.313 127.95 204.016 122.816 199.267C118.577 200.26 115.028 199.618 112.463 195.822C111.041 195.009 110.867 196.923 110.411 197.899Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.476 189.699C120.984 182.862 109.335 187.968 112.571 190.994C115.156 193.106 118.5 193.692 119.476 189.699Z" fill="#FAF9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M130.009 193.45C131.271 190.468 126.3 189.742 125.178 191.877C123.779 195.127 129.429 197.133 130.009 193.45Z" fill="#FAF9FF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="733" height="286" viewBox="0 0 733 286" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.08" d="M389.252 -536.894C392.003 -537.47 392.29 -535.599 392.481 -533.633C438.746 -389.325 505.726 -294.854 592.705 -196.882C675.928 -103.155 772.165 -14.393 780.035 149.581C786.207 278.444 729.919 382.627 670.235 447.454C603.637 519.836 495.512 569.649 382.793 567.275C266.917 564.805 172.068 513.576 105.04 437.765C38.2743 362.265 -7.60729 266.692 1.69821 136.606C15.3574 -54.5891 158.648 -147.956 250.387 -268.113C310.024 -346.299 351.647 -427.17 389.252 -536.894ZM150.251 175.507C151.4 262.783 198.477 342.455 263.305 382.747C354.757 439.564 481.972 425.006 557.206 350.322C596.915 310.941 623.875 255.971 631.482 175.507C531.059 175.507 508.142 174.02 391.453 174.02C391.453 94.1317 392.505 9.25458 392.505 -70.586C252.971 -70.9458 148.553 44.3662 150.251 175.507Z" fill="#E6E5F7"/>
</svg>

After

Width:  |  Height:  |  Size: 912 B

+37
View File
@@ -0,0 +1,37 @@
<svg width="82" height="83" viewBox="0 0 82 83" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.5535 52.2471C37.7589 52.4218 37.9744 48.6744 35.004 48.0551C31.4093 47.3257 30.7726 52.3447 34.5535 52.2471ZM4.15234 58.818C4.58095 57.5153 5.50565 56.3396 5.08816 54.9452C5.26524 53.9225 4.27612 53.3162 4.32798 52.2958C4.4401 50.5874 4.13256 48.7684 4.75207 47.1427C4.85804 46.8873 4.82561 46.7697 4.6282 46.5669C4.12452 46.0492 4.33512 45.2117 5.02049 45.0158C10.687 44.1939 16.4325 43.6327 22.154 43.328C23.0682 43.1245 22.331 44.5153 23.1244 44.3775C26.7907 44.138 30.469 44.2427 34.1333 44.0221C35.3796 44.2644 41.2603 43.3322 41.1722 44.517C42.0743 47.6542 41.9575 50.9512 42.0671 54.1843C42.1837 59.9094 41.127 63.1209 35.2236 59.0725C33.9874 58.1042 28.4067 52.6697 27.2189 54.607C26.4179 57.1781 24.3235 58.5013 22.2997 56.1663C21.2287 54.8368 20.1803 53.1716 18.5302 52.581C14.4315 51.559 10.6204 55.2549 9.04942 58.7089C8.15254 60.3453 7.96038 63.4916 5.54215 63.375C3.50916 63.5866 4.61736 60.2209 4.15234 58.818Z" fill="#59E9D3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.11953 58.8494C4.47728 63.6371 4.4976 68.4251 4.95579 73.2074C5.31038 74.6033 4.87261 78.2116 5.84923 78.9668C16.9549 79.4453 28.1443 79.0619 39.2536 78.462C40.6964 78.4435 42.9709 77.6205 43.3573 79.628C43.5691 80.9412 41.6631 80.7717 40.8849 81.3859C40.1978 81.7417 39.3947 81.6427 38.6569 81.5253C32.3974 80.9449 26.0774 81.5481 19.7962 81.5765C15.3599 81.5889 10.9892 82.6149 6.55254 82.0103C5.64664 81.9823 6.20373 83.1514 4.88588 82.582C3.88416 82.4476 3.89319 82.4466 3.73703 81.5092C3.23684 79.1655 2.99731 76.7801 2.79653 74.3967C2.29581 66.9092 1.57371 59.4184 1.57524 51.9029C1.46306 48.2571 1.33544 44.6307 1.71704 40.9958C1.81811 40.5227 1.66601 39.8117 2.18769 39.5811C4.82284 40.1117 3.43943 55.8135 4.11953 58.8494Z" fill="#E94668"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.7381 39.8857C51.4813 42.0363 52.8228 46.3834 49.4884 46.3846C48.1848 46.1019 42.2405 47.179 42.3592 45.8052C40.9687 43.8546 43.937 44.4721 42.9136 43.0755C41.2438 40.8039 42.7296 37.0865 40.4258 37.8239C37.2449 38.5313 35.8303 36.5416 35.6641 33.5941C35.7555 31.1528 40.1913 32.711 41.7998 32.4263C44.6457 32.4064 47.4006 33.0898 50.1715 33.6367C52.9049 34.288 51.4586 37.9418 51.7381 39.8857Z" fill="#462949"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.3076 65.7912C37.9366 65.5994 40.2892 66.6368 39.2066 68.5101C38.3611 69.4787 40.8949 70.5807 39.1281 71.7191C37.4615 72.629 35.4304 72.5073 33.5889 72.7628C32.2063 72.6241 31.1619 74.6116 29.8543 73.8068C29.1695 72.1883 29.8213 70.3081 29.7713 68.5937C29.6888 67.0739 30.5596 66.1072 32.1222 66.2108C33.4691 66.0646 34.984 65.864 36.3076 65.7912Z" fill="#462949"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.5807 52.2249C30.7623 52.3212 31.4057 47.3489 35.036 48.0719C38.0357 48.6865 37.8175 52.3982 34.5807 52.2249Z" fill="#462949"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.07959 56.1774C4.33928 57.2314 3.6882 63.3181 4.73907 63.1063C5.53143 62.7692 6.91344 63.0101 7.17656 61.9974C8.07071 59.0916 9.39238 56.2562 11.4036 54.0116C13.8317 51.2152 17.3078 49.5327 20.313 52.5938C28.8523 60.4617 23.1951 56.5556 29.7439 53.6481C31.7646 53.134 34.5919 56.9645 36.1707 58.1798C37.9147 59.4868 36.8951 60.3013 35.3432 59.118C34.5129 58.4233 33.6652 57.7494 32.8391 57.0497C31.8352 56.4662 30.6665 54.1862 29.4547 55.4524C27.9914 56.5975 26.9995 60.017 24.6965 58.7581C21.7851 57.0118 19.9558 53.2416 16.5794 52.4086C12.0323 53.2557 10.0189 58.2611 8.14487 61.9767C7.97993 62.3173 8.08787 62.4778 8.46084 62.4609C18.3512 61.8111 28.2342 61.1999 38.1492 61.1079C43.8976 61.059 41.4705 60.3193 41.1925 54.4888C40.7722 51.4631 40.5909 48.4169 40.2817 45.3823C40.4359 44.1323 39.4826 44.767 38.7132 44.7477C33.8588 44.8598 29.0068 44.8003 24.1563 45.1255C17.5553 45.7059 10.9186 45.583 4.32095 46.1879C3.97933 46.246 3.88006 46.3575 3.88927 46.7217C3.9805 49.8767 4.22908 53.0192 4.07959 56.1774ZM12.6651 63.315C12.3015 63.8327 11.555 63.3314 11.1076 63.5686C9.09277 64.0015 6.94352 63.9485 4.87465 64.2026C3.77392 64.3552 4.46222 66.1139 4.20939 66.9025C3.94004 70.7047 4.23333 74.5167 4.41733 78.3166C15.9052 78.3134 28.4144 78.0824 40.2314 78.6888C46.155 78.8055 43.7508 78.8596 43.4135 71.4758C43.0218 68.5877 42.821 65.6632 42.3493 62.7864C42.0702 61.3139 42.2429 61.463 40.8326 61.53C39.949 61.5719 39.0653 61.6136 38.1815 61.6495C31.2402 61.8687 24.1926 62.5317 17.3891 63.1071C17.0343 63.2778 16.5239 63.1406 16.1371 63.0979C15.0816 63.3269 13.6911 63.9314 12.6552 63.3108C12.76 63.3782 12.8628 63.4081 12.9852 63.3733C12.8825 63.331 12.7752 63.3192 12.6651 63.315ZM39.9514 43.6046C40.2606 42.8547 39.6646 39.6121 39.5684 38.5871C36.8948 38.6628 34.2063 38.8476 31.5324 38.9826C22.1912 39.4246 12.857 40.0196 3.50556 40.2083C2.44322 40.0451 0.894373 40.5348 0.114667 39.7267C-0.0682467 39.3384 0.481848 39.2656 0.740004 39.1968C10.7296 37.985 20.8359 37.8028 30.8806 37.2219C33.24 37.1055 35.5993 36.9863 37.9587 36.8687C39.4303 36.7944 41.5069 36.3601 41.9395 38.2725C44.0052 51.402 44.179 64.57 45.6076 77.7563C45.7245 78.7516 45.5189 80.2256 44.2154 80.1528C42.0943 79.9232 39.9657 79.8079 37.83 79.8281C33.1706 79.7303 28.5081 79.824 23.847 79.8238C17.1725 79.7512 10.4236 80.1685 3.81367 79.1343C2.62875 79.4529 3.22455 76.8279 2.94752 76.1316C2.81329 74.1023 2.60211 72.074 2.5751 70.0397C2.70174 63.4408 2.21017 56.8519 2.18705 50.2524C1.9727 47.6247 2.25452 44.8846 1.60263 42.3271C1.29778 40.684 3.44206 41.2353 3.42941 42.574C3.94555 47.8163 4.13198 44.3808 11.7457 44.5069C16.7973 44.4039 21.9031 43.6572 26.8852 43.8325C31.0439 43.2916 35.6231 43.8165 39.9514 43.6046Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.55776 24.8572C9.07927 24.9919 11.0788 25.1386 12.5156 26.0122C13.4123 26.4366 14.5357 27.7709 12.7214 27.3952C10.4361 26.7937 8.07769 26.7404 5.7649 26.353C3.07455 25.1083 6.11515 24.7763 7.55776 24.8572Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M57.7921 70.0813C59.2717 69.8162 64.6064 79.978 61.1789 76.8687C60.5875 76.0455 55.5773 70.238 57.7921 70.0813Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M53.0763 73.8007C51.5099 74.27 50.49 70.9384 49.5873 69.894C49.2433 69.0563 48.036 68.0164 48.6163 67.1079C48.8887 66.9439 49.3598 66.6609 49.6024 67.0178C51.0971 69.0754 52.0472 71.4724 53.0763 73.8007Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.7956 33.1533C10.7821 32.91 3.05779 32.9397 4.09571 31.3226C4.83165 30.4724 16.5381 33.0983 11.7956 33.1533Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.157 31C19.0404 31.093 23.8178 31.5724 22.6672 33.0887C21.6621 33.5378 13.9777 31.5374 18.157 31Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.5962 40.2995C35.2797 39.7736 35.8209 40.8047 36.446 40.228C36.6903 40.0029 37.005 39.8934 37.3369 39.9297C38.1359 40.2623 36.4289 40.9676 37.0888 41.4161C37.3568 41.7153 38.0046 41.9139 37.9153 42.3886C37.5358 42.8714 36.8997 42.046 36.502 41.877C36.0495 41.4824 35.7149 42.0994 35.3247 42.2661C35.0705 42.3977 34.3998 42.4507 34.5649 41.9906C35.2011 41.2202 35.6267 41.0168 34.5962 40.2995Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.622 42.6415C8.51534 41.4695 10.2112 41.2929 10.7259 42.1551C11.4669 43.5174 8.58984 44.2054 8.622 42.6415Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.73308 41.6359C7.98177 41.6731 6.95828 44.254 5.92551 43.3442C5.08558 42.7091 5.73378 41.438 6.73308 41.6359Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5397 42.3502C13.5217 42.9547 12.6031 43.3748 12.2106 42.8113C11.5921 41.6104 13.3282 40.8954 13.5397 42.3502Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.2017 51.5928C36.8456 51.4923 34.4336 49.0485 33.2151 48.6336C31.3794 48.3661 32.4292 51.8541 34.2017 51.5928ZM34.4924 53.1367C32.2838 53.302 30.5265 51.691 30.8068 49.5626C30.291 45.9724 35.2751 46.9475 36.7438 48.9188C38.2634 50.8142 36.9089 53.3839 34.4924 53.1367Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.6174 72.5733C36.6186 72.5902 36.6197 72.6071 36.6209 72.624C38.3888 72.5086 39.607 73.2671 38.6917 70.8454C37.3152 64.0768 36.8802 65.6804 30.7767 66.3215C29.8918 66.8123 30.4379 70.7439 30.2706 71.9017C30.3695 72.6343 30.4834 73.2108 31.3322 73.0815C33.0958 72.9479 34.8555 72.7359 36.6174 72.5733ZM28.3152 68.1141C28.2604 66.3539 28.5091 64.9989 30.5014 64.8949C32.4904 64.7943 34.7273 64.2432 36.6132 65.0379C36.9251 65.3466 37.271 65.3953 37.6896 65.3734C39.4136 64.8947 40.4483 72.2276 39.9543 73.3212C39.8042 73.7595 39.2957 73.668 38.9269 73.6769C37.081 73.6032 35.2671 73.9256 33.4424 74.1152C27.9829 75.3024 28.4845 72.4309 28.3152 68.1141Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5391 69.2924C10.8189 67.8337 11.6505 66.2724 13.0902 65.7007C14.5927 64.9361 14.9775 68.441 16.296 67.5843C19.7366 62.7996 18.9633 67.6324 20.5415 67.0111C21.275 66.3906 22.7103 64.4325 23.5757 65.7593C23.6334 66.4667 24.2469 67.3649 23.8893 68.0014C23.3794 68.1317 23.0221 67.5677 22.814 67.1813C21.972 66.6929 20.7512 69.7392 19.3954 68.2986C18.997 68.062 18.7014 66.9078 18.1555 67.3709C15.1046 72.1644 14.1287 66.1345 12.949 67.5775C12.3158 68.122 11.3519 70.8552 10.5391 69.2924Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.3779 71.4371C21.1479 71.5748 22.928 71.0305 23.0766 72.0263C23.0515 72.2263 22.9081 72.3366 22.5633 72.3528C19.1612 72.3441 15.7697 72.7709 12.3678 72.6783C11.0568 71.5315 19.5452 71.543 20.3774 71.3838L20.3779 71.4371Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.9108 74.2305C24.016 74.3563 14.9355 75.6564 13.4998 75.0869C13.0104 74.2994 17.291 74.2544 17.9108 74.2363C17.9108 74.2343 17.9108 74.2324 17.9108 74.2305Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.4599 70.4347C36.5644 72.4321 31.7404 70.7858 33.3169 69.95C34.2946 70.4774 35.2103 70.554 36.0917 69.7837C36.4954 69.6766 36.4119 70.1624 36.4599 70.4347Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.77 67.8005C34.8498 66.006 37.3798 68.0451 36.3838 68.58C35.6085 68.9316 34.747 68.8548 34.77 67.8005Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.9291 68.0307C31.8152 67.2079 33.0864 67.3582 33.2217 67.9619C33.3516 68.86 31.829 68.9821 31.9291 68.0307Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M71.9495 52.2176C73.0872 51.9487 78.2328 49.6169 76.0884 52.3613C75.7668 52.5472 75.8276 52.6912 76.0723 52.8971C76.2027 53.0069 76.2023 53.1848 76.1092 53.3573C75.6628 54.3824 75.9608 55.5345 75.4927 56.5909C75.4134 56.9353 74.9298 57.3901 75.5213 57.525C75.8241 57.5354 75.9391 57.8439 76.01 58.084C75.9551 58.5224 75.8271 58.7187 76.4147 58.7619C77.0878 58.9226 76.7113 59.4669 76.5742 59.8866C76.5477 61.1081 71.843 62.0276 70.6859 62.7523C69.4264 63.3132 68.1435 63.4728 66.7861 63.4788C65.9154 63.6321 64.8572 63.9808 64.2156 63.131C63.4171 62.4663 62.2974 63.4261 62.019 61.7393C61.9794 61.1947 61.5125 61.4939 61.1659 61.5335C60.4366 61.5742 60.8625 60.4263 60.7677 59.9741C60.9632 55.6198 61.1206 51.2567 60.5809 46.9179C60.1084 45.1093 61.7313 42.9488 59.758 41.7505C59.4278 41.1081 61.8136 40.3138 61.6129 41.3252C61.4679 42.6453 65.4764 40.0994 65.9707 41.2169C66.0696 41.511 66.2798 41.4824 66.5127 41.4374C68.5747 41.0574 70.6602 41.4099 72.7374 40.9887C75.3311 40.9625 73.6344 41.7567 73.8237 43.2189C73.4882 44.6775 76.6473 42.8669 73.9978 46.943C74.4778 47.0219 75.2831 46.4511 75.4154 47.2091C77.4108 48.4092 73.4531 49.2404 72.7432 49.9383C73.8916 49.7993 77.6167 48.4042 74.9174 50.7555C73.8908 51.3013 72.9485 51.5583 71.9495 52.2176Z" fill="#59E9D3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.6262 33.7329C54.7923 34.0302 55.4037 33.6324 55.5493 33.3565C55.2269 33.2561 54.9295 33.6276 54.6262 33.7329ZM56.9736 26.2183C57.3723 29.6725 57.5897 33.2567 57.8508 36.7466C57.9295 37.2358 57.9099 37.7639 58.4876 37.9782C59.762 38.4571 58.2633 39.0546 58.2106 39.7798C58.1274 40.5431 57.7732 41.1799 56.8986 41.1337C55.2819 41.2098 53.6688 41.3704 52.0567 41.5232C51.0776 41.6519 50.0661 42.2262 49.2196 41.3752C46.6564 39.1136 48.4458 35.8473 48.3611 33.0056C48.6894 30.1727 48.2527 26.9439 49.8661 24.468C50.8593 23.2705 54.609 22.8584 56.1882 22.791C56.881 22.8099 56.7015 23.474 56.6714 23.953C57.0074 24.6599 57.0429 25.4028 56.9736 26.2183Z" fill="#59E9D3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.4194 3.785C32.2034 0.949692 33.669 0.0655385 36.3353 0C37.5758 0.106077 39.407 -0.208923 39.9958 1.18846C40.725 3.85023 42.08 6.41946 42.4107 9.14831C42.0676 12.755 37.4782 12.798 36.3696 9.49615C35.4121 7.35092 32.3828 6.31823 32.4194 3.785Z" fill="#E94668"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.367 28.4835C18.5581 28.5742 19.7506 28.8711 20.9441 28.6262C21.2513 28.5553 21.5102 28.6162 21.7957 28.8185C22.6126 29.6329 23.9418 28.8447 24.8041 29.4377C25.2835 29.8969 26.1748 30.0932 25.5129 30.8856C24.2044 32.5065 23.1988 34.3727 21.4972 35.645C19.2036 37.4518 16.094 35.7995 14.4091 33.9114C12.5099 31.8645 14.6863 28.1213 17.367 28.4835Z" fill="#E94668"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M78.8152 10.0658C79.9493 9.86661 82.2435 10.4642 80.776 11.7775C78.3315 14.2553 75.4669 16.3567 73.4266 19.1994C73.0105 19.9168 71.5753 20.0021 71.5352 19.0241C71.7076 18.063 73.5067 17.2389 72.1978 16.4934C71.5863 15.8351 71.8207 14.6594 72.0307 13.883C72.1383 13.4335 73.7908 11.9323 73.0287 11.8541C68.8774 10.1471 77.9728 10.2471 78.8152 10.0658Z" fill="#59E9D3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M57.9338 46.1631V56.1481C56.2296 53.1515 57.3293 49.3933 57.9338 46.1631Z" fill="#59E9D3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.3444 38.8295H58.346C58.3745 39.3808 58.1088 40.469 59.0189 40.2063C60.7099 39.9008 62.4102 40.0919 64.1065 40.1222C66.6656 40.2138 69.203 40.6103 71.7676 40.6251C72.247 40.5376 73.2989 41.1015 73.3955 40.4082C73.2543 39.7399 73.7469 38.487 73.0315 38.1579C71.189 37.8285 69.2983 37.947 67.4349 37.8313C64.6444 37.7327 61.8541 37.6307 59.064 37.5233C58.1443 37.2989 58.3493 38.2304 58.3444 38.8295ZM71.1393 13.6024L71.19 13.6014C71.1406 10.8307 71.604 7.9817 71.0133 5.25231C69.0057 5.63485 68.2219 14.5603 68.3489 16.5292C68.4804 18.0201 66.8661 18.215 66.5849 16.7906C65.9893 13.799 65.8818 10.7472 65.3772 7.74208C64.374 2.31939 63.2111 8.12946 63.0792 10.1959C62.4053 11.7394 63.848 19.0196 61.9219 18.7312C60.4542 18.1825 60.6959 12.8802 60.128 11.2175C59.7382 9.93893 59.1828 2.024 57.987 7.04723C56.4573 15.9027 57.9458 24.8935 58.1583 33.7963C58.1584 34.3829 58.1585 34.9704 58.1878 35.5559C58.2397 36.5919 58.2493 36.541 59.2079 36.4684C61.5766 36.2823 63.9515 36.2555 66.3231 36.3872C74.8765 36.2585 72.9776 38.8089 73.2618 31.6579C73.2077 27.712 80.3158 29.3624 78.0535 27.8772C76.1574 26.7172 74.0571 25.8151 72.3706 24.3515C70.4976 21.2312 71.5188 17.1092 71.1393 13.6024ZM61.6228 10.8382C62.4258 8.78677 61.9684 6.34916 63.6733 4.71646C64.6617 4.17877 64.933 3.95854 65.7318 4.98562C66.8898 6.48277 66.7606 8.52685 67.3482 10.1917C68.3751 8.12585 68.4078 5.25323 70.5708 3.76946C74.1402 1.39623 72.7382 17.8635 73.0996 19.8002C73.1319 22.5008 72.9111 23.1345 75.3485 24.5353C80.0165 26.5207 82.7262 30.0891 75.7295 30.9453C74.832 31.2002 74.7249 31.3817 74.8117 32.3138C75.4877 38.7354 75.6392 45.1952 75.6408 51.647C75.7365 54.4393 75.8086 57.23 75.9272 60.0213C76.0341 60.6109 75.1688 61.2803 74.8562 60.5779C74.263 55.5888 73.7426 50.4511 73.697 45.4153C73.4752 44.6128 74.0136 41.7535 72.9261 41.8122C71.2306 41.1234 72.5014 44.7302 72.3622 45.601C72.5025 46.8573 72.4585 48.107 72.3203 49.3603C71.9818 52.1361 72.0038 54.9352 71.93 57.7262C71.7303 58.6168 72.4615 61.1138 71.2325 61.1689C70.1689 60.6443 70.9662 55.0039 70.7004 53.5314C70.6357 51.0129 71.2374 48.5204 71.0193 46.007C70.9723 44.6676 70.8449 43.3239 71.2039 42.0132C71.229 41.8774 71.164 41.7654 71.0542 41.7638C70.3703 41.8766 68.9752 41.4124 68.6675 42.1495C68.9453 45.0405 69.2389 47.939 68.9778 50.843C68.8992 52.9902 68.8452 55.1412 68.7992 57.29C68.5495 58.2576 69.5317 62.2901 68.1121 61.9623C67.6473 61.7473 67.7456 60.9792 67.5816 60.5456C67.1564 57.9828 67.1837 55.3646 67.1973 52.7756C67.3774 49.2644 66.8818 45.7302 67.3654 42.2391C67.4358 41.3778 66.0022 41.8782 65.4559 41.744C65.1655 41.7478 65.1024 41.8552 65.1052 42.1872C65.1259 44.6718 65.1765 47.1565 65.1567 49.6407C65.1281 53.1332 64.8918 56.6233 65.0646 60.1153C64.972 60.69 65.3127 61.5752 64.6828 61.8802C64.2606 62.0565 64.2291 61.5145 64.2135 61.2263C63.9302 58.3616 63.682 55.503 63.6848 52.6188C63.6213 49.1028 63.2532 45.5725 63.6518 42.0651C63.6858 41.7949 63.605 41.6829 63.3541 41.6764C62.8001 41.8083 61.5409 41.2988 61.3915 41.9833C61.6295 43.8414 61.7788 45.7074 61.6758 47.5811C61.4275 48.4456 62.682 62.0415 61.33 59.8102C61.0983 59.3648 60.9315 58.8851 60.9491 58.3767C60.8364 54.4127 60.0083 50.506 60.1979 46.5282C60.0012 45.6877 60.6158 41.541 59.7828 41.565C59.201 41.5258 58.2158 41.2042 58.3892 42.1459C58.4803 47.8666 58.1917 53.6107 58.5952 59.3174C58.4913 66.3645 57.2554 56.3045 57.4724 54.6456C57.6635 40.8287 56.4821 27.0569 56.375 13.2517C56.7631 10.8501 56.3319 4.99493 59.0176 4.22339C60.5652 4.21385 61.0284 9.507 61.6228 10.8382Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.4145 22.1313C45.493 22.8309 44.6383 23.5586 44.1224 22.8503C43.645 22.197 43.8195 21.3316 43.4724 20.625C43.1118 19.8424 40.6053 20.8478 39.7168 20.6025C38.2735 19.7776 42.1779 19.314 42.6701 19.133C43.4843 18.9883 41.6088 16.1457 43.6313 17.1626C44.1945 17.6697 44.119 18.9206 45.0768 18.7518C45.7975 18.8329 47.2087 18.2518 47.5335 19.0862C47.7473 20.122 46.1555 19.8486 45.5069 20.0573C44.7215 20.2322 45.5148 21.5308 45.4145 22.1313Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.617 3.99051C48.6604 4.35513 48.0733 5.50766 48.8303 5.57774C49.458 5.53936 51.9592 6.94512 50.6232 7.33928C47.6857 6.26959 48.2609 6.1732 47.4197 9.03936C47.2917 9.40659 46.9735 9.5192 46.622 9.53528C45.3941 9.37712 46.216 7.71136 46.3034 6.94236C46.6304 6.13105 46.069 6.03089 45.4135 5.90066C45.105 5.87882 44.3804 5.34189 44.8934 5.12951C45.962 4.94859 46.7593 5.72459 47.23 4.40066C47.5879 3.64759 48.8265 2.07666 48.617 3.99051Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8773 9.31094C30.2691 9.69478 30.1783 8.68547 30.6776 7.72601C31.9134 5.76232 31.7187 8.40363 31.5471 9.29901C31.4416 10.3312 33.0892 9.95793 33.4574 10.7382C33.5396 11.6847 32.1883 11.0246 31.6832 10.9606C31.076 10.6237 30.2726 14.5809 29.4523 13.2054C28.8556 12.4025 29.4165 11.5884 29.6489 10.7562C29.6994 9.9184 28.1636 10.2865 27.6773 9.75932C27.1868 9.07978 28.5223 9.33063 28.8776 9.2914C28.8775 9.29794 28.8774 9.3044 28.8773 9.31094Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.5418 23.4684C21.3198 23.0159 21.5038 20.7593 19.8788 21.5767C19.5305 21.6384 17.914 22.2197 18.1822 21.4994C20.0996 20.1349 20.7011 21.1448 19.6504 18.7007C19.6164 18.5367 19.5931 18.3476 19.7671 18.2493C20.9637 17.7339 21.1373 20.3978 22.3612 19.7173C24.5991 18.5061 25.3401 20.5053 22.7174 20.845C21.702 21.3956 24.7088 23.4736 22.5418 23.4684Z" fill="#3D3D3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.9447 3.23828C19.6221 3.40005 24.1622 4.20374 23.9552 4.85113C23.6197 5.82682 22.8825 5.5689 22.0924 5.30974C21.5323 5.14328 16.3215 3.5849 18.9447 3.23828Z" fill="#3D3D3D"/>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

+2 -44
View File
@@ -1,35 +1,12 @@
import { z } from "astro/zod";
import { ActionError, defineAction } from "astro:actions";
import { apiGatewayCheckoutsPost, apiGatewaysGetCollection } from "../openapi/client/index.ts";
import { apiGatewayCheckoutsPost } from "../openapi/client/index.ts";
import { getDefaultCurrency } from "../utils/consts.ts";
import type { GatewayCharge, ApiGatewaysNameGetResponse } from "../openapi/client/index.ts";
import type { GatewayCharge } from "../openapi/client/index.ts";
const defaultCurrency = getDefaultCurrency();
async function getPaymentGateways(): Promise<ApiGatewaysNameGetResponse[]> {
if (import.meta.env.NODE_ENV === "test" || process.env.NODE_ENV === "test") {
return [{ name: "test-gateway" }] as ApiGatewaysNameGetResponse[];
}
try {
const response = await apiGatewaysGetCollection();
const paymentGateways: ApiGatewaysNameGetResponse[] | undefined = response.data;
if (!paymentGateways) {
throw new Error("Payment gateways not found");
}
return paymentGateways;
} catch (error) {
console.error("Error fetching payment gateways:", error);
// En caso de error, devolver un gateway por defecto o re-lanzar según necesites
throw error;
}
}
let cachedPaymentGateways: ApiGatewaysNameGetResponse[] | null = null;
let cachedPaymentGatewayNames: string[] | null = null;
export const payment = defineAction({
accept: "form",
@@ -61,23 +38,6 @@ export const payment = defineAction({
const { t } = context.locals;
try {
if (!cachedPaymentGateways) {
cachedPaymentGateways = await getPaymentGateways();
cachedPaymentGatewayNames = cachedPaymentGateways
.map((gateway) => gateway.name)
.filter((name): name is string => name !== undefined);
}
if (
cachedPaymentGatewayNames === null ||
!cachedPaymentGatewayNames.includes(input.paymentMethod)
) {
throw new ActionError({
code: "BAD_REQUEST",
message: t("payment.error.invalidPaymentMethod"),
});
}
const cart = input.cartData;
const charges: GatewayCharge[] = cart.items.map((item) => ({
type: "single",
@@ -115,8 +75,6 @@ export const payment = defineAction({
},
});
console.log({ response });
return { success: true, checkout: response.data };
} catch (err) {
console.error(err);
+4 -4
View File
@@ -5,21 +5,21 @@
<section>
<div class="flex flex-row gap-6">
<button
class="btn btn-primary cursor-pointer rounded-2xl border border-[#462949] px-4 py-2 font-bold text-[#575757]"
class="btn btn-primary text-content border-secondary cursor-pointer rounded-2xl border px-4 py-2 font-bold"
>
{$t("contributions.grid.actions.refundToWallet")}</button
>
<button
class="btn btn-secondary cursor-pointer rounded-2xl border border-[#462949] px-4 py-2 font-bold text-[#575757]"
class="btn btn-secondary text-content border-secondary cursor-pointer rounded-2xl border px-4 py-2 font-bold"
>
{$t("contributions.grid.actions.refundToUser")}
</button>
<button
class="btn btn-danger cursor-pointer rounded-2xl border border-[#462949] px-4 py-2 font-bold text-[#575757]"
class="btn btn-danger text-content border-secondary cursor-pointer rounded-2xl border px-4 py-2 font-bold"
>{$t("contributions.grid.actions.changeUser")}</button
>
<button
class="btn btn-danger cursor-pointer rounded-2xl border border-[#462949] px-4 py-2 font-bold text-[#575757]"
class="btn btn-danger text-content border-secondary cursor-pointer rounded-2xl border px-4 py-2 font-bold"
>{$t("contributions.grid.actions.manageReward")}
</button>
</div>
+2 -2
View File
@@ -44,11 +44,11 @@
{/if}
</div>
<div class="flex flex-row items-center gap-4">
<p class="text-tertiary font-bold">
<p class="text-secondary font-bold">
{$t("contributions.grid.content-footer.downloadDetails")}
</p>
<span
class="text-tertiary flex flex-row items-center gap-2 rounded-2xl bg-[#E6E5F7] px-4 py-2 font-bold"
class="text-secondary bg-variant1 flex flex-row items-center gap-2 rounded-2xl px-4 py-2 font-bold"
>
<AnnotationIcon />
{$t("contributions.grid.content-footer.annotations", {
+20 -20
View File
@@ -59,7 +59,7 @@
<section class="flex flex-col gap-10">
<div
class="grid grid-cols-4 gap-x-8 gap-y-8 rounded-md bg-[#fbfafe] p-6 text-[16px] text-[#575757]"
class="text-content grid grid-cols-4 gap-x-8 gap-y-8 rounded-md bg-[#fbfafe] p-6 text-[16px]"
>
<div class="flex flex-col gap-1">
<p class="font-semibold">{$t("contributions.grid.details.operationTime")}</p>
@@ -68,7 +68,7 @@
<div class="flex flex-col gap-1">
<p class="font-semibold">{$t("contributions.grid.details.trackingCodes.title")}</p>
<button
class="text-tertiary flex cursor-pointer items-start truncate whitespace-nowrap underline"
class="text-secondary flex cursor-pointer items-start truncate whitespace-nowrap underline"
title={trackingCodes.map((tc: Tracking) => tc.value).join(", ")}
onclick={() => (trackingModal = true)}
>
@@ -78,7 +78,7 @@
<Modal
bind:open={trackingModal}
closeBtnClass="top-7 end-7 bg-transparent text-[#462949] hover:bg-transparent hover:text-[#462949] hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-[#462949] dark:hover:text-[#462949] dark:hover:bg-transparent"
closeBtnClass="top-7 end-7 bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent"
class="!left-1/2 max-w-[800px] p-4 backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
title={$t("contributions.grid.details.trackingCodes.title")}
headerClass="py-2"
@@ -86,25 +86,25 @@
<Table class="w-full table-fixed border-separate border-spacing-y-2">
<TableHead>
<TableHeadCell
class="bg-secondary rounded-tl-lg rounded-bl-lg py-4 text-base whitespace-nowrap text-white"
class="bg-tertiary rounded-tl-lg rounded-bl-lg py-4 text-base whitespace-nowrap text-white"
>
{$t("contributions.grid.details.trackingCodes.headers.title")}
</TableHeadCell>
<TableHeadCell
class="bg-secondary rounded-tr-lg rounded-br-lg py-4 text-base whitespace-nowrap text-white"
class="bg-tertiary rounded-tr-lg rounded-br-lg py-4 text-base whitespace-nowrap text-white"
>
{$t("contributions.grid.details.trackingCodes.headers.trackingCode")}
</TableHeadCell>
</TableHead>
<TableBody class="text-base">
{#each trackingCodes as item}
<TableBodyRow class=" bg-[#FBFBFB]">
<TableBodyRow class=" bg-light-surface">
<TableBodyCell
class="rounded-l-md border-t border-b border-l border-[#E6E5F7]"
class="border-variant1 rounded-l-md border-t border-b border-l"
>{item.title}</TableBodyCell
>
<TableBodyCell
class="rounded-r-md border-t border-r border-b border-[#E6E5F7] align-top"
class="border-variant1 rounded-r-md border-t border-r border-b align-top"
>
<div class="flex w-full items-center gap-4">
<div
@@ -115,7 +115,7 @@
</div>
<Tooltip
text={$t("contributions.tootip.copied")}
tooltipClass="bg-[#462949] -translate-x-[90%]"
tooltipClass="bg-secondary -translate-x-[90%]"
className="h-[20px] w-[20px] cursor-copy shrink-0"
>
<button
@@ -148,7 +148,7 @@
<p class="font-semibold">{$t("contributions.grid.details.platformLinks.title")}</p>
<button
class="text-tertiary flex cursor-pointer items-start truncate whitespace-nowrap underline"
class="text-secondary flex cursor-pointer items-start truncate whitespace-nowrap underline"
title={platformLinks
.map((pl: Link) => pl.href ?? "")
.filter(Boolean)
@@ -165,7 +165,7 @@
<Modal
bind:open={linksModal}
closeBtnClass="top-7 end-7 bg-transparent text-[#462949] hover:bg-transparent hover:text-[#462949] hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-[#462949] dark:hover:text-[#462949] dark:hover:bg-transparent"
closeBtnClass="top-7 end-7 bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent"
class="!left-1/2 max-w-[800px] p-4 backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
title={$t("contributions.grid.details.platformLinks.title")}
headerClass="py-2"
@@ -173,36 +173,36 @@
<Table class="w-full table-fixed border-separate border-spacing-y-2">
<TableHead>
<TableHeadCell
class="bg-secondary rounded-tl-lg rounded-bl-lg py-4 text-base whitespace-nowrap text-white"
class="bg-tertiary rounded-tl-lg rounded-bl-lg py-4 text-base whitespace-nowrap text-white"
>
{$t("contributions.grid.details.platformLinks.headers.type")}
</TableHeadCell>
<TableHeadCell
class="bg-secondary border-t-lg border-b-lg py-4 text-base whitespace-nowrap text-white"
class="bg-tertiary border-t-lg border-b-lg py-4 text-base whitespace-nowrap text-white"
>
{$t("contributions.grid.details.platformLinks.headers.rel")}
</TableHeadCell>
<TableHeadCell
class="bg-secondary rounded-tr-lg rounded-br-lg py-4 text-base whitespace-nowrap text-white"
class="bg-tertiary rounded-tr-lg rounded-br-lg py-4 text-base whitespace-nowrap text-white"
>
{$t("contributions.grid.details.platformLinks.headers.href")}
</TableHeadCell>
</TableHead>
<TableBody class="text-base">
{#each platformLinks as item}
<TableBodyRow class=" bg-[#FBFBFB]">
<TableBodyRow class=" bg-light-surface">
<TableBodyCell
class="rounded-l-md border-t border-b border-l border-[#E6E5F7]"
class="border-variant1 rounded-l-md border-t border-b border-l"
>
{item.type}
</TableBodyCell>
<TableBodyCell
class="rounded-l-md border-t border-b border-[#E6E5F7]"
class="border-variant1 rounded-l-md border-t border-b"
>
{item.rel}
</TableBodyCell>
<TableBodyCell
class="rounded-r-md border-t border-r border-b border-[#E6E5F7] align-top"
class="border-variant1 rounded-r-md border-t border-r border-b align-top"
>
<div class="flex w-full items-center gap-4">
<div
@@ -211,14 +211,14 @@
>
<a
href={item.href}
class=" text-tertiary"
class=" text-secondary"
target="_blank"
>{item.href}
</a>
</div>
<Tooltip
text={$t("contributions.tootip.copied")}
tooltipClass="bg-[#462949]"
tooltipClass="bg-secondary"
className="h-[20px] w-[20px] cursor-copy shrink-0"
>
<button
+9 -9
View File
@@ -211,7 +211,7 @@
</script>
<div
class="relative flex flex-col gap-10 rounded-[40px] border border-[#E6E5F7] px-8 pt-6 pb-8 shadow-[0px_1px_3px_0px_#0000001A]"
class="border-variant1 relative flex flex-col gap-10 rounded-[40px] border px-8 pt-6 pb-8 shadow-[0px_1px_3px_0px_#0000001A]"
>
<div class=" flex items-center justify-between gap-4">
<Search onSelectTarget={handleSelectTarget} />
@@ -220,7 +220,7 @@
<button
type="button"
onclick={() => (showFilters = !showFilters)}
class="border-tertiary text-tertiary relative inline-flex cursor-pointer items-center gap-2 rounded-3xl border px-6 py-4 font-bold text-nowrap"
class="border-secondary text-secondary relative inline-flex cursor-pointer items-center gap-2 rounded-3xl border px-6 py-4 font-bold text-nowrap"
>
<span class="relative">
<FiltersIcon />
@@ -243,7 +243,7 @@
<form onsubmit={handleSubmit} class="flex flex-col gap-6">
<div class="grid grid-cols-3 gap-4">
<select
class="border-tertiary w-full rounded-lg border p-4"
class="border-secondary w-full rounded-lg border p-4"
bind:value={selectedPaymentMethod}
>
<option value="" disabled selected
@@ -255,7 +255,7 @@
</select>
<select
class="border-tertiary w-full rounded-lg border p-4"
class="border-secondary w-full rounded-lg border p-4"
bind:value={selectedChargeStatus}
>
<option value="" disabled
@@ -267,7 +267,7 @@
</select>
<select
class="border-tertiary w-full rounded-lg border p-4"
class="border-secondary w-full rounded-lg border p-4"
bind:value={selectedRangeAmount}
>
<option value="" disabled
@@ -287,7 +287,7 @@
type="date"
bind:value={dateFrom}
onclick={(e) => (e.currentTarget as HTMLInputElement).showPicker?.()}
class="border-tertiary w-full rounded-lg border p-4 pt-4"
class="border-secondary w-full rounded-lg border p-4 pt-4"
/>
</div>
@@ -298,7 +298,7 @@
<input
id="dateTo"
type="date"
class="border-tertiary w-full rounded-lg border p-4 pt-4"
class="border-secondary w-full rounded-lg border p-4 pt-4"
bind:value={dateTo}
onclick={(e) => (e.currentTarget as HTMLInputElement).showPicker?.()}
/>
@@ -308,7 +308,7 @@
<div class="col-span-3 flex justify-end">
<button
type="submit"
class="bg-primary text-tertiary cursor-pointer rounded-3xl px-6 py-4 text-base font-bold"
class="bg-primary text-secondary cursor-pointer rounded-3xl px-6 py-4 text-base font-bold"
>
{$t("contributions.filters.btns.apply")}
</button>
@@ -322,7 +322,7 @@
type="button"
onclick={handleExportCSV}
disabled={isExporting}
class="relative inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-[#E6E5F7] px-4 py-2 font-medium text-nowrap text-gray-700 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
class="bg-variant1 relative inline-flex cursor-pointer items-center gap-2 rounded-2xl px-4 py-2 font-medium text-nowrap text-gray-700 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isExporting}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
+11 -11
View File
@@ -39,26 +39,26 @@
<nav class="flex items-center gap-1">
<button
onclick={() => goToPage(1)}
class="w-[40px] rounded border border-[#5757573D] py-1 text-center disabled:opacity-40
class="border-content w-[40px] rounded border py-1 text-center disabled:opacity-40
"
disabled={currentPage === 1}>«</button
>
<button
onclick={() => goToPage(currentPage - 1)}
class="w-[40px] rounded border border-[#5757573D] py-1 text-center disabled:opacity-40"
class="border-content w-[40px] rounded border py-1 text-center disabled:opacity-40"
disabled={currentPage === 1}>‹</button
>
{#each getVisiblePages() as page}
{#if page === "..."}
<span class="text-tertiary w-[40px] border-[#5757573D] text-center">…</span>
<span class="text-secondary border-content w-[40px] text-center">…</span>
{:else}
<button
onclick={() => typeof page === "number" && goToPage(page)}
class="w-[40px] rounded border border-[#5757573D] py-1 text-center text-sm font-medium"
class:bg-tertiary={currentPage === page}
class="border-content w-[40px] rounded border py-1 text-center text-sm font-medium"
class:bg-secondary={currentPage === page}
class:text-primary={currentPage === page}
class:text-tertiary={currentPage !== page}
class:text-secondary={currentPage !== page}
class:hover:bg-gray-200={currentPage !== page}
>
{page}
@@ -68,26 +68,26 @@
<button
onclick={() => goToPage(currentPage + 1)}
class="w-[40px] rounded border border-[#5757573D] py-1 text-center disabled:opacity-40"
class="border-content w-[40px] rounded border py-1 text-center disabled:opacity-40"
disabled={currentPage === totalPages()}>›</button
>
<button
onclick={() => goToPage(totalPages())}
class="w-[40px] rounded border border-[#5757573D] py-1 text-center disabled:opacity-40"
class="border-content w-[40px] rounded border py-1 text-center disabled:opacity-40"
disabled={currentPage === totalPages()}>»</button
>
</nav>
{#if total > 0}
<span class="text-sm font-bold text-[#575757]">
<span class="text-content text-sm font-bold">
{@html $t("contributions.pagination.showing", { items, total }, { allowHTML: true })}
</span>
{:else if total === 0 && !isLoading}
<span class="text-sm font-bold text-[#575757]">
<span class="text-content text-sm font-bold">
{@html $t("contributions.pagination.noResults")}
</span>
{:else}
<span class="text-sm font-bold text-[#575757]">
<span class="text-content text-sm font-bold">
{@html $t("contributions.pagination.unloaded")}
</span>
{/if}
+1 -1
View File
@@ -100,7 +100,7 @@
oninput={(e) =>
handleInput(e.target instanceof HTMLInputElement ? e.target.value : "")}
placeholder={$t("contributions.filters.search.placeholder")}
class="border-tertiary w-full rounded-3xl border p-4"
class="border-secondary w-full rounded-3xl border p-4"
minlength="4"
/>
{#if query}
+4 -4
View File
@@ -44,17 +44,17 @@
<div class="relative h-[160px]">
{#if !isLoaded}
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-[#575757]">Cargando...</span>
<span class="text-content">Cargando...</span>
</div>
{/if}
<div bind:this={mainCarousel} class="main-carousel h-full opacity-{isLoaded ? 100 : 0}">
{#each slides as { title, amount }}
<div
class="carousel-cell mr-6 flex h-full w-[22.5%] flex-col items-start justify-center gap-4 rounded-xl border border-[#e6e5f7] bg-[#faf9ff] p-8 shadow-[0px_1px_3px_0px_#0000001A]"
class="carousel-cell border-variant1 bg-soft-purple mr-6 flex h-full w-[22.5%] flex-col items-start justify-center gap-4 rounded-xl border p-8 shadow-[0px_1px_3px_0px_#0000001A]"
>
<span class="text-base font-semibold text-[#575757]">{title}</span>
<span class="text-secondary text-[40px]">{amount}</span>
<span class="text-content text-base font-semibold">{title}</span>
<span class="text-tertiary text-[40px]">{amount}</span>
</div>
{/each}
</div>
+19 -19
View File
@@ -507,12 +507,12 @@
<div class="flex justify-between">
<div class="flex flex-row items-center gap-2">
<p class="font-bold text-[#575757]">
<p class="text-content font-bold">
{$t("contributions.filters.order.title")}
</p>
<select
bind:value={selectedSort}
class="border-tertiary text-tertiary min-w-[200px] rounded-sm py-1"
class="border-secondary text-secondary min-w-[200px] rounded-sm py-1"
disabled={isLoading}
>
{#each sortOptions as option}
@@ -522,13 +522,13 @@
</div>
<div class="flex flex-row items-center gap-2">
<p class="font-bold text-[#575757]">
<p class="text-content font-bold">
{$t("contributions.filters.itemsPerPage.title")}
</p>
<select
name="itemsPerPage"
id="itemsPerPage"
class="border-tertiary text-tertiary rounded-sm py-1"
class="border-secondary text-secondary rounded-sm py-1"
bind:value={itemsPerPage}
disabled={isLoading}
>
@@ -540,10 +540,10 @@
</div>
<Table class="w-full border-separate border-spacing-y-2">
<TableHead class="bg-secondary">
<TableHead class="bg-tertiary">
{#each tableHeaders as header}
<TableHeadCell
class="py-4 text-base whitespace-nowrap text-[#FBFBFB] first:rounded-l-md last:rounded-r-md
class="text-light-surface py-4 text-base whitespace-nowrap first:rounded-l-md last:rounded-r-md
{header.sortable ? 'hover:bg-opacity-80 cursor-pointer select-none' : ''}"
onclick={() => handleHeaderClick(header)}
>
@@ -579,44 +579,44 @@
<TableBodyRow
onclick={() => toggleRow(i)}
class="{openRow === i
? 'bg-[#FAF9FF]'
: 'bg-white'} border border-[#e6e5f7] transition-colors hover:bg-[#FAF9FF]"
? 'bg-soft-purple]'
: 'bg-white'} border-variant1 hover:bg-soft-purple] border transition-colors"
>
<TableBodyCell
class="truncate rounded-l-md border-t border-b border-l border-[#E6E5F7] "
class="border-variant1 truncate rounded-l-md border-t border-b border-l "
>{charge.targetDisplayName}</TableBodyCell
>
{#if charge.money.amount && charge.money.currency}
<TableBodyCell class="border-t border-b border-[#E6E5F7]">
<TableBodyCell class="border-variant1 border-t border-b">
{formatCurrency(charge.money.amount, charge.money.currency)}
</TableBodyCell>
{:else}
<TableBodyCell class="border-t border-b border-[#E6E5F7]">—</TableBodyCell>
<TableBodyCell class="border-variant1 border-t border-b">—</TableBodyCell>
{/if}
<TableBodyCell class="truncate border-t border-b border-[#E6E5F7]"
<TableBodyCell class="border-variant1 truncate border-t border-b"
>{charge.originDisplayName}</TableBodyCell
>
<TableBodyCell class="border-t border-b border-[#E6E5F7]">
<TableBodyCell class="border-variant1 border-t border-b">
{$t(`contributions.table.rows.payments.${charge.paymentMethod}`)}
</TableBodyCell>
<TableBodyCell class="border-t border-b border-[#E6E5F7]">
<TableBodyCell class="border-variant1 border-t border-b">
{getDate(charge.dateCreated).date}
<p
class="text-tertiary max-w-[180px] cursor-pointer truncate text-[12px] whitespace-nowrap underline"
class="text-secondary max-w-[180px] cursor-pointer truncate text-[12px] whitespace-nowrap underline"
title={charge.trackingCodes[0]?.value || "—"}
>
{charge.trackingCodes[0]?.value || "—"}
</p>
</TableBodyCell>
<TableBodyCell class="border-t border-b border-[#E6E5F7]">
<TableBodyCell class="border-variant1 border-t border-b">
<button
class="border-secondary text-secondary flex items-center gap-1 rounded border px-3 py-1 text-base font-medium"
class="border-tertiary text-tertiary flex items-center gap-1 rounded border px-3 py-1 text-base font-medium"
>
{$t(`contributions.table.rows.status.${charge.status}`)}
</button>
</TableBodyCell>
<TableBodyCell class="rounded-r-md border-t border-r border-b border-[#E6E5F7]"
<TableBodyCell class="border-variant1 rounded-r-md border-t border-r border-b"
>{charge.refundToWallet}</TableBodyCell
>
</TableBodyRow>
@@ -624,7 +624,7 @@
<TableBodyRow>
<TableBodyCell
colspan={tableHeaders.length}
class="rounded-lg border border-[#E6E5F7] bg-[#FAF9FF] shadow-[0px_1px_3px_0px_#0000001A]"
class="border-variant1 bg-soft-purple] rounded-lg border shadow-[0px_1px_3px_0px_#0000001A]"
>
<DetailsRow
platformLinks={charge.platformLinks}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import CollapsibleBox from "./CollapsibleBox.svelte";
import { t } from "../i18n/store";
</script>
<div class="flex items-start justify-between px-0 pt-0 pb-0 lg:hidden">
<CollapsibleBox
detailsId="auth-details"
isInitiallyCollapsed={true}
buttonTextShow={$t("checkout.summary.show_more")}
buttonTextHide={$t("checkout.summary.hide")}
>
{#snippet header()}
<h2 class="text-secondary flex items-center gap-2 text-base font-semibold">
{$t("login.page.welcome.title")}
</h2>
{/snippet}
</CollapsibleBox>
</div>
<div class="hidden flex-col gap-6 pt-6 pb-0 lg:flex">
<div>
<h2 class="text-secondary text-[56px] leading-tight font-semibold">
{$t("login.page.welcome.title")}
</h2>
<p class="text-content font-light">
{$t("login.page.welcome.subtitle")}
</p>
</div>
<hr class="bg-secondary" />
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ const { t } = Astro.locals;
<button
onclick="history.back()"
class="group flex w-fit cursor-pointer items-center gap-2 text-[#575757] transition-colors duration-200 hover:text-[#3D3D3D]"
class="group text-content flex w-fit cursor-pointer items-center gap-2 transition-colors duration-200 hover:text-black"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
+93 -19
View File
@@ -1,22 +1,31 @@
<script lang="ts">
import ArrowSliderIcon from "../svgs/ArrowSliderIcon.svelte";
import { onMount, tick } from "svelte";
import { twMerge, type ClassNameValue } from "tailwind-merge";
// Browser check for SSR compatibility
const browser = typeof window !== "undefined";
let {
itemsPerGroup = 1,
gap = 16,
gap = 24,
showDots = true,
class: className = "",
mobileItemsToShow = 1,
desktopItemsToShow = 3,
children = null,
}: {
itemsPerGroup: number;
gap: number;
showDots: boolean;
class?: ClassNameValue;
mobileItemsToShow?: number;
desktopItemsToShow?: number;
children?: any;
} = $props();
const wrapperClasses = twMerge("relative w-full", className);
let container: HTMLDivElement;
let totalGroups = $state(0);
@@ -31,20 +40,54 @@
const observerMap = new Map<number, HTMLElement>();
function getCurrentItemsToShow(): number {
if (!browser) return desktopItemsToShow;
return window.innerWidth >= 1024 ? desktopItemsToShow : mobileItemsToShow;
}
function updateItemWidths() {
if (!browser || !container || !mounted) return;
try {
const containerWidth = container.offsetWidth;
if (containerWidth === 0) {
setTimeout(updateItemWidths, 50);
return;
}
const styles = getComputedStyle(container);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const available = container.clientWidth - paddingLeft - paddingRight;
const childWidth = (available - gap * (itemsPerGroup - 1)) / itemsPerGroup;
const available = containerWidth - paddingLeft - paddingRight;
for (const el of Array.from(container.children) as HTMLElement[]) {
el.style.minWidth = `${childWidth}px`;
el.style.maxWidth = `${childWidth}px`;
el.style.flex = "0 0 auto";
const visibleItems = getCurrentItemsToShow();
const childWidth = (available - gap * (visibleItems - 1)) / visibleItems;
const directChildren = Array.from(container.children) as HTMLElement[];
for (const el of directChildren) {
if (el.tagName.toLowerCase() === "astro-slot") {
const slotChildren = Array.from(el.children) as HTMLElement[];
slotChildren.forEach((child) => {
child.style.minWidth = `${childWidth}px`;
child.style.maxWidth = `${childWidth}px`;
child.style.width = `${childWidth}px`;
child.style.flex = "0 0 auto";
child.style.overflow = "hidden";
});
el.style.minWidth = "";
el.style.maxWidth = "";
el.style.width = "";
el.style.flex = "";
el.style.display = "contents";
} else {
el.style.minWidth = `${childWidth}px`;
el.style.maxWidth = `${childWidth}px`;
el.style.width = `${childWidth}px`;
el.style.flex = "0 0 auto";
el.style.overflow = "hidden";
}
}
updateNavForShort();
} catch (error) {
@@ -56,15 +99,26 @@
if (!browser || !container || !intersectionObs || !mounted) return;
try {
const children = Array.from(container.children) as HTMLElement[];
let actualChildren: HTMLElement[] = [];
const directChildren = Array.from(container.children) as HTMLElement[];
for (const child of directChildren) {
if (child.tagName.toLowerCase() === "astro-slot") {
actualChildren.push(...(Array.from(child.children) as HTMLElement[]));
} else {
actualChildren.push(child);
}
}
intersectionObs.disconnect();
observerMap.clear();
children.forEach((el, idx) => {
actualChildren.forEach((el, idx) => {
observerMap.set(idx, el);
intersectionObs?.observe(el);
});
totalGroups = Math.ceil(children.length / itemsPerGroup);
totalGroups = Math.ceil(actualChildren.length / itemsPerGroup);
updateNavForShort();
} catch (error) {
console.warn("Carousel: Error observing visibility:", error);
@@ -93,8 +147,18 @@
if (!browser || !container || !mounted) return;
try {
const kids = Array.from(container.children) as HTMLElement[];
const target = kids[i * itemsPerGroup];
let actualChildren: HTMLElement[] = [];
const directChildren = Array.from(container.children) as HTMLElement[];
for (const child of directChildren) {
if (child.tagName.toLowerCase() === "astro-slot") {
actualChildren.push(...(Array.from(child.children) as HTMLElement[]));
} else {
actualChildren.push(child);
}
}
const target = actualChildren[i * itemsPerGroup];
target?.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
} catch (error) {
console.warn("Carousel: Error scrolling to group:", error);
@@ -151,9 +215,14 @@
if (!mounted) return;
for (const e of entries) {
if (e.isIntersecting && container) {
const idx = Array.from(container.children).indexOf(
e.target as HTMLElement,
);
let idx = -1;
for (const [index, element] of observerMap.entries()) {
if (element === e.target) {
idx = index;
break;
}
}
if (idx !== -1) {
const group = Math.floor(idx / itemsPerGroup);
if (group !== activeGroup) updateNavState(group);
@@ -180,6 +249,8 @@
}
});
mutationObs.observe(container, { childList: true });
window.addEventListener("resize", updateItemWidths);
} catch (error) {
console.warn("Carousel: Error initializing observers:", error);
}
@@ -192,14 +263,17 @@
if (intersectionObs) intersectionObs.disconnect();
if (resizeObs) resizeObs.disconnect();
if (mutationObs) mutationObs.disconnect();
if (browser) {
window.removeEventListener("resize", updateItemWidths);
}
};
});
</script>
<div class="relative w-full">
<div class={wrapperClasses}>
<button
onclick={() => scroll("left")}
class="absolute top-1/2 -left-4 z-10 hidden h-10 w-10 -translate-y-1/2 rounded-full bg-[#e6e5f7] p-2 shadow-md disabled:opacity-50 lg:block"
class="bg-variant1 absolute top-1/2 -left-4 z-10 hidden h-10 w-10 -translate-y-1/2 rounded-full p-2 shadow-md disabled:opacity-50 lg:block"
disabled={isAtStart}
aria-label="Scroll left"
>
@@ -211,7 +285,7 @@
bind:this={container}
role="region"
aria-label="Carousel"
class="hide-scrollbar flex overflow-x-auto scroll-smooth select-none"
class="hide-scrollbar flex w-full overflow-x-auto scroll-smooth select-none"
class:cursor-grab={isScrollable && !isDragging}
class:cursor-default={!isScrollable}
class:cursor-grabbing={isDragging && isScrollable}
@@ -231,7 +305,7 @@
<button
onclick={() => scroll("right")}
class="absolute top-1/2 -right-4 z-10 hidden h-10 w-10 -translate-y-1/2 rounded-full bg-[#e6e5f7] p-2 shadow-md disabled:opacity-50 lg:block"
class="bg-variant1 absolute top-1/2 -right-4 z-10 hidden h-10 w-10 -translate-y-1/2 rounded-full p-2 shadow-md disabled:opacity-50 lg:block"
disabled={isAtEnd}
aria-label="Scroll right"
>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import { t } from "../i18n/store";
import BookmarkIcon from "../svgs/BookmarkIcon.svelte";
import Tag from "./library/Tag.svelte";
interface Props {
category: string;
}
let { category }: Props = $props();
</script>
<Tag>
<BookmarkIcon />
<span>{$t(`categories.${category}`)}</span>
</Tag>
+1 -1
View File
@@ -90,7 +90,7 @@
<div class="flex flex-col gap-10">
{#each Object.entries($groupedByOwner) as [target, items]}
<div class="flex flex-col gap-6">
<h2 class="text-secondary text-2xl font-bold">
<h2 class="text-2xl font-bold text-black">
{$t("project.owner")}
{$displayNames[target] ?? ""}
</h2>
+26 -24
View File
@@ -2,38 +2,47 @@
import { formatCurrency } from "../../utils/currencies";
import MinusIcon from "../../svgs/MinusIcon.svelte";
import PlusIcon from "../../svgs/PlusIcon.svelte";
import TrashIcon from "../../svgs/TrashIcon.svelte";
import type { CartItem } from "../../stores/cart";
import { t } from "../../i18n/store";
export let item: CartItem;
export let onIncrement;
export let onDecrement;
export let onRemove;
export let onIncrement: (item: CartItem) => void;
export let onDecrement: (item: CartItem) => void;
export let onRemove: (item: CartItem) => void;
function handleDecrement() {
if (item.quantity === 1) {
onRemove(item);
} else {
onDecrement(item);
}
}
</script>
{#if item.amount > 0}
<div class="flex w-full flex-col gap-4">
<div class=" flex items-center justify-between rounded-2xl bg-white p-4 shadow-md">
<div
class="flex flex-col gap-4 rounded-2xl bg-white p-4 shadow-md md:flex-row md:items-center md:justify-between"
>
<div class="flex items-center gap-6">
<div
class="flex h-[160px] w-[160px] items-center justify-center overflow-hidden rounded-3xl bg-orange-200"
class="flex h-[80px] w-[80px] items-center justify-center overflow-hidden rounded-3xl bg-orange-200 md:h-[160px] md:w-[160px]"
>
{#if item.image}
<img
src={item.image}
alt={item.title}
class="h-[160px] w-[160px] object-cover"
class="h-[80px] w-[80px] object-cover md:h-[160px] md:w-[160px]"
/>
{:else}
<span class="text-2xl">🙂</span>
<span class="text-xl md:text-2xl">🙂</span>
{/if}
</div>
<div class="flex flex-col gap-4">
<p class="text-tertiary text-[32px] font-bold">
<div class="flex flex-col gap-2 md:gap-4">
<p class="text-secondary text-lg font-bold md:text-[32px]">
{formatCurrency(item.amount, item.currency)}
</p>
<p class="text-tertiary font-bold">{item.title}</p>
<p class="text-[#575757]">
<p class="text-secondary text-sm font-bold md:text-base">{item.title}</p>
<p class="text-content text-sm">
{#if typeof item.claimed === "number"}
{#if item.claimed > 0}
<span>{item.claimed} {$t("checkout.reward.claimed")} </span>
@@ -44,16 +53,16 @@
</p>
</div>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-4">
<div class="flex items-center justify-center gap-4 md:flex-col md:items-end md:gap-2">
<div class="order-2 flex items-center gap-4 md:order-1">
<button
on:click={() => onDecrement(item)}
on:click={handleDecrement}
class="cursor-pointer text-xl text-purple-800"
>
<MinusIcon />
</button>
<div class="relative flex w-4 items-center justify-center">
<span class="text-tertiary absolute text-2xl font-bold"
<span class="text-secondary absolute text-2xl font-bold"
>{item.quantity}</span
>
</div>
@@ -65,13 +74,6 @@
<PlusIcon />
</button>
</div>
<button
on:click={() => onRemove(item)}
class="cursor-pointer text-xl text-purple-800"
>
<TrashIcon />
</button>
</div>
</div>
<!-- TODO: feature is not yet fully designed. -->
@@ -81,7 +83,7 @@
id={`reward-checkbox-${item.key}`}
class="accent-primary h-6 w-6 rounded"
/>
<label for="donation-checkbox" class=" text-[#575757]">
<label for="donation-checkbox" class=" text-content">
{$t("checkout.changeAddressLabel")}
</label>
</div> -->
+6 -6
View File
@@ -8,7 +8,7 @@
<form id="feedback" method="POST" class="flex flex-col gap-4">
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center gap-2">
<h2 class="text-secondary text-2xl font-bold">
<h2 class="text-2xl font-bold text-black">
{$t("payment.page-aproved.form-goal.title")}
</h2>
</div>
@@ -35,7 +35,7 @@
type="radio"
name="type"
value="organization"
class="relative h-6 w-6 appearance-none rounded-full border border-[#855a96] after:absolute after:top-1/2 after:left-1/2 after:hidden after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:transform after:rounded-full after:bg-purple-900 after:content-[''] checked:border-teal-400 checked:bg-teal-400 checked:after:block"
class="border-secondary relative h-6 w-6 appearance-none rounded-full border after:absolute after:top-1/2 after:left-1/2 after:hidden after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:transform after:rounded-full after:bg-purple-900 after:content-[''] checked:border-teal-400 checked:bg-teal-400 checked:after:block"
/>
</div>
<span class="text-gray-700"
@@ -46,11 +46,11 @@
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center gap-2">
<h2 class="text-secondary text-2xl font-bold">
<h2 class="text-2xl font-bold text-black">
{$t("payment.page-aproved.form-review.title")}
</h2>
</div>
<p class="text-[#575757]">{$t("payment.page-aproved.form-review.description")}</p>
<p class="text-content">{$t("payment.page-aproved.form-review.description")}</p>
</div>
<fieldset class="flex flex-col gap-6">
@@ -74,7 +74,7 @@
name="messageType"
value="public"
bind:group={messageType}
class="relative h-6 w-6 appearance-none rounded-full border border-[#855a96] after:absolute after:top-1/2 after:left-1/2 after:hidden after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:transform after:rounded-full after:bg-purple-900 after:content-[''] checked:border-teal-400 checked:bg-teal-400 checked:after:block"
class="border-secondary relative h-6 w-6 appearance-none rounded-full border after:absolute after:top-1/2 after:left-1/2 after:hidden after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:transform after:rounded-full after:bg-purple-900 after:content-[''] checked:border-teal-400 checked:bg-teal-400 checked:after:block"
/>
</div>
<span class="text-gray-700"> Dejar un mensaje de forma pública </span>
@@ -87,7 +87,7 @@
</label>
<textarea
id="review-message"
class="w-full appearance-none rounded-md border border-[#855a96] bg-white p-3 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:ring-[#855a96] focus:outline-none"
class="border-secondary focus:ring-secondary w-full appearance-none rounded-md border bg-white p-3 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none"
name="review"
placeholder="Escribe tu mensaje de apoyo al impulsor..."
rows="4"
+4 -4
View File
@@ -34,7 +34,7 @@ const { accounting } = Astro.props;
{
(paymentGateways ?? []).map((gateway) => (
<label
class="flex cursor-pointer items-center justify-between gap-4 rounded-2xl border border-[#F3F3EF] px-6 py-4 shadow-sm transition-all hover:shadow-md"
class="border-grey flex cursor-pointer items-center justify-between gap-4 rounded-2xl border px-6 py-4 shadow-sm transition-all hover:shadow-md"
data-gateway={gateway.name}
>
<div class="flex items-center gap-2">
@@ -47,7 +47,7 @@ const { accounting } = Astro.props;
gateway.name !== "wallet" &&
paymentGateways?.[0]?.name === gateway.name
}
class="after:bg-primary relative h-6 w-6 appearance-none rounded-full border border-[#462949] after:absolute after:top-1/2 after:left-1/2 after:hidden after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:transform after:rounded-full after:content-[''] checked:border-[#462949] checked:bg-[#462949] checked:after:block"
class="after:bg-primary border-secondary checked:border-secondary checked:bg-secondary relative h-6 w-6 appearance-none rounded-full border after:absolute after:top-1/2 after:left-1/2 after:hidden after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:transform after:rounded-full after:content-[''] checked:after:block"
/>
<div class="flex flex-col leading-tight">
<span class="font-semibold capitalize">{gateway.name}</span>
@@ -188,12 +188,12 @@ const { accounting } = Astro.props;
input.addEventListener("change", () => {
labels.forEach((label) => {
label.classList.remove("bg-teal-300", "text-[#2f1e2e]", "border-teal-400");
label.classList.add("border-[#F3F3EF]");
label.classList.add("border-grey");
});
const selectedLabel = input.closest("label");
if (selectedLabel) {
selectedLabel.classList.remove("border-[#F3F3EF]");
selectedLabel.classList.remove("border-grey");
selectedLabel.classList.add("bg-teal-300", "text-[#2f1e2e]", "border-teal-400");
}
});
+5 -5
View File
@@ -13,9 +13,9 @@
];
</script>
<div class="px-6">
<div class="lg:px-6">
<div class="relative">
<div class="bg-purple-tint h-4 w-full rounded-full"></div>
<div class="bg-variant1 h-4 w-full rounded-full"></div>
<div
class="bg-primary absolute top-0 left-0 h-4 rounded-full transition-all duration-500"
@@ -28,13 +28,13 @@
{#each [1, 2, 3, 4] as i}
<div
class="relative z-10 flex h-4 w-4 items-center justify-center rounded-full border-2 transition-all duration-300"
class:bg-tertiary={i <= step}
class:bg-purple-tint={i > step}
class:bg-secondary={i <= step}
class:bg-variant1={i > step}
class:border-primary={i <= step}
class:border-black={i > step}
>
{#if i <= step}
<div class="bg-tertiary h-1.5 w-1.5 rounded-full"></div>
<div class="bg-secondary h-1.5 w-1.5 rounded-full"></div>
{/if}
</div>
{/each}
+47 -18
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import WarningIcon from "../../svgs/WarningIcon.svelte";
import CollapsibleBox from "../CollapsibleBox.svelte";
import { cart } from "../../stores/cart";
import { derived } from "svelte/store";
import { formatCurrency } from "../../utils/currencies";
@@ -10,6 +11,8 @@
export let currency: string;
export let accountingIdPlatoniq: number;
let summaryRef;
const total = derived(cart, ($cart) =>
$cart.items.reduce((sum, item) => sum + item.amount * item.quantity, 0),
);
@@ -23,23 +26,49 @@
const donations = derived([total, foundation], ([$total, $foundation]) => $total - $foundation);
</script>
<div class="flex flex-col gap-6 px-6 pt-6 pb-0">
<div>
<h2
class={`flex items-center gap-2 text-[32px] font-semibold ${hasError ? "text-[#E94668]" : "text-[#462949]"}`}
>
{#if hasError}
<span class="h-6 w-6">
<WarningIcon />
</span>
<div class="flex flex-col gap-6 px-0 pt-0 pb-0 lg:px-6 lg:pt-6 lg:pb-0" bind:this={summaryRef}>
<CollapsibleBox
detailsId="checkout-details"
isInitiallyCollapsed={false}
buttonTextShow={$t("checkout.summary.show_details")}
buttonTextHide={$t("checkout.summary.hide_details")}
>
{#snippet header()}
<h2
class={`flex items-center gap-2 text-base font-semibold lg:text-[32px] ${hasError ? "text-tertiary" : "text-secondary"}`}
>
{#if hasError}
<span class="h-6 w-6">
<WarningIcon />
</span>
{/if}
{$t("checkout.summary.total.title")}
</h2>
<p
class={`text-[32px] leading-tight font-bold lg:text-[56px] ${hasError ? "text-tertiary" : "text-secondary"}`}
>
{formatCurrency(amount ?? $total, currency)}
</p>
{/snippet}
{#snippet content()}
{#if $donations > 0}
<div class="flex flex-col gap-2">
<div class="flex justify-between text-sm">
<span>{$t("checkout.summary.donations")}</span>
<span>{formatCurrency($donations, currency)}</span>
</div>
</div>
{/if}
{$t("checkout.summary.total.title")}
</h2>
<p
class={`text-[56px] leading-tight font-bold ${hasError ? "text-[#E94668]" : "text-[#462949]"}`}
>
{formatCurrency(amount ?? $total, currency)}
</p>
</div>
<hr class="bg-[#462949]" />
{#if $foundation > 0}
<div class="flex flex-col gap-2">
<div class="flex justify-between text-sm">
<span>{$t("checkout.summary.foundation")}</span>
<span>{formatCurrency($foundation, currency)}</span>
</div>
</div>
{/if}
{/snippet}
</CollapsibleBox>
</div>
+2 -2
View File
@@ -85,7 +85,7 @@
<div class="flex w-auto flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="text-secondary text-2xl font-bold">
<h2 class="text-2xl font-bold text-black">
{$t("checkout.tipjar.community")}
</h2>
@@ -122,7 +122,7 @@
toggleDonation(isChecked);
}}
/>
<label for="donation-checkbox" class="text-tertiary">
<label for="donation-checkbox" class="text-secondary">
{$t("checkout.tipjar.checkboxLabel")}
</label>
</div>
@@ -20,7 +20,7 @@ const { accounting, defaultCurrency } = Astro.props;
<!-- TODO: Merge with Btn "Continuar" -->
<button
id="submit-button"
class="bg-primary cursor-pointer rounded-3xl px-6 py-4 font-bold text-[#462949]"
class="bg-primary text-secondary cursor-pointer rounded-3xl px-6 py-4 font-bold"
type="submit"
>
{t("payment.global.btn.continue")}
+2 -2
View File
@@ -18,7 +18,7 @@
<span class="block text-sm text-gray-600"
>{$t("payment.wallet-confirmation.currentBalance")}</span
>
<p class="text-[32px] font-bold text-[#462949]">
<p class="text-secondary text-[32px] font-bold">
{formatCurrency(accounting.balance.amount, accounting.balance.currency)}
</p>
</div>
@@ -36,7 +36,7 @@
<span class="block text-sm text-gray-600"
>{$t("payment.wallet-confirmation.remainingBalance")}
</span>
<p id="cart-difference" class="text-[32px] font-bold text-[#462949]">
<p id="cart-difference" class="text-secondary text-[32px] font-bold">
{formatCurrency(accounting.balance.amount - $total, accounting.balance.currency)}
</p>
</div>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Snippet } from "svelte";
import LineIcon from "../svgs/LineIcon.svelte";
import Button from "./library/Button.svelte";
import { t } from "../i18n/store.ts";
interface Props {
detailsId: string;
isInitiallyCollapsed?: boolean;
showToggleButton?: boolean;
buttonTextShow?: string;
buttonTextHide?: string;
header: Snippet;
content?: Snippet;
}
let {
detailsId,
isInitiallyCollapsed = false,
showToggleButton = true,
buttonTextShow = $t("checkout.summary.show_details"),
buttonTextHide = $t("checkout.summary.hide_details"),
header,
content,
}: Props = $props();
let isCollapsed = $state(isInitiallyCollapsed);
function toggleCollapse() {
isCollapsed = !isCollapsed;
if (typeof window !== "undefined" && window.innerWidth < 1024) {
const detailsElement = document.getElementById(detailsId);
if (detailsElement) {
if (isCollapsed) {
detailsElement.style.display = "none";
} else {
detailsElement.style.display = "block";
}
}
}
}
function handleScreenSize() {
if (typeof window !== "undefined") {
const detailsElement = document.getElementById(detailsId);
if (detailsElement) {
if (window.innerWidth >= 1024) {
detailsElement.style.display = "";
} else {
detailsElement.style.display = isCollapsed ? "none" : "block";
}
}
}
}
function initializeMobileState() {
handleScreenSize();
}
onMount(() => {
initializeMobileState();
const handleResize = () => handleScreenSize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
});
</script>
<div class="flex w-full items-start justify-between">
<div class="flex-1">
{@render header()}
</div>
{#if showToggleButton}
<div class="self-start lg:hidden">
<Button kind="invert" onclick={toggleCollapse} class="px-2 py-1 text-sm">
{isCollapsed ? buttonTextShow : buttonTextHide}
<LineIcon rotate={isCollapsed} />
</Button>
</div>
{/if}
</div>
{#if !isCollapsed && content}
{@render content()}
{/if}
+1 -1
View File
@@ -50,7 +50,7 @@
}
</script>
<div class="text-tertiary flex items-center gap-2 text-end text-2xl font-bold lg:justify-end">
<div class="text-secondary flex items-center gap-2 text-end text-2xl font-bold lg:justify-end">
{#if timeLeft.total > 0}
<ClockIcon />
<p>{$t("countdown.remaining")} {timeLeft.days}d {timeLeft.hours}h {timeLeft.minutes}m</p>
+300 -286
View File
@@ -4,327 +4,341 @@ import FooterFacebookIcon from "../svgs/footer/FooterFacebookIcon.astro";
import FooterLinkedinIcon from "../svgs/footer/FooterLinkedinIcon.astro";
import FooterInstagramIcon from "../svgs/footer/FooterInstagramIcon.astro";
import FooterGmailIcon from "../svgs/footer/FooterGmailIcon.astro";
import FooterPartnerImage from "./FooterPartnerImage.svelte";
import FooterLink from "./FooterLink.svelte";
const { t } = Astro.locals;
---
<footer class="text-purple-tint bg-[#FAF9FF]">
<footer class="text-variant1" role="contentinfo" aria-labelledby="footer-heading">
<!-- Funding Partners Section -->
<div class="text-secondary border-purple-tint rounded-t-3xl border-2 border-b-0 py-10">
<section
class="bg-soft-purple text-tertiary border-variant1 border-2 border-b-0 py-6 sm:py-8 md:py-10"
aria-labelledby="funding-partners-heading"
>
<div class="wrapper">
<div class="flex flex-col gap-8 lg:flex-row lg:items-center lg:justify-between">
<div
class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between lg:gap-8"
>
<!-- Funding Partners -->
<div class="flex flex-col gap-6 md:flex-row md:items-center">
<h3 class="text-tertiary text-base font-bold">{t("footer.funding.title")}</h3>
<div class="flex items-center gap-6">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:gap-6">
<h3
id="funding-partners-heading"
class="text-secondary text-sm font-bold sm:text-base"
>
{t("footer.funding.title")}
</h3>
<div
class="flex items-center gap-4"
role="list"
aria-label="Funding partner organizations"
>
<!-- Funding Partner Logos -->
<div class="flex items-center gap-4">
<img
<div class="flex items-center gap-3 sm:gap-4">
<FooterPartnerImage
src="/imgs/orgs/NGEU.png"
alt="Funded by the European Union NextGeneration EU"
class="h-10 w-auto"
client:load
/>
<img
<FooterPartnerImage
src="/imgs/orgs/MTES.png"
alt="Ministerio de Trabajo y Economía Social"
class="h-10 w-auto"
client:load
/>
<img
<FooterPartnerImage
src="/imgs/orgs/PRTR.png"
alt="Plan de Recuperación, Transformación y Resiliencia"
class="h-10 w-auto"
client:load
/>
</div>
</div>
</div>
<!-- Part Of Section -->
<div class="flex flex-col gap-4 md:flex-row md:items-center">
<h3 class="text-tertiary text-base font-medium">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:gap-4">
<h3 class="text-secondary text-sm font-medium sm:text-base">
{t("footer.funding.partOf")}
</h3>
<div class="flex items-center gap-3">
<a href="https://xes.cat">
<img
src="/imgs/orgs/xes.png"
alt="XES"
class="h-14 rounded-lg object-cover"
/>
</a>
<a href="https://www.alda-europe.eu">
<img
src="/imgs/orgs/alda-europe.svg"
alt="Alda Europe"
class="h-14 rounded-lg bg-white object-cover p-2"
/>
</a>
<a href="https://alianzaporelclima.org">
<img
src="/imgs/orgs/alianza-por-el-clima.webp"
alt="Alianza por el Clima"
class="h-14 rounded-lg object-cover"
/>
</a>
<a href="https://ampliandodemocracia.org">
<img
src="/imgs/orgs/ampliando-democracia.jpg"
alt="Ampliando Democracia"
class="h-14 rounded-lg object-cover"
/>
</a>
<div
class="flex items-center gap-2 sm:gap-3"
role="list"
aria-label="Partner organizations"
>
<FooterPartnerImage
src="/imgs/orgs/xes.png"
alt="XES - Xarxa d'Economia Solidària"
href="https://xes.cat"
class="rounded-lg"
size="medium"
client:load
/>
<FooterPartnerImage
src="/imgs/orgs/alda-europe.svg"
alt="Alda Europe - European Association for Local Democracy"
href="https://www.alda-europe.eu"
class="rounded-lg"
size="medium"
client:load
/>
<FooterPartnerImage
src="/imgs/orgs/alianza-por-el-clima.webp"
alt="Alianza por el Clima"
href="https://alianzaporelclima.org"
class="rounded-lg"
size="medium"
client:load
/>
<FooterPartnerImage
src="/imgs/orgs/ampliando-democracia.jpg"
alt="Ampliando Democracia"
href="https://ampliandodemocracia.org"
class="rounded-lg"
size="medium"
client:load
/>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Main Footer Content -->
<div class="bg-tertiary rounded-t-3xl py-10">
<div class="wrapper">
<div class="flex flex-row justify-between gap-8">
<!-- Navigation Links -->
<div class="flex flex-row gap-10">
<!-- Collaborate -->
<div class="space-y-2">
<h4 class="text-base leading-6 font-bold text-[#faf9ff]">
{t("footer.navigation.collaborate.title")}
</h4>
<ul class="space-y-1 text-sm leading-6">
<li>
<a
href="/create-project"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.collaborate.createProject")}
</a>
</li>
<li>
<a
href="/matchfunding"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.collaborate.matchfunding")}
</a>
</li>
<li>
<a
href="/workshops"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.collaborate.workshops")}
</a>
</li>
</ul>
</div>
<!-- Help -->
<div class="space-y-2">
<h4 class="text-base leading-6 font-bold text-[#faf9ff]">
{t("footer.navigation.help.title")}
</h4>
<ul class="space-y-1 text-sm leading-6">
<li>
<a
href="/faqs"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.help.faqs")}
</a>
</li>
<li>
<a
href="/contact"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.help.contact")}
</a>
</li>
<li>
<a
href="/tax-calculator"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.help.taxCalculator")}
</a>
</li>
</ul>
</div>
<!-- About Goteo -->
<div class="space-y-2">
<h4 class="text-base leading-6 font-bold text-[#faf9ff]">
{t("footer.navigation.aboutGoteo.title")}
</h4>
<ul class="space-y-1 text-sm leading-6">
<li>
<a
href="/about"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.aboutGoteo.whoWeAre")}
</a>
</li>
<li>
<a
href="/benefits"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.aboutGoteo.goteoBenefits")}
</a>
</li>
<li>
<a
href="/impact"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.aboutGoteo.foundationImpact")}
</a>
</li>
<li>
<a
href="/platoniq"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.aboutGoteo.platoniqFoundation")}
</a>
</li>
</ul>
</div>
<!-- For Users -->
<div class="space-y-2">
<h4 class="text-base leading-6 font-bold text-[#faf9ff]">
{t("footer.navigation.forUsers.title")}
</h4>
<ul class="space-y-1 text-sm leading-6">
<li>
<a
href="/wallet"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.forUsers.rechargeWallet")}
</a>
</li>
<li>
<a
href="/certificate"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.forUsers.downloadCertificate")}
</a>
</li>
<li>
<a
href="/funds"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.forUsers.accessComplementaryFunds")}
</a>
</li>
<li>
<a
href="/program"
class="text-purple-tint transition-colors hover:text-[#faf9ff]"
>
{t("footer.navigation.forUsers.designFundingProgram")}
</a>
</li>
</ul>
</div>
</div>
<!-- Logo -->
<div class="flex flex-1 shrink-0 justify-end">
<div class="h-10 w-[131px]">
<img src="/imgs/goteo-white-logo.svg" alt="Goteo" class="h-full w-full" />
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Footer -->
<div class="bg-tertiary pb-10">
<div class="wrapper">
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<!-- Legal Links -->
<div class="flex flex-wrap gap-8">
<a
href="/terms"
class="text-purple-tint text-xs leading-4 font-medium transition-colors hover:text-[#faf9ff]"
<div
class="bg-secondary relative rounded-t-3xl bg-[length:116%_114%] bg-[position:-127%_42%] bg-no-repeat min-[700px]:bg-[length:64%_108%] min-[700px]:bg-[position:114%]"
style="background-image: url('/path2.svg');"
>
<!-- Main Footer Content -->
<section
class="relative z-10 py-6 sm:py-8 md:py-10"
aria-labelledby="footer-navigation-heading"
>
<div class="wrapper">
<div class="flex flex-col gap-8 lg:flex-row lg:justify-between lg:gap-8">
<!-- Navigation Links -->
<nav
class="grid grid-cols-2 gap-6 sm:grid-cols-4 lg:flex lg:flex-row lg:gap-8 xl:gap-10"
aria-label="Footer navigation"
>
{t("footer.legal.termsOfUse")}
</a>
<a
href="/privacy"
class="text-purple-tint text-xs leading-4 font-medium transition-colors hover:text-[#faf9ff]"
>
{t("footer.legal.privacyPolicy")}
</a>
<a
href="/licenses"
class="text-purple-tint text-xs leading-4 font-medium transition-colors hover:text-[#faf9ff]"
>
{t("footer.legal.licenses")}
</a>
<a
href="/stats"
class="text-purple-tint text-xs leading-4 font-medium transition-colors hover:text-[#faf9ff]"
>
{t("footer.legal.stats")}
</a>
</div>
<h2 id="footer-navigation-heading" class="sr-only">Footer Navigation</h2>
<!-- Social Media and Platoniq -->
<div class="flex flex-col gap-6 md:flex-row md:items-center">
<!-- Social Media Icons -->
<div class="flex items-center gap-2">
<a
href="mailto:info@goteo.org"
class="transition-opacity hover:opacity-80"
aria-label="Email"
>
<FooterGmailIcon />
</a>
<a
href="https://instagram.com/goteofunding"
class="transition-opacity hover:opacity-80"
aria-label="Instagram"
>
<FooterInstagramIcon />
</a>
<a
href="https://facebook.com/goteofunding"
class="transition-opacity hover:opacity-80"
aria-label="Facebook"
>
<FooterFacebookIcon />
</a>
<a
href="https://twitter.com/goteofunding"
class="transition-opacity hover:opacity-80"
aria-label="X (Twitter)"
>
<FooterXIcon />
</a>
<a
href="https://www.linkedin.com/company/platoniqlab"
class="transition-opacity hover:opacity-80"
aria-label="LinkedIn"
>
<FooterLinkedinIcon />
</a>
</div>
<!-- Collaborate -->
<div class="space-y-2">
<h4 class="text-soft-purple text-sm leading-6 font-bold sm:text-base">
{t("footer.navigation.collaborate.title")}
</h4>
<ul class="space-y-1 text-xs leading-5 sm:text-sm sm:leading-6">
<li>
<FooterLink href="/create-project" client:load>
{t("footer.navigation.collaborate.createProject")}
</FooterLink>
</li>
<li>
<FooterLink href="/matchfunding" client:load>
{t("footer.navigation.collaborate.matchfunding")}
</FooterLink>
</li>
<li>
<FooterLink href="/workshops" client:load>
{t("footer.navigation.collaborate.workshops")}
</FooterLink>
</li>
</ul>
</div>
<!-- Platoniq Foundation -->
<div class="text-purple-tint">
<img src="/imgs/platoniq.png" alt="Platoniq" class="h-[21px]" />
<div class="text-[13px] leading-normal font-light">
{t("footer.platoniq.foundation")}
<!-- Help -->
<div class="space-y-2">
<h4 class="text-soft-purple text-sm leading-6 font-bold sm:text-base">
{t("footer.navigation.help.title")}
</h4>
<ul class="space-y-1 text-xs leading-5 sm:text-sm sm:leading-6">
<li>
<FooterLink href="/faqs" client:load>
{t("footer.navigation.help.faqs")}
</FooterLink>
</li>
<li>
<FooterLink href="/contact" client:load>
{t("footer.navigation.help.contact")}
</FooterLink>
</li>
<li>
<FooterLink href="/tax-calculator" client:load>
{t("footer.navigation.help.taxCalculator")}
</FooterLink>
</li>
</ul>
</div>
<!-- About Goteo -->
<div class="space-y-2">
<h4 class="text-soft-purple text-sm leading-6 font-bold sm:text-base">
{t("footer.navigation.aboutGoteo.title")}
</h4>
<ul class="space-y-1 text-xs leading-5 sm:text-sm sm:leading-6">
<li>
<FooterLink href="/about" client:load>
{t("footer.navigation.aboutGoteo.whoWeAre")}
</FooterLink>
</li>
<li>
<FooterLink href="/benefits" client:load>
{t("footer.navigation.aboutGoteo.goteoBenefits")}
</FooterLink>
</li>
<li>
<FooterLink href="/impact" client:load>
{t("footer.navigation.aboutGoteo.foundationImpact")}
</FooterLink>
</li>
<li>
<FooterLink href="/platoniq" client:load>
{t("footer.navigation.aboutGoteo.platoniqFoundation")}
</FooterLink>
</li>
</ul>
</div>
<!-- For Users -->
<div class="space-y-2">
<h4 class="text-soft-purple text-sm leading-6 font-bold sm:text-base">
{t("footer.navigation.forUsers.title")}
</h4>
<ul class="space-y-1 text-xs leading-5 sm:text-sm sm:leading-6">
<li>
<FooterLink href="/wallet" client:load>
{t("footer.navigation.forUsers.rechargeWallet")}
</FooterLink>
</li>
<li>
<FooterLink href="/certificate" client:load>
{t("footer.navigation.forUsers.downloadCertificate")}
</FooterLink>
</li>
<li>
<FooterLink href="/funds" client:load>
{t("footer.navigation.forUsers.accessComplementaryFunds")}
</FooterLink>
</li>
<li>
<FooterLink href="/program" client:load>
{t("footer.navigation.forUsers.designFundingProgram")}
</FooterLink>
</li>
</ul>
</div>
</nav>
<!-- Logo -->
<div class="flex justify-center lg:justify-end">
<div class="h-8 w-28 sm:h-10 sm:w-36">
<img
src="/imgs/goteo-white-logo.svg"
alt="Goteo logo"
class="h-full w-full object-contain"
loading="lazy"
/>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Bottom Footer -->
<section
class="relative z-10 pb-6 sm:pb-8 md:pb-10"
aria-labelledby="footer-bottom-heading"
>
<div class="wrapper">
<div
class="flex flex-col gap-4 sm:gap-6 lg:flex-row lg:items-center lg:justify-between"
>
<!-- Legal Links -->
<nav
class="flex flex-wrap gap-4 sm:gap-6 lg:gap-8"
aria-label="Legal navigation"
>
<h3 id="footer-bottom-heading" class="sr-only">Legal and Social Links</h3>
<FooterLink href="/terms" class="text-xs leading-4 font-medium" client:load>
{t("footer.legal.termsOfUse")}
</FooterLink>
<FooterLink
href="/privacy"
class="text-xs leading-4 font-medium"
client:load
>
{t("footer.legal.privacyPolicy")}
</FooterLink>
<FooterLink
href="/licenses"
class="text-xs leading-4 font-medium"
client:load
>
{t("footer.legal.licenses")}
</FooterLink>
<FooterLink href="/stats" class="text-xs leading-4 font-medium" client:load>
{t("footer.legal.stats")}
</FooterLink>
</nav>
<!-- Social Media and Platoniq -->
<div class="flex flex-col gap-4 sm:gap-6 md:flex-row md:items-center">
<!-- Social Media Icons -->
<div
class="flex items-center justify-center gap-2 lg:justify-start"
role="list"
aria-label="Social media links"
>
<a
href="mailto:info@goteo.org"
class="focus:ring-soft-purple focus:ring-offset-secondary rounded-sm transition-all duration-200 hover:opacity-80 focus:ring-2 focus:ring-offset-2 focus:outline-none"
aria-label="Send email to info@goteo.org"
>
<FooterGmailIcon />
</a>
<a
href="https://instagram.com/goteofunding"
class="focus:ring-soft-purple focus:ring-offset-secondary rounded-sm transition-all duration-200 hover:opacity-80 focus:ring-2 focus:ring-offset-2 focus:outline-none"
aria-label="Follow us on Instagram"
>
<FooterInstagramIcon />
</a>
<a
href="https://facebook.com/goteofunding"
class="focus:ring-soft-purple focus:ring-offset-secondary rounded-sm transition-all duration-200 hover:opacity-80 focus:ring-2 focus:ring-offset-2 focus:outline-none"
aria-label="Follow us on Facebook"
>
<FooterFacebookIcon />
</a>
<a
href="https://twitter.com/goteofunding"
class="focus:ring-soft-purple focus:ring-offset-secondary rounded-sm transition-all duration-200 hover:opacity-80 focus:ring-2 focus:ring-offset-2 focus:outline-none"
aria-label="Follow us on X (formerly Twitter)"
>
<FooterXIcon />
</a>
<a
href="https://www.linkedin.com/company/platoniqlab"
class="focus:ring-soft-purple focus:ring-offset-secondary rounded-sm transition-all duration-200 hover:opacity-80 focus:ring-2 focus:ring-offset-2 focus:outline-none"
aria-label="Connect with us on LinkedIn"
>
<FooterLinkedinIcon />
</a>
</div>
<!-- Platoniq Foundation -->
<div class="text-variant1 flex flex-col items-center lg:items-start">
<img
src="/imgs/platoniq.png"
alt="Platoniq Foundation logo"
class="h-4 sm:h-5"
loading="lazy"
/>
<div class="text-xs leading-normal font-light sm:text-sm">
{t("footer.platoniq.foundation")}
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</footer>
+12
View File
@@ -0,0 +1,12 @@
<script>
import { twMerge } from "tailwind-merge";
let { href, class: customClass = "", children } = $props();
const defaultClasses =
"text-variant1 hover:text-soft-purple focus:ring-soft-purple focus:ring-offset-secondary rounded-sm transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:outline-none";
</script>
<a {href} class={twMerge(defaultClasses, customClass)}>
{@render children()}
</a>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { ClassNameValue } from "tailwind-merge";
import { twMerge } from "tailwind-merge";
const sizeStyles = {
small: "h-8 sm:h-10 md:h-12",
medium: "h-10 sm:h-12 md:h-14",
};
let {
src,
alt,
href,
hrefAriaLabel,
size = "small",
class: customClass = "",
}: {
src: string;
alt: string;
href?: string;
hrefAriaLabel?: string;
size: keyof typeof sizeStyles;
class: ClassNameValue;
} = $props();
</script>
{#snippet image()}
<img
{src}
{alt}
loading="lazy"
class={twMerge("w-auto transition-opacity hover:opacity-90", sizeStyles[size], customClass)}
/>
{/snippet}
{#if href}
<a
{href}
class="focus:ring-soft-purple focus:ring-offset-soft-purple rounded-lg focus:ring-2 focus:ring-offset-2 focus:outline-none"
aria-label={hrefAriaLabel || `Visit ${href}`}
>
{@render image()}
</a>
{:else}
{@render image()}
{/if}
+147 -46
View File
@@ -1,11 +1,13 @@
---
import CartButton from "./CartButton.svelte";
import MenuIcon from "../svgs/MenuIcon.astro";
import CloseIconMenu from "../svgs/CloseIconMenu.svelte";
import Logo from "../svgs/Logo.astro";
import UserIcon from "../svgs/UserIcon.astro";
import Logout from "./Logout.astro";
import UiLanguages from "./UiLanguages.astro";
import UiLanguages from "./UiLanguages.svelte";
import HeaderButtons from "./HeaderButtons.svelte";
import SearchIcon from "../svgs/SearchIcon.svelte";
const { t } = Astro.locals;
@@ -13,56 +15,155 @@ let user;
const accessToken = Astro.cookies.get("access-token");
if (accessToken) {
// {"id":8,"token":"pat_XYZ","accountingId":"395711"}
user = JSON.parse(accessToken.value);
}
---
<header class="sticky top-0 z-100 w-full max-w-screen px-3 py-4 md:px-6 md:py-8">
<div
class="mx-auto flex max-w-[1440px] items-center justify-between rounded-xl border-1 border-[#f3f3f3] bg-[#fbfbfb80] px-4 py-6 backdrop-blur-xl"
>
<div class="flex items-center gap-4">
<a href="/" class="flex-shrink-0"><Logo /></a>
<HeaderButtons client:load />
</div>
<nav>
<ul class="flex items-center gap-2 md:gap-4">
{
accessToken && (
<li class="flex items-center">
<CartButton client:load />
<header class="sticky top-0 z-100 w-full px-2 py-3 md:px-6 md:py-8">
<div class="mx-auto max-w-[1440px] px-2">
<div
class="border-gray-light flex flex-col rounded-xl border-1 bg-white/50 backdrop-blur-xl transition-all duration-300"
id="header-container"
>
<div
class="flex items-center justify-between px-2 py-3 md:px-4 md:py-6"
id="header-main"
>
<div class="flex items-center gap-2 md:gap-4">
<a href="/" class="flex-shrink-0"><Logo /></a>
<div class="hidden sm:block">
<HeaderButtons client:load />
</div>
</div>
<nav>
<ul class="flex items-center gap-1 md:gap-4">
{
accessToken && (
<li class="flex items-center pr-2">
<CartButton client:load />
</li>
)
}
<li class="flex items-center gap-1 pr-2 text-sm md:text-base">
{
accessToken ? (
<a href="/me" class="flex w-full items-center gap-1">
<UserIcon />
<span class="hidden sm:inline">
{t("greeting")}, {user.person.firstName}
</span>
</a>
) : (
<a href="/login" class="flex w-full items-center gap-1">
<UserIcon />
<span class="hidden sm:inline">{t("header.login")}</span>
</a>
)
}
</li>
)
}
<li class="items-center gap-1 text-sm sm:flex md:text-base">
<UserIcon />
{
accessToken && (
<span class="hidden md:inline">
{t("greeting")}, {user.person.firstName}
</span>
)
}
{
!accessToken && (
<a href="/login">
<span class="hidden md:inline">{t("header.login")}</span>
<li class="hidden items-center pr-2 md:flex">
<UiLanguages client:load />
</li>
<li class="flex items-center md:hidden">
<button id="mobile-menu-button" class="p-1">
<div id="menu-icon">
<MenuIcon />
</div>
<div id="close-icon" class="hidden">
<CloseIconMenu client:load />
</div>
</button>
</li>
<li class="hidden items-center md:flex">
<MenuIcon />
</li>
</ul>
</nav>
</div>
<div
id="mobile-menu"
class="hidden translate-y-[-20px] transform overflow-hidden opacity-0 transition-all duration-300 ease-in-out md:hidden"
>
<div class="border-gray-light border-t-1">
<div class="flex flex-col gap-4 p-4">
<div class="flex gap-2">
<a
href="/search"
class="font-karla text-secondary disabled:bg-grey bg-purple-tint flex w-auto items-center justify-center gap-2 rounded-[16px] px-4 py-2 text-base leading-6 font-bold transition hover:cursor-pointer"
>
<SearchIcon width="16" height="16" />
{t("header.search")}
</a>
)
}
</li>
<li class="flex items-center">
<UiLanguages />
</li>
<li class="flex items-center md:hidden">
<button class="p-1" aria-label="Menu">
<MenuIcon />
</button>
</li>
<li class="hidden items-center md:flex">
<MenuIcon />
</li>
</ul>
</nav>
<a
href="/create/project"
class="font-karla bg-primary text-secondary hover:bg-primary-hover flex-1 rounded-lg px-3 py-2 text-center text-base leading-6 font-bold"
>
{t("header.createProject")}
</a>
</div>
<div class="flex flex-col gap-2 pt-1">
<UiLanguages client:load />
</div>
</div>
</div>
</div>
</div>
</div>
</header>
<script>
function initMobileMenu() {
const menuButton = document.getElementById("mobile-menu-button");
const mobileMenu = document.getElementById("mobile-menu");
const menuIcon = document.getElementById("menu-icon");
const closeIcon = document.getElementById("close-icon");
if (!menuButton || !mobileMenu || !menuIcon || !closeIcon) return;
function toggleMenu() {
const isOpen = !mobileMenu!.classList.contains("hidden");
if (isOpen) {
mobileMenu!.classList.add("translate-y-[-20px]", "opacity-0");
setTimeout(() => {
mobileMenu!.classList.add("hidden");
}, 200);
closeIcon!.classList.add("hidden");
menuIcon!.classList.remove("hidden");
} else {
mobileMenu!.classList.remove("hidden");
setTimeout(() => {
mobileMenu!.classList.remove("translate-y-[-20px]", "opacity-0");
}, 10);
menuIcon!.classList.add("hidden");
closeIcon!.classList.remove("hidden");
}
}
menuButton.addEventListener("click", toggleMenu);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !mobileMenu.classList.contains("hidden")) {
mobileMenu.classList.add("translate-y-[-20px]", "opacity-0");
setTimeout(() => {
mobileMenu.classList.add("hidden");
}, 200);
closeIcon.classList.add("hidden");
menuIcon.classList.remove("hidden");
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initMobileMenu);
} else {
initMobileMenu();
}
</script>
+4 -11
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import SearchIcon from "../svgs/SearchIcon.svelte";
import Button from "./library/Button.svelte";
function openSearch() {
window.location.href = "/search";
@@ -11,17 +12,9 @@
</script>
<div class="flex gap-2">
<button
onclick={openSearch}
class="flex justify-center items-center gap-2 px-4 py-2 rounded-2xl bg-purple-tint cursor-pointer hover:bg-[#D0CFED] transition-colors"
>
<Button size="sm" kind="secondary" onclick={openSearch}>
<SearchIcon width="16" height="16" />
Buscar
</button>
<button
onclick={goToCreateProject}
class="flex justify-center items-center gap-2 px-4 py-2 rounded-2xl bg-primary cursor-pointer hover:bg-[#38bfa7] transition-colors"
>
Crea tu proyecto
</button>
</Button>
<Button size="sm" kind="primary" onclick={goToCreateProject}>Crea tu proyecto</Button>
</div>
+3 -3
View File
@@ -9,7 +9,7 @@ const { t } = Astro.locals;
<div class="grid items-center gap-12 lg:grid-cols-2">
<!-- Hero Content -->
<div class="space-y-8">
<h1 class="text-tertiary text-4xl leading-tight font-bold lg:text-5xl xl:text-6xl">
<h1 class="text-secondary text-4xl leading-tight font-bold lg:text-5xl xl:text-6xl">
{t("hero.title")}
</h1>
@@ -20,12 +20,12 @@ const { t } = Astro.locals;
<!-- CTA Buttons -->
<div class="flex flex-col gap-4 sm:flex-row">
<!-- <button
class="text-tertiary bg-light-accent rounded-full border-0 px-8 py-4 font-semibold whitespace-nowrap transition-all duration-200 hover:opacity-90"
class="text-secondary bg-light-accent rounded-full border-0 px-8 py-4 font-semibold whitespace-nowrap transition-all duration-200 hover:opacity-90"
>
{t("hero.buttons.discover")}
</button>
<button
class="bg-primary text-tertiary rounded-full border-0 px-8 py-4 font-semibold whitespace-nowrap transition-all duration-200 hover:opacity-90"
class="bg-primary text-secondary rounded-full border-0 px-8 py-4 font-semibold whitespace-nowrap transition-all duration-200 hover:opacity-90"
>
{t("hero.buttons.impulse")}
</button> -->
+27 -49
View File
@@ -1,48 +1,29 @@
---
import TextInput from "./library/TextInput.svelte";
const { t } = Astro.locals;
---
<div>
<div class="mt-8 sm:w-full sm:max-w-md">
<form id="login" method="POST" class="flex flex-col gap-4">
<div class="relative w-full">
<input
type="text"
id="identifier"
name="identifier"
placeholder=""
class="peer w-full rounded-md border border-[#855a96] px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:ring-[#855a96] focus:outline-none"
required
/>
<label
for="identifier"
class="absolute top-1 left-4 text-[10px] font-medium text-gray-500 transition-all peer-placeholder-shown:top-4 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-400 peer-focus:top-1 peer-focus:text-[10px] peer-focus:text-gray-500"
>
{t("login.form.email")}
</label>
</div>
<div class="relative w-full">
<input
class="peer w-full rounded-md border border-[#855a96] px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:ring-[#855a96] focus:outline-none"
required
type="password"
id="password"
name="password"
placeholder=""
required
/>
<label
for="password"
class="absolute top-1 left-4 text-[10px] font-medium text-gray-500 transition-all peer-placeholder-shown:top-4 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-400 peer-focus:top-1 peer-focus:text-[10px] peer-focus:text-gray-500"
>
{t("login.form.password")}
</label>
</div>
<div class="mt-8 w-full max-w-md">
<form id="login" method="POST" class="flex flex-col gap-6">
<TextInput
name="identifier"
placeholder={t("login.form.email")}
required={true}
labelText={t("login.form.email")}
/>
<TextInput
type="password"
name="password"
placeholder={t("login.form.password")}
required={true}
labelText={t("login.form.password")}
/>
</form>
<div id="login-error-content" class="mt-4 text-center text-red-500"></div>
<!-- <a href="#" class="text-tertiary font-bold underline">
<!-- <a href="#" class="text-secondary font-bold underline">
{t("login.page.forgotPassword")}
</a> -->
</div>
@@ -50,14 +31,6 @@ const { t } = Astro.locals;
<script>
import { actions, isInputError } from "astro:actions";
import { itemCount } from "../stores/cart";
let hasCart = false;
itemCount.subscribe((count) => {
hasCart = count > 1;
console.log(count);
console.log(hasCart);
});
const form = document.getElementById("login");
const errorContent = document.getElementById("login-error-content");
@@ -88,11 +61,16 @@ const { t } = Astro.locals;
errorContent.innerText = error.message;
}
if (hasCart) {
window.location.href = `/payment`;
}
if (!error) {
const urlParams = new URLSearchParams(window.location.search);
const isFromCheckout = urlParams.get("checkout") === "true";
window.location.href = `/`;
if (isFromCheckout) {
window.location.href = `/payment`;
} else {
window.location.href = `/`;
}
}
});
}
</script>
+1 -1
View File
@@ -4,7 +4,7 @@ const { t } = Astro.locals;
<button
id="btn-logout"
class="bg-primary w-fit cursor-pointer rounded-xl px-4 py-2 font-bold text-[#462949]"
class="bg-primary text-secondary w-fit cursor-pointer rounded-xl px-4 py-2 font-bold"
>
{t("logout.label")}
</button>
@@ -1,93 +0,0 @@
<script lang="ts">
import { categories } from "../../utils/categories";
import SearchIcon from "../../svgs/SearchIcon.svelte";
import { t } from "../../i18n/store";
let showCategoryMenu = $state(false);
function toggleCategoryMenu() {
console.log("Button clicked! Current state:", showCategoryMenu);
showCategoryMenu = !showCategoryMenu;
console.log("New state:", showCategoryMenu);
}
function closeCategoryMenu() {
showCategoryMenu = false;
}
</script>
<nav>
<!-- Mobile Layout -->
<div class="flex w-full max-w-screen items-center gap-4 px-4 py-4 md:hidden">
<!-- Search Bar -->
<div class="relative flex-1">
<input
type="search"
placeholder={$t("navigation.search.placeholder")}
class="placeholder:text-tertiary w-full rounded-full border-none bg-[#E6E5F7] p-3 pl-12 text-sm placeholder:font-bold focus:bg-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
<div class="absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2 transform text-gray-500">
<SearchIcon />
</div>
</div>
<!-- Categories Button (Mobile Only) -->
<div class="relative">
<button
class="text-tertiary rounded-full border border-black bg-white px-6 py-3 text-sm font-bold whitespace-nowrap transition-colors hover:bg-gray-50"
onclick={toggleCategoryMenu}
>
{$t("navigation.categories")}
</button>
<!-- Categories Dropdown Menu -->
{#if showCategoryMenu}
<div
class="absolute top-full right-0 z-50 mt-2 max-h-64 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg"
>
<div class="p-2">
{#each categories as category}
<button
class="text-tertiary w-full rounded-md px-3 py-2 text-left text-sm font-medium hover:bg-gray-100"
onclick={closeCategoryMenu}
>
{$t(category.translationKey)}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
<!-- Desktop Layout -->
<div class="hidden justify-between gap-6 overflow-x-auto px-10 py-6 md:flex">
<div class="relative">
<input
type="search"
placeholder={$t("navigation.search.placeholder")}
class="placeholder:text-tertiary w-full rounded-full border-none bg-[#E6E5F7] p-3 pl-12 placeholder:font-bold focus:bg-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
<div class="absolute top-1/2 left-4 h-5 w-5 -translate-y-1/2 transform text-gray-500">
<SearchIcon />
</div>
</div>
<div class="relative min-w-52"></div>
<!-- Horizontal Categories (Desktop Only) -->
{#each categories as category}
<button class="text-tertiary font-bold">{$t(category.translationKey)}</button>
{/each}
</div>
</nav>
<!-- Overlay to close menu when clicking outside -->
{#if showCategoryMenu}
<div
class="fixed inset-0 z-40"
role="button"
tabindex="-1"
onclick={closeCategoryMenu}
onkeydown={(e) => e.key === "Escape" && closeCategoryMenu()}
></div>
{/if}
+21 -22
View File
@@ -22,7 +22,7 @@ const { t } = Astro.locals;
type="radio"
name="type"
value="organization"
class="relative h-6 w-6 appearance-none rounded-full border border-[#855a96] after:absolute after:top-1/2 after:left-1/2 after:hidden after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:transform after:rounded-full after:bg-purple-900 after:content-[''] checked:border-teal-400 checked:bg-teal-400 checked:after:block"
class="border-secondary relative h-6 w-6 appearance-none rounded-full border after:absolute after:top-1/2 after:left-1/2 after:hidden after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:transform after:rounded-full after:bg-purple-900 after:content-[''] checked:border-teal-400 checked:bg-teal-400 checked:after:block"
/>
<span class="text-gray-700">{t("register.form.userType.organization")}</span>
</label>
@@ -31,7 +31,7 @@ const { t } = Astro.locals;
<div class="flex flex-row gap-4">
<div class="relative w-full">
<input
class="peer w-full rounded-md border border-[#855a96] px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:ring-[#855a96] focus:outline-none"
class="peer border-secondary focus:ring-secondary w-full rounded-md border px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none"
required
type="text"
id="firstname"
@@ -49,7 +49,7 @@ const { t } = Astro.locals;
<div class="relative w-full">
<input
class="peer w-full rounded-md border border-[#855a96] px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:ring-[#855a96] focus:outline-none"
class="peer border-secondary focus:ring-secondary w-full rounded-md border px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none"
required
type="text"
id="lastname"
@@ -72,7 +72,7 @@ const { t } = Astro.locals;
id="identifier"
name="identifier"
placeholder=""
class="peer w-full rounded-md border border-[#855a96] px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:ring-[#855a96] focus:outline-none"
class="peer border-secondary focus:ring-secondary w-full rounded-md border px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none"
required
/>
<label
@@ -85,7 +85,7 @@ const { t } = Astro.locals;
<div class="relative w-full">
<input
class="peer w-full rounded-md border border-[#855a96] px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:ring-[#855a96] focus:outline-none"
class="peer border-secondary focus:ring-secondary w-full rounded-md border px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none"
required
type="password"
id="password"
@@ -101,7 +101,7 @@ const { t } = Astro.locals;
</label>
</div>
<div class="relative w-full text-xs font-medium text-[#575757]">
<div class="text-content relative w-full text-xs font-medium">
<span> {t("register.form.validation.password.minLength")}</span>
</div>
@@ -111,7 +111,7 @@ const { t } = Astro.locals;
<input
type="checkbox"
id="toggle-dni"
class="accent-primary mt-1 h-5 w-5 shrink-0 border border-gray-400 text-[#855a96] focus:ring-[#855a96]"
class="accent-primary text-secondary focus:ring-secondary mt-1 h-5 w-5 shrink-0 border border-gray-400"
/>
<span class="text-sm text-gray-700"
>{t("register.individual.taxId.msgDeduction")}</span
@@ -141,7 +141,7 @@ const { t } = Astro.locals;
<div class="relative w-full">
<input
id="fields-organization-razonSocial"
class="peer w-full rounded-md border border-[#855a96] px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:ring-[#855a96] focus:outline-none"
class="peer border-secondary focus:ring-secondary w-full rounded-md border px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none"
type="text"
id="razonSocial"
name="razonSocial"
@@ -159,7 +159,7 @@ const { t } = Astro.locals;
<div class="relative w-full">
<input
id="fields-organization-cif"
class="peer w-full rounded-md border border-[#855a96] px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:ring-[#855a96] focus:outline-none"
class="peer border-secondary focus:ring-secondary w-full rounded-md border px-4 pt-6 pb-2 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none"
type="text"
id="cif"
name="cif"
@@ -180,7 +180,7 @@ const { t } = Astro.locals;
<input
type="checkbox"
id="terms"
class="accent-primary mt-1 h-5 w-5 shrink-0 border border-gray-400 text-[#855a96] focus:ring-[#855a96]"
class="accent-primary text-secondary focus:ring-secondary mt-1 h-5 w-5 shrink-0 border border-gray-400"
/>
<span class="text-sm text-gray-700">
{t("register.form.termsCheckbox")}
@@ -190,21 +190,13 @@ const { t } = Astro.locals;
<input
type="checkbox"
id="cookies"
class="accent-primary mt-1 h-5 w-5 shrink-0 border border-gray-400 text-[#855a96] focus:ring-[#855a96]"
class="accent-primary text-secondary focus:ring-secondary mt-1 h-5 w-5 shrink-0 border border-gray-400"
/>
<span class="text-sm text-gray-700">
{t("register.form.policiesCheckbox")}
</span>
</label>
</div>
<!-- TODO: Merge with Btn "Continuar" -->
<button
class="bg-primary cursor-pointer rounded-3xl px-6 py-4 font-bold text-[#462949]"
type="submit"
>
{t("register.form.btnSubmit")}</button
>
</form>
<div id="register-error-content" class="mt-4 text-center text-red-500"></div>
</div>
@@ -225,8 +217,15 @@ const { t } = Astro.locals;
const { error } = await actions.register(formData);
if (!error) {
const lang = location.pathname.split("/")[1];
window.location.href = `/${lang}/`;
const urlParams = new URLSearchParams(window.location.search);
const isFromCheckout = urlParams.get("checkout") === "true";
if (isFromCheckout) {
window.location.href = `/payment`;
} else {
const lang = location.pathname.split("/")[1];
window.location.href = `/${lang}/`;
}
}
if (error && errorContent instanceof HTMLElement) {
@@ -299,7 +298,7 @@ const { t } = Astro.locals;
dniInput.classList.remove("border-[#e6e6e6]", "focus:ring-transparent");
if (isChecked) {
dniInput.classList.add("border-[#855a96]", "focus:ring-[#855a96]");
dniInput.classList.add("border-secondary", "focus:ring-secondary");
dniInput.classList.remove("cursor-not-allowed");
labelInput.classList.remove("cursor-not-allowed");
}
+10 -11
View File
@@ -5,6 +5,7 @@
import UserIcon from "../svgs/UserIcon.svelte";
import { formatCurrency } from "../utils/currencies";
import { renderMarkdown } from "../utils/renderMarkdown";
import Button from "./library/Button.svelte";
import RewardModal from "./RewardModal.svelte";
let {
@@ -32,12 +33,12 @@
</script>
<li
class="flex basis-1/3 flex-col items-center justify-between gap-4 gap-8 rounded-4xl border border-[#F3F3EF] bg-[#FFF] p-6 shadow-[0px_1px_3px_0px_#0000001A] md:gap-8"
class="border-grey flex basis-1/3 flex-col items-center justify-between gap-4 gap-8 rounded-4xl border bg-[#FFF] p-6 shadow-[0px_1px_3px_0px_#0000001A] md:gap-8"
class:opacity-50={!isAvailable}
class:cursor-not-allowed={!isAvailable}
>
<div class="flex flex-col gap-4">
<h3 class="text-tertiary line-clamp-2 w-full text-left text-2xl font-semibold">
<h3 class="text-secondary line-clamp-2 w-full text-left text-2xl font-semibold">
<div>
{@html $t(
"rewards.by-amount",
@@ -60,7 +61,7 @@
</div>
<div class="mt-auto flex w-full justify-between">
<div class="text-tertiary flex items-center justify-between gap-2 text-sm font-bold">
<div class="text-secondary flex items-center justify-between gap-2 text-sm font-bold">
<UserIcon />
<span>
{@html $t(
@@ -71,7 +72,7 @@
</span>
</div>
{#if reward.isFinite}
<div class="text-tertiary flex items-center justify-between gap-2 text-sm font-bold">
<div class="text-secondary flex items-center justify-between gap-2 text-sm font-bold">
<UnitIcon />
<span>
{@html $t(
@@ -84,16 +85,14 @@
{/if}
</div>
<button
type="button"
onclick={() => (openModal = true)}
<Button
kind="secondary"
class="w-full"
disabled={!isAvailable}
class:cursor-pointer={isAvailable}
class:cursor-not-allowed={!isAvailable}
class="text-tertiary inline-block w-full rounded-3xl bg-[#E6E5F7] px-6 py-4 font-bold transition"
onclick={() => (openModal = true)}
>
{$t("reward.donate")}
{formatCurrency(reward.money.amount, reward.money.currency)}
</button>
</Button>
</li>
<RewardModal {reward} {project} bind:open={openModal} />
+9 -16
View File
@@ -9,6 +9,7 @@
import { cart } from "../stores/cart";
import { renderMarkdown } from "../utils/renderMarkdown";
import { onMount } from "svelte";
import Button from "./library/Button.svelte";
let {
open = $bindable(false),
@@ -65,12 +66,12 @@
<Modal
bind:open
closeBtnClass="top-7 end-7 bg-transparent text-[#462949] hover:bg-transparent hover:text-[#462949] hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-[#462949] dark:hover:text-[#462949] dark:hover:bg-transparent"
closeBtnClass="top-7 end-7 bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent"
class="fixed top-1/2 left-1/2 w-full max-w-[900px] -translate-x-1/2 -translate-y-1/2 rounded-3xl bg-white p-6 shadow-lg backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
headerClass="py-2"
>
<div class="flex flex-col gap-6">
<div class="text-tertiary flex w-full flex-col gap-6 text-left text-[40px] font-semibold">
<div class="text-secondary flex w-full flex-col gap-6 text-left text-[40px] font-semibold">
<h3>
{@html $t(
"rewards.by-amount-or-more",
@@ -124,7 +125,7 @@
</div>
</div>
<div class="flex w-full gap-2">
<div class="text-tertiary flex items-center justify-between gap-2 text-sm font-bold">
<div class="text-secondary flex items-center justify-between gap-2 text-sm font-bold">
<UserIcon />
<span>
{@html $t(
@@ -136,7 +137,7 @@
</div>
{#if reward.isFinite}
<div
class="text-tertiary flex items-center justify-between gap-2 text-sm font-bold"
class="text-secondary flex items-center justify-between gap-2 text-sm font-bold"
>
<UnitIcon />
<span>
@@ -150,20 +151,12 @@
{/if}
</div>
<div class="flex flex-row gap-4">
<button
type="button"
onclick={() => updateAmount("close")}
class="text-tertiary border-tertiary inline-block w-full cursor-pointer rounded-3xl border bg-[#FFF] px-6 py-4 font-bold transition"
>
<Button kind="ghost" onclick={() => updateAmount("close")} class="w-full">
{$t("rewards.reward-donate-close")}
</button>
<button
type="button"
onclick={() => updateAmount("checkout")}
class="text-tertiary bg-primary bg inline-block w-full cursor-pointer rounded-3xl px-6 py-4 font-bold transition"
>
</Button>
<Button onclick={() => updateAmount("checkout")} class="w-full">
{$t("rewards.donation-free.btn")}
</button>
</Button>
</div>
</div>
</Modal>
+4 -4
View File
@@ -16,8 +16,8 @@
- useDataAttributes: Whether to toggle [data-tab-content] elements (default: true)
Design System:
- Active: border-primary, text-tertiary
- Inactive: border-purple-tint, text-secondary
- Active: border-primary, text-secondary
- Inactive: border-variant1, text-tertiary
- Hover: opacity-80
- Transition: 200ms ease
-->
@@ -85,8 +85,8 @@
<button
class="box-border flex items-center justify-center gap-2 overflow-visible rounded-tl-lg rounded-tr-lg border-b-2 px-6 py-2 transition-all duration-200 {currentTab ===
tab.id
? 'border-primary text-tertiary'
: 'border-purple-tint text-secondary'}"
? 'border-primary text-secondary'
: 'border-variant1 text-tertiary'}"
onclick={() => handleTabClick(tab.id)}
data-tab={tab.id}
>
+9 -26
View File
@@ -1,35 +1,18 @@
<script lang="ts">
import BookmarkIcon from "../svgs/BookmarkIcon.svelte";
import MapIcon from "../svgs/MapIcon.svelte";
import { t } from "../i18n/store";
import type { Project, Territory } from "../openapi/client";
import iso3166 from "iso-3166-2";
import { getTerritoryTag } from "../utils/getTerritoryTag";
import type { Project } from "../openapi/client";
import TerritoryTag from "./TerritoryTag.svelte";
import CategoryTag from "./CategoryTag.svelte";
export let lang: string;
export let project: Project;
function displayName(territory: Territory) {
const countryNames = new Intl.DisplayNames(lang, { type: "region" });
const country = countryNames.of(territory.country);
const tag = getTerritoryTag(territory);
if (!tag || tag === territory.country) {
return country;
}
const iso = iso3166.subdivision(tag!);
return iso?.name;
function parseCategoryIri(category: string) {
return category.split("/")[3];
}
</script>
<div class="flex w-auto gap-2">
<div class="border-secondary flex items-center gap-2 rounded-sm border px-2 py-1">
<BookmarkIcon />
{$t(`categories.${project.category}`)}
</div>
<div class="border-secondary flex items-center gap-2 rounded-sm border px-2 py-1">
<MapIcon />{displayName(project.territory)}
</div>
{#each project.categories as category}
<CategoryTag category={parseCategoryIri(category)} />
{/each}
<TerritoryTag territory={project.territory} />
</div>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import MapIcon from "../svgs/MapIcon.svelte";
import type { Territory } from "../openapi/client";
import iso3166 from "iso-3166-2";
import { getTerritoryTag } from "../utils/getTerritoryTag";
import Tag from "./library/Tag.svelte";
import type { Locale } from "../i18n/locales";
import { locale } from "../i18n/store";
let {
territory,
lang: initialLang = undefined,
}: {
territory: Territory;
lang?: Locale;
} = $props();
const lang = initialLang || $locale;
function displayName(territory: Territory) {
const countryNames = new Intl.DisplayNames(lang, { type: "region" });
const country = countryNames.of(territory.country!);
const tag = getTerritoryTag(territory);
if (!tag || tag === territory.country) {
return country;
}
const iso = iso3166.subdivision(tag!);
if (!iso) {
return country;
}
const subdivision = iso.name.split(",")[0];
return `${subdivision}, ${country}`;
}
</script>
<Tag>
<MapIcon />
<span>{displayName(territory)}</span>
</Tag>
+8 -9
View File
@@ -3,6 +3,7 @@
import type { Project, ProjectReward } from "../openapi/client";
import { formatCurrency } from "../utils/currencies";
import { renderMarkdown } from "../utils/renderMarkdown";
import Button from "./library/Button.svelte";
import RewardModal from "./RewardModal.svelte";
let {
@@ -30,12 +31,12 @@
</script>
<li
class="flex basis-1/3 flex-col items-center justify-between gap-8 rounded-4xl border border-[#F3F3EF] bg-[#FFF] p-6 shadow-[0px_1px_3px_0px_#0000001A]"
class="border-grey flex basis-1/3 flex-col items-center justify-between gap-8 rounded-4xl border bg-[#FFF] p-6 shadow-[0px_1px_3px_0px_#0000001A]"
class:opacity-50={!isAvailable}
class:cursor-not-allowed={!isAvailable}
>
<div class="flex flex-col gap-4">
<h3 class="text-tertiary line-clamp-2 w-full text-left text-2xl font-semibold">
<h3 class="text-secondary line-clamp-2 w-full text-left text-2xl font-semibold">
{reward.title}
</h3>
@@ -48,16 +49,14 @@
{/if}
</div>
<button
type="button"
onclick={() => (openModal = true)}
<Button
kind="secondary"
class="w-full"
disabled={!isAvailable}
class:cursor-pointer={isAvailable}
class:cursor-not-allowed={!isAvailable}
class="text-tertiary inline-block w-full rounded-3xl bg-[#E6E5F7] px-6 py-4 font-bold transition"
onclick={() => (openModal = true)}
>
{$t("reward.donate")}
{formatCurrency(reward.money.amount, reward.money.currency)}
</button>
</Button>
</li>
<RewardModal {reward} {project} bind:open={openModal} />
-57
View File
@@ -1,57 +0,0 @@
---
import { languagesList } from "../i18n/locales/index";
const currentLang: string = Astro.locals.lang || "es";
const languagesJSON = JSON.stringify(Object.keys(languagesList));
---
<select
class="rounded-lg border-none bg-[#fbfbfb] font-bold shadow-sm"
name="language"
id="language-select"
data-languages={languagesJSON}
>
{
Object.entries(languagesList).map(([lang, label]) => (
<option value={lang} selected={lang === currentLang}>
{lang.toUpperCase()}
</option>
))
}
</select>
<script>
document.addEventListener("DOMContentLoaded", () => {
const selectElement = document.getElementById("language-select");
if (!selectElement) return;
const languages = JSON.parse(selectElement.getAttribute("data-languages") || "[]");
selectElement.addEventListener("change", (event) => {
const target = event.target;
if (!target) return;
const selectedLang = (target as HTMLSelectElement).value;
if (!languages.includes(selectedLang)) {
console.error("Idioma no permitido:", selectedLang);
return;
}
document.cookie = `preferred-lang=${encodeURIComponent(selectedLang)}; Path=/; Max-Age=31536000; SameSite=Strict`;
let pathParts = window.location.pathname.split("/").filter(Boolean);
if (languages.includes(pathParts[0])) {
pathParts.shift();
}
if (selectedLang !== "es") {
pathParts.unshift(selectedLang);
}
const newPath = `/${pathParts.join("/")}`;
window.location.href = newPath;
});
});
</script>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import { locale } from "../i18n/store";
import { languagesList } from "../i18n/locales";
import { getDefaultLanguage } from "../utils/consts";
const languages = Object.keys(languagesList);
function handleSelect(event: Event) {
const selectedLang = (event.target as HTMLSelectElement).value;
if (!languages.includes(selectedLang)) {
console.error(`The language "${selectedLang}" is not supported by the interface.`);
return;
}
document.cookie = `preferred-lang=${encodeURIComponent(selectedLang)}; Path=/; Max-Age=31536000; SameSite=Strict`;
let pathParts = window.location.pathname.split("/").filter(Boolean);
if (languages.includes(pathParts[0])) {
pathParts.shift();
}
if (selectedLang !== getDefaultLanguage()) {
pathParts.unshift(selectedLang);
}
const newPath = `/${pathParts.join("/")}`;
window.location.href = newPath;
}
</script>
<select
id="language-select"
class="bg-light-surface rounded-lg border-none font-bold shadow-sm"
on:change={handleSelect}
>
{#each languages as lang}
<option value={lang} selected={lang === $locale}>
{lang.toUpperCase()}
</option>
{/each}
</select>
+4 -4
View File
@@ -20,10 +20,10 @@ const blogPost = {
<div class="flex flex-col gap-6 p-10 lg:min-h-0 lg:flex-1">
<!-- Main Content -->
<div class="flex flex-col gap-2">
<h2 class="text-tertiary text-4xl leading-12 font-bold">
<h2 class="text-secondary text-4xl leading-12 font-bold">
{blogPost.title}
</h2>
<p class="text-tertiary text-base leading-6">
<p class="text-secondary text-base leading-6">
{blogPost.excerpt}
</p>
</div>
@@ -32,9 +32,9 @@ const blogPost = {
<div class="flex">
<a
href={blogPost.href}
class="group border-tertiary hover:bg-tertiary flex items-center justify-center gap-2 rounded-3xl border px-6 py-4 transition-all duration-200"
class="group border-secondary hover:bg-secondary flex items-center justify-center gap-2 rounded-3xl border px-6 py-4 transition-all duration-200"
>
<span class="text-tertiary text-base font-bold group-hover:text-white">
<span class="text-secondary text-base font-bold group-hover:text-white">
{blogPost.buttonText}
</span>
</a>
-126
View File
@@ -1,126 +0,0 @@
---
import type { Campaign, CampaignSize } from "../../types/campaign";
import ShareIcon from "../../svgs/ShareIcon.svelte";
import ClockIcon from "../../svgs/ClockIcon.svelte";
import MatchFundingIcon from "../../svgs/MatchFundingIcon.astro";
import { formatCurrency } from "../../utils/currencies";
export interface Props {
size: CampaignSize;
campaign: Campaign;
}
const { size, campaign } = Astro.props;
const { t } = Astro.locals;
// Define responsive classes based on size
// Large cards span 2 columns in lg+ (3-column grid), 2 columns in md (2-column grid), full width on mobile
const containerClasses =
size === "large" ? "col-span-1 md:col-span-2 lg:col-span-2 h-[633px]" : "col-span-1 h-auto";
const imageHeight = "h-[389px]"; // Same height for both sizes
---
<div
class={`bg-light-surface rounded-[32px] grow basis-0 min-w-sm border border-light-muted shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1)] p-6 ${containerClasses}`}
>
<a href=`/project/${campaign.id}`>
<div class={`flex flex-col gap-6 ${size === "large" ? "h-full" : ""}`}>
<!-- Project Image -->
<div
class={`relative ${imageHeight} w-full rounded-3xl bg-center bg-cover`}
style={`background-image: url('${campaign.image}')`}
>
<!-- Share Button (only for small cards) -->
<!-- {
size === "small" && (
<button class="border-light-muted bg-light-surface absolute right-4 bottom-4 flex h-10 w-10 items-center justify-center rounded-full border shadow-[0px_2px_4px_0px_rgba(0,0,0,0.16)] transition-all duration-200 hover:opacity-80">
<ShareIcon />
</button>
)
} -->
<!-- Tags Overlay -->
<div class="absolute top-4 left-4 flex gap-2">
<!-- Days Remaining Tag -->
<!-- <div
class="bg-light-muted flex items-center gap-2 rounded border border-white px-2 py-1"
>
<ClockIcon />
<span class="text-tertiary text-sm font-bold whitespace-nowrap">
{t("home.campaigns.daysRemaining", { days: campaign.daysRemaining })}
</span>
</div> -->
<!-- Matchfunding Tag (conditional) -->
{
campaign.hasMatchfunding && (
<div class="bg-light-muted flex items-center gap-2 rounded border border-white px-2 py-1">
<MatchFundingIcon />
<span class="text-tertiary text-sm font-bold whitespace-nowrap">
{t("home.campaigns.matchfunding")}
</span>
</div>
)
}
<!-- Additional Tags -->
{
campaign.tags?.map((tag) => (
<div class="bg-light-muted flex items-center gap-2 rounded border border-white px-2 py-1">
<span class="text-tertiary text-sm font-bold whitespace-nowrap">
{tag}
</span>
</div>
))
}
</div>
</div>
<!-- Project Content -->
<div class={`flex flex-col gap-6 ${size === "large" ? "flex-1" : ""}`}>
<h3 class="text-tertiary max-h-16 overflow-hidden text-2xl leading-8 font-bold">
{campaign.title}
</h3>
<!-- Progress Bar -->
<div class="flex flex-col gap-4">
<!-- <div
class="border-purple-tint bg-light-surface relative h-4 w-full rounded-2xl border"
>
<div
class="from-primary to-purple-tint absolute top-0 left-0 h-full rounded-2xl bg-gradient-to-r transition-all duration-300"
style={`width: ${campaign.progressPercentage}%`}
>
</div>
</div> -->
<!-- Amount Information -->
<div class="flex items-start justify-between">
<div class="flex flex-col gap-1">
<span class="text-secondary text-base">
{t("home.campaigns.obtained")}
</span>
<span class="text-secondary text-2xl font-bold">
{
formatCurrency(
campaign.obtained.amount,
campaign.obtained.currency,
)
}
</span>
</div>
<div class="flex flex-col gap-1 text-right">
<span class="text-secondary text-base">
{t("home.campaigns.minimum")}
</span>
<span class="text-secondary text-2xl font-bold">
{formatCurrency(campaign.minimum.amount, campaign.minimum.currency)}
</span>
</div>
</div>
</div>
</div>
</div>
</a>
</div>
+234
View File
@@ -0,0 +1,234 @@
<!--
Campaign Card Component (Svelte version)
Displays campaign information in a card format with responsive sizing
Converted from CampaignCard.astro to maintain exact functionality
-->
<script lang="ts">
import type { Campaign, CampaignSize } from "../../types/campaign";
import { t } from "../../i18n/store";
import { formatCurrency } from "../../utils/currencies";
import { twMerge } from "tailwind-merge";
import MatchFundingIcon from "../../svgs/MatchFundingIcon.svelte";
import ClockIcon from "../../svgs/ClockIcon.svelte";
import CategoryIcon from "../../svgs/CategoryIcon.svelte";
import Tag from "../library/Tag.svelte";
import CampaignStatusBadge from "../library/CampaignStatusBadge.svelte";
interface Props {
size: CampaignSize;
campaign: Campaign;
showUserDonations?: boolean;
showOwnerActions?: boolean;
class?: string;
}
let {
size,
campaign,
showUserDonations = false,
showOwnerActions = false,
class: className = "",
}: Props = $props();
// Define responsive classes based on size
// Large cards span 2 columns in lg+ (3-column grid), 2 columns in md (2-column grid), full width on mobile
const sizeClasses = $derived(
size === "large" ? "col-span-1 md:col-span-2 lg:col-span-2" : "col-span-1",
);
const imageHeight = "h-[215px]"; // More rectangular proportions matching design
// Calculate funding status and remaining amount
const hasReachedMinimum = $derived(campaign.obtained.amount >= campaign.minimum.amount);
// Determine status badge text based on funding level
// Using lookup pattern for consistency with other i18n implementations
const statusBadgeText = $derived.by(() => {
const key = hasReachedMinimum ? "minimumReached" : "goForMinimum";
return $t(`home.campaigns.status.${key}`);
});
// Calculate remaining amount (to minimum or optimum)
const remainingToGoal = $derived(() => {
if (hasReachedMinimum && campaign.optimum) {
// Show remaining to optimum if minimum is reached
const remaining = campaign.optimum.amount - campaign.obtained.amount;
return {
amount: remaining > 0 ? remaining : 0,
label: $t("home.campaigns.remaining.toOptimum"),
currency: campaign.optimum.currency,
};
} else {
// Show remaining to minimum
const remaining = campaign.minimum.amount - campaign.obtained.amount;
return {
amount: remaining > 0 ? remaining : 0,
label: $t("home.campaigns.remaining.toMinimum"),
currency: campaign.minimum.currency,
};
}
});
// Get first category only (as per review comments)
const firstCategory = $derived(() => {
if (Array.isArray(campaign.category)) {
return campaign.category[0] || null;
}
return campaign.category || null;
});
</script>
<div
class={twMerge(
"bg-light-surface border-grey grow basis-0 rounded-[32px] border p-6 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1)]",
sizeClasses,
className,
)}
data-testid="campaign-card"
>
<!-- Note: campaign.id is actually the project slug, not a numeric ID -->
<a href="/project/{campaign.id}">
<div class="flex flex-col gap-4 md:gap-6">
<!-- Project Image -->
<div
class="relative {imageHeight} w-full rounded-3xl bg-cover bg-center"
style="background-image: url('{campaign.image}')"
>
<!-- Tags Overlay (top-left) -->
<div class="absolute top-4 left-4 flex gap-2">
<!-- Matchfunding Tag (conditional) -->
{#if campaign.hasMatchfunding}
<Tag>
<MatchFundingIcon />
<span>{$t("home.campaigns.matchfunding")}</span>
</Tag>
{/if}
<!-- Additional Tags -->
{#if campaign.tags}
{#each campaign.tags as tag}
<Tag>
{tag}
</Tag>
{/each}
{/if}
</div>
<!-- Status Badge (top-right) -->
{#if campaign.status === "in_campaign"}
<div class="absolute top-4 right-4">
<CampaignStatusBadge text={statusBadgeText} />
</div>
{/if}
</div>
<!-- Project Content -->
<div class="flex flex-col gap-4 md:gap-6">
<!-- Days Remaining & Category -->
<div class="flex items-center gap-2 md:gap-4">
<!-- Days Remaining -->
{#if campaign.daysRemaining !== undefined}
<div class="flex items-center gap-2">
<ClockIcon />
<span class="text-sm text-black">
{$t("home.campaigns.daysRemaining", {
days: campaign.daysRemaining,
})}
</span>
</div>
{/if}
<!-- Category (display only first) -->
{#if firstCategory()}
<div class="flex items-center gap-2">
<CategoryIcon />
<span class="text-sm text-black">
{$t(`categories.${firstCategory()}`)}
</span>
</div>
{/if}
</div>
<!-- Title -->
<h3 class="text-secondary h-16 overflow-hidden text-2xl leading-8 font-bold">
{campaign.title}
</h3>
<!-- Funding Information -->
<div class="flex flex-col gap-2">
<!-- Obtained Amount -->
<div class="flex items-start justify-between">
<div class="flex flex-col gap-1">
<span class="text-secondary text-base"
>{$t("home.campaigns.obtained")}</span
>
<span class="text-secondary text-2xl font-bold">
{formatCurrency(
campaign.obtained.amount,
campaign.obtained.currency,
)}
</span>
</div>
<!-- Remaining to Goal -->
<div class="flex flex-col gap-1 text-right">
{#if campaign.optimum && campaign.obtained.amount >= campaign.minimum.amount}
<span class="text-secondary text-base">
{$t("home.campaigns.optimum")}
</span>
<span class="text-secondary text-2xl font-bold">
{formatCurrency(
campaign.optimum.amount,
campaign.optimum.currency,
)}
</span>
{:else}
<span class="text-secondary text-base">
{$t("home.campaigns.minimum")}
</span>
<span class="text-secondary text-2xl font-bold">
{formatCurrency(
campaign.minimum.amount,
campaign.minimum.currency,
)}
</span>
{/if}
</div>
</div>
</div>
<!-- User Donations Footer -->
{#if showUserDonations && campaign.userDonations}
<div
class="bg-primary -mx-6 -mb-6 flex items-center justify-between rounded-b-3xl px-6 py-4"
>
<span class="text-base font-normal text-black"
>{$t("home.campaigns.userDonations")}</span
>
<span class="text-2xl font-bold text-black">
{formatCurrency(
campaign.userDonations.amount,
campaign.userDonations.currency,
)}
</span>
</div>
{/if}
<!-- Owner Actions Footer -->
{#if showOwnerActions}
<div class="flex w-full gap-4">
<button
class="border-secondary text-secondary hover:bg-secondary flex-1 rounded-3xl border px-4 py-4 text-base font-bold transition-colors hover:text-white"
>
{$t("me.ownedProjects.messageToDonatorsButton")}
</button>
<button
class="bg-variant1 text-secondary hover:bg-light-accent flex-1 rounded-3xl px-4 py-4 text-base font-bold transition-colors"
>
{$t("me.ownedProjects.uploadNewsButton")}
</button>
</div>
{/if}
</div>
</div>
</a>
</div>
+6 -5
View File
@@ -1,8 +1,8 @@
---
import CampaignCard from "./CampaignCard.astro";
import CampaignCard from "./CampaignCard.svelte";
import type { Campaign } from "../../types/campaign";
import ArrowRightIcon from "../../svgs/ArrowRightIcon.svelte";
import { apiProjectsGetCollection, type Accounting, type ApiMoney } from "../../openapi/client";
import { apiProjectsGetCollection, type Accounting, type Money } from "../../openapi/client";
import { getLanguage } from "../../middleware/utils";
import { client } from "../../openapi/client/client.gen";
@@ -39,7 +39,8 @@ const campaigns: Campaign[] = await Promise.all(
highlightedProjects[project.slug! as keyof typeof highlightedProjects] ||
project.video?.thumbnail!,
minimum: project.budget?.minimum?.money!,
obtained: accounting!.balance as ApiMoney,
obtained: accounting!.balance as Money,
optimum: project.budget?.optimum?.money,
};
}),
);
@@ -80,11 +81,11 @@ const displayCampaigns = selectCampaignsForGrid(campaigns);
<!-- "Ver todas" Button -->
<button
class="bg-purple-tint flex items-center gap-2 rounded-3xl px-6 py-4 transition-all duration-200 hover:opacity-90"
class="bg-variant1 flex items-center gap-2 rounded-3xl px-6 py-4 transition-all duration-200 hover:opacity-90"
>
<!-- Arrow Icon -->
<ArrowRightIcon />
<span class="text-tertiary text-base font-bold">
<span class="text-secondary text-base font-bold">
{t("home.campaigns.viewAll")}
</span>
</button>
+4 -3
View File
@@ -1,5 +1,5 @@
---
import CampaignCard from "./CampaignCard.astro";
import CampaignCard from "./CampaignCard.svelte";
import Carousel from "../Carousel.svelte";
import CampaignsTabsNav from "./CampaignsTabsNav.svelte";
import type { Campaign } from "../../types/campaign";
@@ -7,7 +7,7 @@ import {
apiProjectsGetCollection,
type Accounting,
type ApiAccountingsIdGetData,
type ApiMoney,
type Money,
} from "../../openapi/client";
import { getLanguage } from "../../middleware/utils";
import { client } from "../../openapi/client/client.gen";
@@ -44,7 +44,8 @@ const campaigns: Campaign[] = await Promise.all(
chosenProjects[project.slug! as keyof typeof chosenProjects] ||
project.video?.thumbnail!,
minimum: project.budget?.minimum?.money!,
obtained: accounting!.balance as ApiMoney,
optimum: project.budget?.optimum?.money,
obtained: accounting!.balance as Money,
};
}),
);
+2 -2
View File
@@ -38,12 +38,12 @@ const promoters: Promoter[] = [
<div class="flex flex-col gap-6">
<!-- Header with title and view all button -->
<div class="flex items-center justify-between px-0 md:px-0">
<h2 class="text-secondary text-2xl leading-8 font-bold md:text-4xl md:leading-[48px]">
<h2 class="text-2xl leading-8 font-bold text-black">
{t("home.featuredPromoters.title")}
</h2>
<a
href="/promoters"
class="bg-purple-tint text-tertiary hover:bg-tertiary flex items-center gap-2 rounded-3xl px-6 py-4 text-base leading-6 font-bold transition-all duration-200 hover:text-white"
class="bg-variant1 text-secondary hover:bg-secondary flex items-center gap-2 rounded-3xl px-6 py-4 text-base leading-6 font-bold transition-all duration-200 hover:text-white"
>
<ArrowRightIcon />
{t("home.featuredPromoters.viewAll")}
+4 -4
View File
@@ -56,7 +56,7 @@ const calls: CallCard[] = [
];
---
<section class="bg-tertiary w-full text-white">
<section class="bg-secondary w-full text-white">
<div class="wrapper relative !my-[-32px]">
<!-- Two top info panels -->
<div class="flex flex-col gap-6">
@@ -82,7 +82,7 @@ const calls: CallCard[] = [
</a>
<a
href="/convocatorias"
class="bg-light-accent text-tertiary rounded-3xl px-6 py-4 text-sm font-bold transition hover:opacity-90"
class="bg-light-accent text-secondary rounded-3xl px-6 py-4 text-sm font-bold transition hover:opacity-90"
>
{t("home.matchfunding.actions.viewCalls")}
</a>
@@ -137,13 +137,13 @@ const calls: CallCard[] = [
</div>
<a
href="/convocatorias"
class="bg-light-accent text-tertiary w-fit rounded-3xl px-6 py-4 text-sm font-bold transition hover:opacity-90"
class="bg-light-accent text-secondary w-fit rounded-3xl px-6 py-4 text-sm font-bold transition hover:opacity-90"
>
{card.cta}
</a>
</div>
{card.tag && (
<div class="absolute top-6 right-6 z-20 rounded bg-[#E94668] px-2 py-1 text-xs font-medium text-white">
<div class="bg-tertiary absolute top-6 right-6 z-20 rounded px-2 py-1 text-xs font-medium text-white">
{card.tag}
</div>
)}
+6 -6
View File
@@ -12,7 +12,7 @@ const { t } = Astro.locals;
<div class="bg-light-surface relative overflow-hidden rounded-3xl shadow-lg">
<!-- Decorative background with gradient and shapes -->
<div class="bg-tertiary relative h-[389px] overflow-hidden rounded-t-3xl">
<div class="bg-secondary relative h-[389px] overflow-hidden rounded-t-3xl">
<!-- Gradient background -->
<div class="from-primary/30 to-tertiary absolute inset-0 bg-gradient-to-br"></div>
@@ -81,7 +81,7 @@ const { t } = Astro.locals;
<!-- Content area -->
<div class="flex flex-col gap-6 p-6">
<!-- Name -->
<h3 class="text-tertiary text-2xl leading-8 font-bold">
<h3 class="text-secondary text-2xl leading-8 font-bold">
{promoter.name}
</h3>
@@ -89,20 +89,20 @@ const { t } = Astro.locals;
<div class="flex items-center justify-between">
<!-- Projects promoted -->
<div class="flex flex-col gap-1 text-left">
<span class="text-secondary text-base leading-6">
<span class="text-content text-base leading-6">
{t("home.featuredPromoters.projectsPromoted")}
</span>
<span class="text-secondary text-2xl leading-8 font-bold">
<span class="text-content text-2xl leading-8 font-bold">
{promoter.projectsPromoted}
</span>
</div>
<!-- Capital raised -->
<div class="flex flex-col gap-1 text-right">
<span class="text-secondary text-base leading-6">
<span class="text-content text-base leading-6">
{t("home.featuredPromoters.capitalRaised")}
</span>
<span class="text-secondary text-2xl leading-8 font-bold">
<span class="text-content text-2xl leading-8 font-bold">
{promoter.capitalRaised}
</span>
</div>
+103 -49
View File
@@ -5,49 +5,98 @@ const { t } = Astro.locals;
<section data-renovation-banner class="wrapper renovation-banner">
<!-- Content -->
<div
class="bg-tertiary relative mx-auto flex items-center justify-between gap-6 overflow-hidden rounded-3xl p-8 text-white"
class="bg-secondary relative mx-auto overflow-hidden rounded-3xl p-4 text-white sm:p-6 md:p-8"
>
<!-- Main Content -->
<div class="flex-1 space-y-3 pr-0 md:pr-6">
<h2 class="text-3xl font-bold tracking-tight md:text-4xl">
{t("renovationBanner.title")}
</h2>
<p class="max-w-3xl text-base leading-relaxed text-purple-100/90 md:text-lg">
{t("renovationBanner.description")}
</p>
<!-- Detail Images Container -->
<div class="pointer-events-none absolute inset-0 -z-0">
<img
src="/imgs/banner-home.svg"
alt="Renovation detail"
class="absolute -top-[23%] right-[388px] h-[226px] w-[244px]"
style="transform: translateY(-25%) rotate(4deg);"
/>
<img
src="/imgs/banner-home.svg"
alt="Renovation detail"
class="absolute top-[54%] right-[214px] h-[226px] w-[244px]"
style="transform: translateY(-37%) rotate(-2deg);"
/>
<img
src="/imgs/banner-home.svg"
alt="Renovation detail"
class="absolute top-[24%] right-0 h-[226px] w-[244px]"
style="transform: translateY(-60%) rotate(2deg);"
/>
</div>
<!-- CTA Button -->
<div class="flex items-center gap-3 md:gap-4">
<a
href="#"
class="text-tertiary bg-light-accent rounded-full px-8 py-4 font-semibold whitespace-nowrap shadow-md transition-colors duration-200 hover:bg-white"
aria-label={t("renovationBanner.ctaButton")}
<!-- Close Button - Mobile (Top Right) -->
<button
class="renovation-banner-close absolute top-4 right-4 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-colors hover:bg-white/10 md:hidden"
aria-label={t("renovationBanner.closeAriaLabel") || "Close banner"}
data-close-renovation-banner
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
{t("renovationBanner.ctaButton")}
</a>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span class="sr-only">{t("renovationBanner.close") || "Close"}</span>
</button>
<!-- Close Button -->
<button
class="renovation-banner-close flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full"
aria-label="Close banner"
data-close-renovation-banner
>
<svg
class="h-10 w-10"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
<!-- Content Container -->
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-6">
<!-- Main Content -->
<div class="flex-1 space-y-3 pr-0 md:pr-6">
<h2 class="text-2xl font-bold tracking-tight sm:text-3xl md:text-4xl">
{t("renovationBanner.title")}
</h2>
<p
class="text-variant1 max-w-3xl text-sm leading-relaxed opacity-90 sm:text-base md:text-lg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span class="sr-only">Close</span>
</button>
{t("renovationBanner.description")}
</p>
</div>
<!-- CTA Button -->
<div class="flex items-center gap-3 md:gap-4">
<a
href={t("renovationBanner.ctaLink") || "/renovation"}
class="text-secondary bg-light-accent focus:ring-offset-secondary rounded-full px-6 py-3 text-center font-semibold whitespace-nowrap shadow-md transition-colors duration-200 hover:bg-white focus:ring-2 focus:ring-white focus:ring-offset-2 focus:outline-none sm:px-8 sm:py-4"
aria-label={t("renovationBanner.ctaAriaLabel") ||
t("renovationBanner.ctaButton")}
>
{t("renovationBanner.ctaButton")}
</a>
<!-- Close Button - Desktop -->
<button
class="renovation-banner-close hidden h-9 w-9 flex-shrink-0 items-center justify-center rounded-full transition-colors hover:bg-white/10 md:flex"
aria-label={t("renovationBanner.closeAriaLabel") || "Close banner"}
data-close-renovation-banner
>
<svg
class="h-10 w-10"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span class="sr-only">{t("renovationBanner.close") || "Close"}</span>
</button>
</div>
</div>
</div>
</section>
@@ -59,25 +108,30 @@ const { t } = Astro.locals;
if (typeof window === "undefined") return;
const banner = document.querySelector("[data-renovation-banner]");
const closeButton = document.querySelector("[data-close-renovation-banner]");
const closeButtons = document.querySelectorAll("[data-close-renovation-banner]");
const storageKey = "renovationBannerClosed";
const STORAGE_KEY = "renovationBannerClosed";
const hide = () => {
const hideBanner = () => {
if (!banner) return;
banner.setAttribute("hidden", "");
banner.setAttribute("aria-hidden", "true");
};
if (window.localStorage.getItem(storageKey) === "true") {
hide();
if (window.localStorage.getItem(STORAGE_KEY) === "true") {
hideBanner();
}
if (closeButton && banner) {
closeButton.addEventListener("click", () => {
hide();
try {
window.localStorage.setItem(storageKey, "true");
} catch (_) {}
if (banner && closeButtons.length > 0) {
closeButtons.forEach((button) => {
button.addEventListener("click", () => {
hideBanner();
try {
window.localStorage.setItem(STORAGE_KEY, "true");
} catch (error) {
console.warn("Could not save banner state to localStorage:", error);
}
});
});
}
});
+4 -4
View File
@@ -10,7 +10,7 @@ export interface Props {
const { title, description, image, href = "#", linkText = "Leer más" } = Astro.props;
---
<article class="bg-light-surface border-light-muted h-full rounded-3xl border p-6 shadow-sm">
<article class="bg-light-surface border-grey h-full rounded-3xl border p-6 shadow-sm">
<div class="flex h-full flex-col gap-6 md:flex-row">
<!-- Image -->
<div class="shrink-0">
@@ -27,12 +27,12 @@ const { title, description, image, href = "#", linkText = "Leer más" } = Astro.
<!-- Text content -->
<div class="flex flex-1 flex-col gap-2">
<h3
class="text-tertiary line-clamp-2 text-xl leading-7 font-bold md:text-2xl md:leading-8"
class="text-secondary line-clamp-2 text-xl leading-7 font-bold md:text-2xl md:leading-8"
>
{title}
</h3>
<p
class="line-clamp-3 text-sm leading-5 text-[#575757] md:line-clamp-4 md:text-base md:leading-6"
class="text-content line-clamp-3 text-sm leading-5 md:line-clamp-4 md:text-base md:leading-6"
>
{description}
</p>
@@ -42,7 +42,7 @@ const { title, description, image, href = "#", linkText = "Leer más" } = Astro.
<div class="shrink-0">
<a
href={href}
class="text-tertiary text-sm leading-5 font-bold underline underline-offset-2 transition-opacity hover:opacity-80 md:text-base md:leading-6"
class="text-secondary text-sm leading-5 font-bold underline underline-offset-2 transition-opacity hover:opacity-80 md:text-base md:leading-6"
>
{linkText}
</a>
+1 -1
View File
@@ -54,7 +54,7 @@ const resources = [
</h2>
<a
href="/recursos"
class="bg-purple-tint text-tertiary hover:bg-tertiary flex items-center gap-2 rounded-3xl px-4 py-3 text-sm leading-5 font-bold transition-all duration-200 hover:text-white md:px-6 md:py-4 md:text-base md:leading-6"
class="bg-variant1 text-secondary hover:bg-secondary flex items-center gap-2 rounded-3xl px-4 py-3 text-sm leading-5 font-bold transition-all duration-200 hover:text-white md:px-6 md:py-4 md:text-base md:leading-6"
>
<ArrowRightIcon />
<span class="hidden sm:inline">{t("home.resources.viewAll")}</span>
+23 -20
View File
@@ -3,18 +3,21 @@ const { t } = Astro.locals;
---
<section class="wrapper py-16">
<h2 class="mb-6 text-2xl font-bold text-black">
{t("home.stats.title")}
</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- First Card: De la teoría a la acción -->
<div class="bg-light-muted flex flex-col gap-6 rounded-3xl p-6 shadow-sm md:gap-10 md:p-10">
<div class="bg-grey flex flex-col gap-6 rounded-3xl p-6 shadow-sm md:gap-10 md:p-10">
<div class="flex flex-col gap-6">
<!-- Header -->
<div class="flex flex-col gap-2">
<h2
class="text-tertiary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
>
{t("home.stats.theoryToAction.title")}
</h2>
<p class="text-tertiary text-sm leading-5 md:text-base md:leading-6">
<p class="text-secondary text-sm leading-5 md:text-base md:leading-6">
{t("home.stats.theoryToAction.subtitle")}
</p>
</div>
@@ -23,31 +26,31 @@ const { t } = Astro.locals;
<div class="flex flex-col items-center justify-between gap-4 md:flex-row md:gap-0">
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-tertiary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
>
{t("home.stats.theoryToAction.projectsReceived.value")}
</div>
<div class="text-tertiary text-sm leading-5 md:text-base md:leading-6">
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
{t("home.stats.theoryToAction.projectsReceived.label")}
</div>
</div>
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-tertiary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
>
{t("home.stats.theoryToAction.projectsPublished.value")}
</div>
<div class="text-tertiary text-sm leading-5 md:text-base md:leading-6">
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
{t("home.stats.theoryToAction.projectsPublished.label")}
</div>
</div>
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-tertiary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
>
{t("home.stats.theoryToAction.successRate.value")}
</div>
<div class="text-tertiary text-sm leading-5 md:text-base md:leading-6">
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
{t("home.stats.theoryToAction.successRate.label")}
</div>
</div>
@@ -57,23 +60,23 @@ const { t } = Astro.locals;
<!-- Button -->
<a
href="https://stats.goteo.org"
class="border-tertiary text-tertiary hover:bg-tertiary flex w-fit items-center justify-center gap-2 self-center rounded-3xl border px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:self-start md:text-base"
class="border-secondary text-secondary hover:bg-secondary flex w-fit items-center justify-center gap-2 self-center rounded-3xl border px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:self-start md:text-base"
>
{t("home.stats.theoryToAction.button")}
</a>
</div>
<!-- Second Card: ¿Por qué Goteo? -->
<div class="bg-light-muted flex flex-col gap-6 rounded-3xl p-6 shadow-sm md:gap-10 md:p-10">
<div class="bg-grey flex flex-col gap-6 rounded-3xl p-6 shadow-sm md:gap-10 md:p-10">
<div class="flex flex-col gap-6">
<!-- Header -->
<div class="flex flex-col gap-2">
<h2
class="text-tertiary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
>
{t("home.stats.whyGoteo.title")}
</h2>
<p class="text-tertiary text-sm leading-5 md:text-base md:leading-6">
<p class="text-secondary text-sm leading-5 md:text-base md:leading-6">
{t("home.stats.whyGoteo.subtitle")}
</p>
</div>
@@ -82,31 +85,31 @@ const { t } = Astro.locals;
<div class="flex flex-col items-center justify-between gap-4 md:flex-row md:gap-0">
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-tertiary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
>
{t("home.stats.whyGoteo.community.value")}
</div>
<div class="text-tertiary text-sm leading-5 md:text-base md:leading-6">
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
{t("home.stats.whyGoteo.community.label")}
</div>
</div>
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-tertiary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
>
{t("home.stats.whyGoteo.averageDonors.value")}
</div>
<div class="text-tertiary text-sm leading-5 md:text-base md:leading-6">
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
{t("home.stats.whyGoteo.averageDonors.label")}
</div>
</div>
<div class="flex flex-col items-center text-center md:items-start md:text-left">
<div
class="text-tertiary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
class="text-secondary text-2xl leading-tight font-bold md:text-4xl md:leading-[48px]"
>
{t("home.stats.whyGoteo.totalRaised.value")}
</div>
<div class="text-tertiary text-sm leading-5 md:text-base md:leading-6">
<div class="text-secondary text-sm leading-5 md:text-base md:leading-6">
{t("home.stats.whyGoteo.totalRaised.label")}
</div>
</div>
@@ -116,7 +119,7 @@ const { t } = Astro.locals;
<!-- Button -->
<a
href="/about"
class="border-tertiary text-tertiary hover:bg-tertiary flex w-fit items-center justify-center gap-2 self-center rounded-3xl border px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:self-start md:text-base"
class="border-secondary text-secondary hover:bg-secondary flex w-fit items-center justify-center gap-2 self-center rounded-3xl border px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:self-start md:text-base"
>
{t("home.stats.whyGoteo.button")}
</a>
+6 -6
View File
@@ -6,26 +6,26 @@ const { t } = Astro.locals;
<div class="flex flex-col gap-0 overflow-hidden rounded-3xl bg-white shadow-sm lg:flex-row">
<!-- Content Section -->
<div
class="bg-light-surface flex flex-col justify-between gap-6 rounded-l-3xl border border-r-0 border-[#faf9ff] p-6 md:gap-8 md:py-6 md:pr-10 md:pl-6 lg:min-h-[484px] lg:w-[692px]"
class="bg-light-surface border-soft-purple flex flex-col justify-between gap-6 rounded-l-3xl border border-r-0 p-6 md:gap-8 md:py-6 md:pr-10 md:pl-6 lg:min-h-[484px] lg:w-[692px]"
>
<!-- Text Content -->
<div class="flex min-h-[348px] max-w-[529px] flex-col gap-4">
<!-- Main Headline -->
<h2
class="text-tertiary text-2xl leading-tight font-bold md:text-[40px] md:leading-[48px]"
class="text-secondary text-2xl leading-tight font-bold md:text-[40px] md:leading-[48px]"
>
{t("home.stories.title")}
</h2>
<!-- Subtitle -->
<h3
class="text-tertiary text-lg leading-8 font-bold md:text-[24px] md:leading-[32px]"
class="text-secondary text-lg leading-8 font-bold md:text-[24px] md:leading-[32px]"
>
{t("home.stories.subtitle")}
</h3>
<!-- Description -->
<div class="flex-1 text-sm leading-6 text-[#575757] md:text-base md:leading-6">
<div class="text-content flex-1 text-sm leading-6 md:text-base md:leading-6">
{
t("home.stories.description")
.split("\n")
@@ -41,7 +41,7 @@ const { t } = Astro.locals;
<!-- Call to Action Button -->
<a
href="/donate"
class="bg-purple-tint text-tertiary hover:bg-tertiary flex w-fit items-center justify-center gap-2 rounded-3xl px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:text-base"
class="bg-variant1 text-secondary hover:bg-secondary flex w-fit items-center justify-center gap-2 rounded-3xl px-6 py-4 text-sm leading-6 font-bold transition-all duration-200 hover:text-white md:text-base"
>
{t("home.stories.button")}
</a>
@@ -49,7 +49,7 @@ const { t } = Astro.locals;
<!-- Images Section -->
<div
class="bg-purple-tint relative min-h-[300px] flex-1 rounded-r-3xl lg:min-h-[484px] lg:w-[668px]"
class="bg-variant1 relative min-h-[300px] flex-1 rounded-r-3xl lg:min-h-[484px] lg:w-[668px]"
>
<!-- Main central image -->
<div
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { MouseEventHandler } from "svelte/elements";
import { twMerge, type ClassNameValue } from "tailwind-merge";
const sizeStyles = {
md: "px-8 py-4 rounded-[24px]",
sm: "px-4 py-2 rounded-[16px]",
};
const kindStyles = {
primary: "bg-primary",
secondary: "bg-variant1",
ghost: "inset-ring-1 inset-ring-secondary",
invert: "",
};
let {
children,
form = undefined,
type = "button",
disabled = false,
class: classes = "",
size = "md",
kind = "primary",
onclick,
"aria-label": ariaLabel = undefined,
"aria-busy": ariaBusy = undefined,
"aria-pressed": ariaPressed = undefined,
"aria-expanded": ariaExpanded = undefined,
}: {
children: Snippet;
form?: string;
type?: "button" | "submit";
disabled?: boolean;
size?: keyof typeof sizeStyles;
kind?: keyof typeof kindStyles;
class?: ClassNameValue;
onclick?: MouseEventHandler<HTMLButtonElement>;
"aria-label"?: string;
"aria-busy"?: boolean;
"aria-pressed"?: boolean;
"aria-expanded"?: boolean;
} = $props();
</script>
<button
{type}
{form}
{disabled}
{onclick}
class={twMerge(
"text-secondary disabled:bg-grey flex w-auto items-center justify-center gap-2 font-[700] transition hover:cursor-pointer",
sizeStyles[size],
kindStyles[kind],
classes,
)}
aria-label={ariaLabel}
aria-busy={ariaBusy}
aria-pressed={ariaPressed}
aria-expanded={ariaExpanded}
>
{@render children()}
</button>
@@ -0,0 +1,19 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
interface Props {
text: string;
class?: ClassNameValue;
}
let { text, class: classes = "" }: Props = $props();
</script>
<div
class={twMerge(
"rounded-full bg-white px-4 py-2 text-sm font-bold whitespace-nowrap text-black shadow-md",
classes,
)}
>
{text}
</div>
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { twMerge, type ClassNameValue } from "tailwind-merge";
const styles = {
default: "inset-ring-1 hover:inset-ring-0 inset-ring-secondary bg-light-surface",
active: "bg-secondary text-primary hover:text-secondary",
ghost: "",
};
let {
type = "default",
disabled = false,
class: classes = "",
onclick,
children,
}: {
type?: keyof typeof styles;
disabled?: boolean;
class?: ClassNameValue;
onclick?: (event: MouseEvent) => void;
children: Snippet;
} = $props();
</script>
<button
{disabled}
class={twMerge(
"hover:bg-variant1 w-auto rounded-[32px] px-[16px] py-[8px] font-[700]",
styles[type],
classes,
)}
onclick={(e) => onclick?.(e)}
>
{@render children()}
</button>
@@ -0,0 +1,66 @@
<script lang="ts">
import Category from "./Category.svelte";
export type Option = {
id: number | string;
text: string;
};
let {
options,
selected = $bindable([]),
selectedIds = $bindable([]),
max,
onchange,
}: {
options: Option[];
selected?: Option[];
selectedIds?: (number | string)[];
max?: number;
onchange?: (selected: Option[], option: Option) => void;
} = $props();
function isSelected(option: Option): boolean {
return selectedIds.includes(option.id);
}
function handleClick(option: Option): void {
if (isSelected(option)) {
selectedIds = selectedIds.filter((id) => id !== option.id);
} else {
selectedIds = selectedIds.concat(option.id);
}
selected = options.filter((option) => selectedIds.includes(option.id));
onchange?.(selected, option);
}
function calcTagType(option: Option) {
if (max === selectedIds.length) {
return isSelected(option) ? "active" : "ghost";
}
return isSelected(option) ? "active" : "default";
}
function calcTagDisabled(option: Option) {
if (max === selectedIds.length && !isSelected(option)) {
return true;
}
return false;
}
</script>
<div class="flex flex-wrap gap-[16px]">
{#each options as option}
<Category
type={calcTagType(option)}
disabled={calcTagDisabled(option)}
onclick={(e) => handleClick(option)}
>
{option.text}
</Category>
{/each}
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { twMerge, type ClassNameValue } from "tailwind-merge";
interface Props {
variant?: keyof typeof variantStyles;
class?: ClassNameValue;
children: Snippet;
}
let { variant = "default", class: classes = "", children }: Props = $props();
const variantStyles = {
default: "bg-white border border-black text-secondary",
success: "bg-variant2",
warning: "bg-variant4",
error: "bg-tertiary",
};
</script>
<div
class={twMerge(
"flex content-center items-center gap-2 rounded-[4px] px-2 py-1 whitespace-nowrap text-black",
variantStyles[variant],
classes,
)}
>
{@render children()}
</div>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import { twMerge, type ClassNameValue } from "tailwind-merge";
let {
value = $bindable(""),
id = undefined,
name = undefined,
placeholder = undefined,
type = "text",
required = false,
disabled = false,
class: classes = "",
labelText = undefined,
helperText = undefined,
}: {
value?: string;
id?: string;
name?: string;
placeholder?: string;
type?: "text" | "email" | "password" | "tel" | "url";
required?: boolean;
disabled?: boolean;
class?: ClassNameValue;
labelText?: string;
helperText?: string;
} = $props();
const finalId = id ? id : getIdForInput();
function getIdForInput(): string {
const cyrb53hash = cyrb53(type + name + placeholder + labelText + helperText);
return `input-${cyrb53hash}`;
}
function cyrb53(str: string, seed = 0) {
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
</script>
<div class={twMerge("relative", disabled && "opacity-40")}>
{#if labelText}
<label
for={finalId}
class="absolute -top-2 left-4 -translate-y-1/2 transform text-[10px] font-medium text-gray-500 transition-all"
>
{labelText}
</label>
{/if}
<input
bind:value
id={finalId}
{name}
{type}
{required}
{disabled}
{placeholder}
class={twMerge(
"peer bg-light-surface border-secondary focus:ring-tertiary w-full rounded-md border p-4 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none",
disabled && "cursor-not-allowed",
classes,
)}
/>
{#if helperText}
<span id={`helper-${finalId}`} class="ml-4 text-[12px]">
{helperText}
</span>
{/if}
</div>
@@ -0,0 +1,517 @@
<script lang="ts">
import { onMount } from "svelte";
import DonationsCard from "./DonationsCard.svelte";
import ProjectsCard from "./ProjectsCard.svelte";
import MatchfundingCard from "./MatchfundingCard.svelte";
import type { ActivityData, MatchfundingCardData } from "../../types/me-page";
import {
apiAccountingsIdGet,
apiProjectSupportsGetCollection,
apiProjectSupportsmoneyTotalGetCollection,
apiProjectsGetCollection,
apiProjectsIdOrSlugGet,
apiMatchCallsGetCollection,
} from "../../openapi/client/sdk.gen.ts";
import { apiAccountingsIdGetUrl, apiUsersIdGetUrl } from "../../openapi/client/paths.gen.ts";
import { createClient } from "@hey-api/client-fetch";
import { extractId } from "../../utils/extractId";
import { toCollectionItems } from "../../utils/hydra.ts";
import type { ProjectSupport, Project, MatchCall } from "../../openapi/client/types.gen.ts";
import { projectCache } from "../../stores/projectCache";
interface Props {
lang: string;
period?: string;
user: {
id: number;
accountingId: string;
isAdmin?: boolean;
};
}
let { lang, period = new Date().getFullYear().toString(), user }: Props = $props();
let activityData = $state<ActivityData | undefined>(undefined);
let matchfundingData = $state<MatchfundingCardData | undefined>(undefined);
let loading = $state(true);
let error = $state<string | null>(null);
// Create a client instance configured to use the API relay
// This ensures all authenticated requests go through the server-side proxy
const relayClient = createClient({
baseUrl: "/api/relay",
});
// Helper function to build URLs from templates by replacing path parameters
function buildUrl(template: string, params: Record<string, string | number>): string {
return Object.entries(params).reduce(
(url, [key, value]) => url.replace(`{${key}}`, String(value)),
template,
);
}
async function fetchActivityData() {
loading = true;
error = null;
try {
// Project details are cached in a session-level store (projectCache)
// This cache persists across component mounts and page navigations,
// reducing redundant API calls and improving performance.
// Projects are indexed by ID, slug, and original IRI segment.
// Only send Accept-Language header; auth is handled by the relay
const headers = {
"Accept-Language": lang,
};
// Fetch user's contributions (donations) - using accounting IRI as origin
const accountingIri = buildUrl(apiAccountingsIdGetUrl, { id: user.accountingId });
const { data: supportsResponse, error: supportsError } =
await apiProjectSupportsGetCollection({
client: relayClient,
query: {
origin: accountingIri,
itemsPerPage: 100,
},
headers,
});
// Try to fetch total money contributed, but it might fail with 500
// In that case, we'll calculate it manually from the contributions
let totalMoney = null;
let totalMoneyError = null;
try {
const response = await apiProjectSupportsmoneyTotalGetCollection({
client: relayClient,
query: {
origin: accountingIri,
},
headers,
});
totalMoney = response.data;
totalMoneyError = response.error;
} catch (err) {
console.warn("money_total endpoint failed, will calculate manually:", err);
totalMoneyError = err;
}
// Log detailed error information
if (totalMoneyError) {
console.warn("Total money endpoint returned error, calculating manually:", {
error: totalMoneyError,
accountingIri,
});
}
// Fetch user's owned projects - using user IRI as owner
const userIri = buildUrl(apiUsersIdGetUrl, { id: user.id });
const { data: projectsResponse, error: projectsError } = await apiProjectsGetCollection(
{
client: relayClient,
query: {
owner: userIri,
itemsPerPage: 3,
},
headers,
},
);
// Only fail if we can't get the critical data (contributions or projects)
if (supportsError) {
console.error("Failed to fetch contributions:", supportsError);
error = "Failed to load donation data";
return;
}
if (projectsError) {
console.error("Failed to fetch projects:", projectsError);
}
// Transform the data into the ActivityData format expected by ActivityCard
const contributions = toCollectionItems<ProjectSupport>(supportsResponse);
const projects = toCollectionItems<Project>(projectsResponse);
const contributionsCount = Array.isArray(supportsResponse)
? supportsResponse.length
: contributions.length;
const projectsCount = Array.isArray(projectsResponse)
? projectsResponse.length
: projects.length;
// Use session-level cache for project details (persists across component mounts)
// Add owned projects to the cache first
projectCache.addMany(projects);
const supportProjectIdOrSlugs = Array.from(
new Set(
contributions
.map((support) =>
extractId(
typeof support.project === "string" ? support.project : undefined,
),
)
.filter((idOrSlug): idOrSlug is string => Boolean(idOrSlug)),
),
);
// Check which projects are not in the cache and need to be fetched
const slugsToFetch = supportProjectIdOrSlugs.filter(
(idOrSlug) => !projectCache.has(idOrSlug),
);
if (slugsToFetch.length > 0) {
const projectDetailResults = await Promise.all(
slugsToFetch.map(async (idOrSlug) => {
try {
const { data, error } = await apiProjectsIdOrSlugGet({
client: relayClient,
path: { idOrSlug },
headers,
});
if (error) {
console.warn("Failed to fetch project detail for support", {
idOrSlug,
error,
});
return { idOrSlug, project: null };
}
return { idOrSlug, project: data ?? null };
} catch (fetchError) {
console.warn("Error fetching project detail for support", {
idOrSlug,
fetchError,
});
return { idOrSlug, project: null };
}
}),
);
// Add fetched projects to the cache using multiple keys for reliable lookups
projectDetailResults.forEach(({ idOrSlug, project: projectDetail }) => {
if (!projectDetail) return;
// Add to the persistent cache with all keys
projectCache.add(projectDetail, idOrSlug);
});
}
const accountingIds = Array.from(
new Set(
projects
.map((project) => extractId(project.accounting))
.filter((id): id is string => Boolean(id)),
),
);
let projectsTotalAmount = 0;
let projectsTotalCurrency: string | null = null;
if (accountingIds.length > 0) {
const accountingResults = await Promise.all(
accountingIds.map(async (accountingId) => {
try {
const { data, error } = await apiAccountingsIdGet({
client: relayClient,
path: { id: accountingId },
headers,
});
if (error) {
console.warn("Failed to fetch accounting", {
accountingId,
error,
});
return null;
}
return data ?? null;
} catch (accountingError) {
console.warn("Error fetching accounting", {
accountingId,
accountingError,
});
return null;
}
}),
);
accountingResults.forEach((accounting) => {
const balance = accounting?.balance;
if (!balance) {
return;
}
if (typeof balance.amount === "number") {
projectsTotalAmount += balance.amount;
}
if (!projectsTotalCurrency && balance.currency) {
projectsTotalCurrency = balance.currency;
}
});
}
const fallbackNow = new Date().toISOString();
const resolveSupportDate = (support: ProjectSupport): string => {
const createdAtValue = (support as { createdAt?: unknown }).createdAt;
if (typeof createdAtValue === "string") {
return createdAtValue;
}
return fallbackNow;
};
// Calculate total money (either from API or manually)
let totalAmount = totalMoney?.amount || 0;
let totalCurrency = totalMoney?.currency || "EUR";
if (!totalMoney && contributions.length > 0) {
// Calculate manually from contributions
totalAmount = contributions.reduce((sum, support) => {
return sum + (support.money?.amount || 0);
}, 0);
totalCurrency = contributions[0]?.money?.currency || "EUR";
}
// Map contributions to recent donations
const sortedContributions = [...contributions].sort((a, b) => {
const dateA = Date.parse(resolveSupportDate(a));
const dateB = Date.parse(resolveSupportDate(b));
return dateB - dateA;
});
const recentDonations = sortedContributions.slice(0, 3).map((support) => {
const idOrSlug = extractId(
typeof support.project === "string" ? support.project : undefined,
);
// Get project from the persistent cache
const project = idOrSlug ? projectCache.get(idOrSlug) : undefined;
// Validate that we have a proper slug, not just an ID
const hasValidSlug = project?.slug && isNaN(Number(project.slug));
// Ensure we always use the slug for URLs, never the ID
// If project is not found or slug is invalid, log an error
if (!project && idOrSlug) {
const cacheStats = projectCache.getStats();
console.error("Project not found in cache", {
idOrSlug,
supportId: support.id,
cacheSize: cacheStats.size,
sampleKeys: cacheStats.sampleKeys,
});
} else if (project && !hasValidSlug) {
console.error("Project found but has invalid slug (numeric or missing)", {
projectId: project.id,
projectSlug: project.slug,
supportId: support.id,
});
}
return {
id: support.id?.toString() || idOrSlug || "",
amount: support.money || { amount: 0, currency: "EUR" },
// Use project title if available, otherwise use a placeholder
projectTitle: project?.title || `Project ${idOrSlug || "Unknown"}`,
// CRITICAL: Only use slug if it's valid (not a number). Never fall back to ID.
// Empty string will prevent broken links - UI should handle gracefully
projectSlug: hasValidSlug ? project.slug : "",
date: resolveSupportDate(support),
};
});
// Map projects to recent projects
const recentProjects = projects.slice(0, 3).map((project) => {
// Validate that slug is not numeric (should never happen for owned projects, but be defensive)
const hasValidSlug = project.slug && isNaN(Number(project.slug));
if (!hasValidSlug) {
console.error("Owned project has invalid or missing slug", {
projectId: project.id,
projectSlug: project.slug,
projectTitle: project.title,
});
}
return {
id: project.id?.toString() || "",
title: project.title || "",
// Only use slug if it's valid (not numeric or empty)
slug: hasValidSlug ? project.slug : "",
status: (project.status as any) || "in_draft",
createdAt: project.dateCreated || new Date().toISOString(),
};
});
activityData = {
donations: {
count: contributionsCount,
total: {
amount: totalAmount,
currency: totalCurrency,
},
recentDonations,
},
projects: projectsError
? undefined
: {
count: projectsCount,
totalRaised: {
amount: projectsTotalAmount,
currency: projectsTotalCurrency ?? "EUR",
},
recentProjects,
},
};
// Fetch matchfunding data
try {
const { data: callsData, error: callsError } = await apiMatchCallsGetCollection({
client: relayClient,
query: {
itemsPerPage: 100,
},
headers,
});
if (!callsError && callsData) {
const calls = toCollectionItems<MatchCall>(callsData);
// Filter calls where user is a manager
const userCalls = calls.filter((call) => {
if (!call.managers || call.managers.length === 0) return false;
// managers is Array<string> containing IRI paths like "/v4/users/123"
return call.managers.some((managerIri) =>
managerIri.includes(String(user.id)),
);
});
if (userCalls.length > 0) {
// Fetch accounting data for each call to get donation amounts
const callAccountings = await Promise.all(
userCalls.map(async (call) => {
const accountingId = extractId(call.accounting);
if (!accountingId)
return { callId: call.id, amount: 0, currency: "EUR" };
try {
const { data: accounting } = await apiAccountingsIdGet({
client: relayClient,
path: { id: accountingId },
headers,
});
return {
callId: call.id,
amount: accounting?.balance?.amount || 0,
currency: accounting?.balance?.currency || "EUR",
};
} catch {
return { callId: call.id, amount: 0, currency: "EUR" };
}
}),
);
// Calculate total donated across all calls
const totalDonated = callAccountings.reduce(
(sum, acc) => sum + acc.amount,
0,
);
const currency = callAccountings[0]?.currency || "EUR";
// Get recent calls (up to 3)
const recentCalls = userCalls.slice(0, 3).map((call, index) => ({
id: call.id || 0,
title: call.title || "",
donationAmount: {
amount: callAccountings[index]?.amount || 0,
currency: callAccountings[index]?.currency || "EUR",
},
}));
matchfundingData = {
totalCalls: userCalls.length,
totalDonated: {
amount: totalDonated,
currency,
},
recentCalls,
};
}
}
} catch (matchfundingError) {
console.warn("Failed to fetch matchfunding data:", matchfundingError);
// Don't fail the whole page if matchfunding fails
matchfundingData = undefined;
}
} catch (err) {
console.error("Error fetching activity data:", err);
error = "Failed to load activity data";
} finally {
loading = false;
}
}
onMount(() => {
fetchActivityData();
});
// Re-fetch when period changes
$effect(() => {
if (period) {
fetchActivityData();
}
});
</script>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-{matchfundingData ? '3' : '2'}">
{#if loading}
<!-- Loading state -->
<div
class="bg-light-surface border-grey flex min-h-[384px] items-center justify-center rounded-[32px] border"
>
<p class="text-content">Loading...</p>
</div>
<div
class="bg-light-surface border-grey flex min-h-[384px] items-center justify-center rounded-[32px] border"
>
<p class="text-content">Loading...</p>
</div>
<div
class="bg-light-surface border-grey flex min-h-[384px] items-center justify-center rounded-[32px] border"
>
<p class="text-content">Loading...</p>
</div>
{:else if error}
<!-- Error state -->
<div
class="bg-light-surface border-grey flex min-h-[384px] items-center justify-center rounded-[32px] border"
>
<p class="text-tertiary font-semibold">{error}</p>
</div>
<div
class="bg-light-surface border-grey flex min-h-[384px] items-center justify-center rounded-[32px] border"
>
<p class="text-tertiary font-semibold">{error}</p>
</div>
<div
class="bg-light-surface border-grey flex min-h-[384px] items-center justify-center rounded-[32px] border"
>
<p class="text-tertiary font-semibold">{error}</p>
</div>
{:else}
<!-- Activity cards -->
<DonationsCard {lang} data={activityData} />
<ProjectsCard {lang} data={activityData} />
{#if matchfundingData}
<MatchfundingCard {lang} data={matchfundingData} />
{/if}
{/if}
</div>
@@ -0,0 +1,221 @@
<script lang="ts">
import { t } from "../../i18n/store";
import type { Snippet } from "svelte";
interface Props {
/**
* Card title translation key
*/
titleKey: string;
/**
* Left stat label translation key
*/
leftStatLabel: string;
/**
* Left stat value (formatted)
*/
leftStatValue: string | number;
/**
* Right stat label translation key
*/
rightStatLabel: string;
/**
* Right stat value (formatted)
*/
rightStatValue: string;
/**
* Recent items section title translation key
*/
recentTitleKey: string;
/**
* Path to decorative illustration
*/
illustrationPath: string;
/**
* Primary action button label translation key
*/
primaryActionLabel: string;
/**
* Primary action button href
*/
primaryActionHref: string;
/**
* Secondary action button label translation key
*/
secondaryActionLabel: string;
/**
* Secondary action button href
*/
secondaryActionHref: string;
/**
* Whether the card is in empty state
*/
isEmpty?: boolean;
/**
* Empty state message translation key
*/
emptyMessageKey?: string;
/**
* Empty state CTA label translation key
*/
emptyCtaLabel?: string;
/**
* Empty state CTA link
*/
emptyCtaLink?: string;
/**
* Optional children (slot content) for filled state
*/
children?: Snippet;
}
let {
titleKey,
leftStatLabel,
leftStatValue,
rightStatLabel,
rightStatValue,
recentTitleKey,
illustrationPath,
primaryActionLabel,
primaryActionHref,
secondaryActionLabel,
secondaryActionHref,
isEmpty = false,
emptyMessageKey,
emptyCtaLabel,
emptyCtaLink,
children,
}: Props = $props();
</script>
{#if isEmpty}
<!-- Empty State -->
<div
class="bg-light-surface border-grey relative flex min-h-[384px] flex-col items-center justify-between overflow-clip rounded-[32px] border p-6"
>
<!-- Decorative illustration - positioned exactly as in Figma -->
<div
class="absolute top-[calc(50%+48.5px)] left-[calc(50%-126px)] size-[389px] -translate-x-1/2 -translate-y-1/2 opacity-[0.04]"
style="color: #462949;"
aria-hidden="true"
>
<img
src={illustrationPath}
alt=""
class="size-full object-contain"
aria-hidden="true"
/>
</div>
<!-- Content - centered and takes up available space -->
<div class="relative z-10 flex grow flex-col items-center justify-center gap-1 text-center">
<h2 class="text-2xl font-bold text-black">
{$t(titleKey)}
</h2>
{#if emptyMessageKey}
<p class="text-content text-base">
{$t(emptyMessageKey)}
</p>
{/if}
</div>
<!-- Actions -->
{#if emptyCtaLabel && emptyCtaLink}
<div class="relative z-10 flex w-full gap-4">
<a
href={emptyCtaLink}
class="bg-variant1 text-secondary focus:ring-secondary flex grow items-center justify-center gap-2 rounded-3xl px-6 py-4 text-base font-bold no-underline transition-opacity hover:opacity-90 focus:ring-2 focus:outline-none"
>
{$t(emptyCtaLabel)}
</a>
</div>
{/if}
</div>
{:else}
<!-- Filled State -->
<div
class="bg-light-surface border-grey relative flex min-h-[384px] flex-col justify-between overflow-clip rounded-[32px] border p-6"
>
<div class="flex gap-4">
<div class="flex flex-1 flex-col">
<!-- Header with title -->
<div class="relative z-10 mb-4">
<h2 class="text-2xl font-bold text-black">
{$t(titleKey)}
</h2>
</div>
<!-- Stats section -->
<div class="relative z-10 mb-4 flex items-start gap-4">
<div class="flex flex-1 flex-col gap-1">
<p class="text-content text-xs font-medium">
{$t(leftStatLabel)}
</p>
<p class="text-2xl font-bold text-black">
{leftStatValue}
</p>
</div>
<div class="flex flex-1 flex-col gap-1">
<p class="text-content text-xs font-medium">
{$t(rightStatLabel)}
</p>
<p class="text-2xl font-bold text-black">
{rightStatValue}
</p>
</div>
</div>
</div>
<!-- Decorative illustration - top right corner -->
<div class="z-0 size-[88px]">
<img
src={illustrationPath}
alt=""
class="size-full object-contain"
aria-hidden="true"
/>
</div>
</div>
<!-- Recent items list -->
<div class="relative z-10 mb-4 flex min-h-[120px] flex-grow flex-col gap-3">
<h3 class="text-base font-bold text-black">
{$t(recentTitleKey)}
</h3>
<ul class="flex flex-col gap-2">
{@render children?.()}
</ul>
</div>
<!-- Actions -->
<div class="relative z-10 flex w-full flex-col gap-3 md:flex-row md:gap-4">
<a
href={primaryActionHref}
class="border-secondary text-secondary hover:bg-secondary/10 focus:ring-secondary flex grow items-center justify-center gap-2 overflow-hidden rounded-3xl border bg-transparent px-6 py-3 text-sm font-bold no-underline transition-colors focus:ring-2 focus:outline-none md:py-4 md:text-base"
>
<span class="truncate whitespace-nowrap">{$t(primaryActionLabel)}</span>
</a>
<a
href={secondaryActionHref}
class="bg-variant1 text-secondary focus:ring-secondary flex grow items-center justify-center gap-2 overflow-hidden rounded-3xl px-6 py-3 text-sm font-bold no-underline transition-opacity hover:opacity-90 focus:ring-2 focus:outline-none md:py-4 md:text-base"
>
<span class="truncate whitespace-nowrap">{$t(secondaryActionLabel)}</span>
</a>
</div>
</div>
{/if}
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
/**
* CTACard Component
*
* Displays a call-to-action card with title, description, and 1-2 action buttons.
* Supports dark (purple) and light (soft purple) variants.
*
* @example
* ```svelte
* <CTACard
* variant="dark"
* title="Descubre, conecta y dona"
* description="Imagina un lugar donde cientos de proyectos sociales esperan tu apoyo..."
* buttons={[
* {
* label: "Dona a un proyecto",
* href: "/projects",
* variant: "primary",
* },
* ]}
* />
* ```
*/
interface Button {
label: string;
href: string;
variant: "primary" | "secondary";
}
interface Props {
/**
* Visual variant (dark = purple bg, light = soft purple bg)
*/
variant: "dark" | "light";
/**
* Card title (max 2 lines with ellipsis)
*/
title: string;
/**
* Card description text
*/
description: string;
/**
* Action buttons (1-2 buttons supported)
*/
buttons: Button[];
}
let { variant, title, description, buttons }: Props = $props();
// Derived state
const isDark = $derived(variant === "dark");
</script>
<div
class="flex flex-col justify-between gap-10 rounded-[32px] border p-6 transition-shadow duration-200 {isDark
? 'border-grey bg-secondary text-light-surface'
: 'border-variant1 bg-soft-purple text-content'}"
>
<!-- Content -->
<div class="flex flex-col gap-4">
<h2
class="text-3xl leading-tight font-bold md:text-4xl"
style="display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;"
>
{title}
</h2>
<p class="text-sm leading-tight md:text-base md:leading-normal">
{description}
</p>
</div>
<!-- Actions -->
<div class="flex flex-col flex-wrap gap-4 md:flex-row">
{#each buttons as button}
<a
href={button.href}
class="inline-flex w-full items-center justify-center rounded-3xl px-6 py-4 text-base leading-normal font-bold no-underline transition-all duration-200 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none md:w-auto {button.variant ===
'primary'
? 'bg-light-accent text-secondary focus-visible:ring-secondary hover:opacity-90'
: isDark
? 'hover:text-secondary focus-visible:ring-offset-secondary border border-white bg-transparent text-white hover:bg-white focus-visible:ring-white'
: 'border-secondary text-secondary hover:bg-secondary focus-visible:ring-secondary border bg-transparent hover:text-white'}"
>
{button.label}
</a>
{/each}
</div>
</div>
@@ -0,0 +1,192 @@
<script lang="ts">
import { onMount } from "svelte";
import { t } from "../../i18n/store";
import CampaignCard from "../home/CampaignCard.svelte";
import Carousel from "../Carousel.svelte";
import {
apiGatewayChargesGetCollection,
apiAccountingsIdGet,
apiProjectsIdOrSlugGet,
} from "../../openapi/client/sdk.gen.ts";
import { extractId } from "../../utils/extractId";
import { toCollectionItems } from "../../utils/hydra.ts";
import type { Campaign } from "../../types/campaign";
import type {
Money,
GatewayCharge,
Accounting,
Project,
} from "../../openapi/client/types.gen.ts";
interface Props {
lang: string;
user: {
id: number;
token: string;
accountingId: string;
isAdmin?: boolean;
};
}
let { lang, user }: Props = $props();
let donatedCampaigns = $state<Campaign[]>([]);
let loading = $state(true);
async function fetchDonatedProjects() {
loading = true;
try {
const headers = {
"Accept-Language": lang,
Authorization: `Bearer ${user.token}`,
};
// Get user's gateway charges to find donated projects
const { data: charges, error: chargesError } = await apiGatewayChargesGetCollection({
query: {
itemsPerPage: 100,
},
headers,
});
if (chargesError) {
console.error("Failed to fetch gateway charges:", chargesError);
donatedCampaigns = [];
loading = false;
return;
}
const chargeItems = toCollectionItems<GatewayCharge>(charges);
if (chargeItems.length > 0) {
// Get unique project accounting IRIs from charges
const projectAccountingIRIs = [
...new Set(
chargeItems
.filter((charge) => charge.target && charge.status === "charged")
.map((charge) => charge.target)
.filter(Boolean),
),
] as string[];
// Calculate total donations per project
const projectDonations = new Map<string, number>();
chargeItems.forEach((charge) => {
if (charge.target && charge.status === "charged" && charge.money?.amount) {
const current = projectDonations.get(charge.target) || 0;
projectDonations.set(charge.target, current + charge.money.amount);
}
});
// Fetch project details for each unique project
const campaigns = (
await Promise.all(
projectAccountingIRIs.slice(0, 10).map(async (accountingIRI) => {
try {
// Fetch accounting to get project reference
const accountingId = extractId(accountingIRI);
if (!accountingId) return null;
const { data: accounting, error: accountingError } =
await apiAccountingsIdGet({
path: { id: accountingId },
headers,
});
if (accountingError || !accounting?.owner) {
console.error(
`Failed to fetch accounting ${accountingIRI}:`,
accountingError,
);
return null;
}
// Fetch project data
const projectId = extractId(accounting.owner);
if (!projectId) return null;
const { data: project, error: projectError } =
await apiProjectsIdOrSlugGet({
path: { idOrSlug: projectId },
headers,
});
if (projectError || !project) {
console.error(
`Failed to fetch project for accounting ${accountingIRI}:`,
projectError,
);
return null;
}
// Only show projects that are in campaign
if (project.status !== "in_campaign") return null;
// Calculate days remaining
let daysRemaining: number | undefined;
if (project.calendar?.minimum) {
const endDate = new Date(project.calendar.minimum);
const today = new Date();
const diffTime = endDate.getTime() - today.getTime();
daysRemaining = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
return {
id: project.slug!,
title: project.title!,
image: project.video?.thumbnail!,
minimum: project.budget?.minimum?.money!,
optimum: project.budget?.optimum?.money,
obtained: accounting.balance as Money,
status: project.status,
category: project.categories?.[0], // Get first category
daysRemaining,
userDonations: {
amount: projectDonations.get(accountingIRI) || 0,
currency: "EUR",
conversion: null,
} as Money,
} as Campaign;
} catch (error) {
console.error(
`Error fetching project for accounting ${accountingIRI}:`,
error,
);
return null;
}
}),
)
).filter(Boolean) as Campaign[];
donatedCampaigns = campaigns;
} else {
donatedCampaigns = [];
}
} catch (error) {
console.error("Error fetching donated projects:", error);
} finally {
loading = false;
}
}
onMount(() => {
fetchDonatedProjects();
});
</script>
{#if !loading && donatedCampaigns.length > 0}
<div class="flex flex-col gap-6">
<h2 class="text-3xl font-bold text-black md:text-4xl">
{$t("me.donatedProjects.title")}
</h2>
<Carousel itemsPerGroup={3} gap={24} showDots={false}>
{#each donatedCampaigns as campaign, index (campaign.id)}
<CampaignCard
size={index === 0 ? "large" : "small"}
{campaign}
showUserDonations={true}
/>
{/each}
</Carousel>
</div>
{/if}
@@ -0,0 +1,78 @@
<script lang="ts">
import BaseActivityCard from "./BaseActivityCard.svelte";
import { formatAmountWithSymbol } from "../../utils/currencies";
import type { ActivityData } from "../../types/me-page";
interface Props {
/**
* Current language locale
*/
lang: string;
/**
* Optional activity data for filled state
* If undefined/null, shows empty state
*/
data?: ActivityData;
}
let { lang, data }: Props = $props();
const fallbackProjectTitle = $derived(lang === "es" ? "Proyecto" : "Project");
// Determine if this card has data
const hasData = $derived(!!(data?.donations && data.donations.count > 0));
// Get donations data
const donationsData = $derived(data?.donations);
// Formatted total
const formattedTotal = $derived(
donationsData?.total
? formatAmountWithSymbol(donationsData.total.amount, donationsData.total.currency, lang)
: "",
);
</script>
<BaseActivityCard
titleKey="me.donations.title"
leftStatLabel="me.donations.count"
leftStatValue={donationsData?.count ?? 0}
rightStatLabel="me.donations.total"
rightStatValue={formattedTotal}
recentTitleKey="me.donations.recent"
illustrationPath="/images/profile/ilustration-donations.png"
primaryActionLabel="me.donations.viewAll"
primaryActionHref={lang === "es" ? "/me/donations" : `/${lang}/me/donations`}
secondaryActionLabel="me.donations.certificate"
secondaryActionHref="#"
isEmpty={!hasData}
emptyMessageKey="me.donations.empty"
emptyCtaLabel="me.donations.explore"
emptyCtaLink={lang === "es" ? "/discover" : `/${lang}/discover`}
>
{#if donationsData?.recentDonations}
{#each donationsData.recentDonations.slice(0, 2) as donation}
<li class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-black">
{formatAmountWithSymbol(donation.amount.amount, donation.amount.currency, lang)}
</span>
<span class="text-sm font-semibold text-black"> - </span>
{#if donation.projectSlug}
<a
href={lang === "es"
? `/project/${donation.projectSlug}`
: `/${lang}/project/${donation.projectSlug}`}
class="text-secondary text-sm no-underline hover:underline focus:underline focus:outline-none"
>
{donation.projectTitle || fallbackProjectTitle}
</a>
{:else}
<span class="text-tertiary text-sm italic">
{donation.projectTitle || fallbackProjectTitle}
</span>
{/if}
</li>
{/each}
{/if}
</BaseActivityCard>
@@ -0,0 +1,127 @@
<script lang="ts">
import { t } from "../../i18n/store";
import { formatAmountWithSymbol } from "../../utils/currencies";
import type { MatchfundingCall } from "../../types/me-page";
interface Props {
/**
* Current language locale
*/
lang: string;
/**
* Matchfunding call data
*/
call: MatchfundingCall;
}
let { lang, call }: Props = $props();
const formattedDonation = $derived(
call?.donationAmount
? formatAmountWithSymbol(call.donationAmount.amount, call.donationAmount.currency, lang)
: "",
);
const callUrl = $derived(
lang === "es" ? `/matchfunding/${call.slug}` : `/${lang}/matchfunding/${call.slug}`,
);
</script>
<!-- Hero-style Matchfunding Card -->
<div
class="relative box-border flex w-full flex-col items-start justify-center gap-2 overflow-hidden rounded-[32px] px-0 py-8 md:py-12 lg:py-16"
>
<!-- Background Image -->
{#if call.imageUrl}
<div aria-hidden="true" class="pointer-events-none absolute inset-0 rounded-[32px]">
<div class="absolute inset-0 overflow-hidden rounded-[32px]">
<img
alt=""
src={call.imageUrl}
class="absolute top-0 left-0 size-full object-cover"
/>
</div>
</div>
{/if}
<!-- Content Container -->
<div
class="relative z-10 box-border flex w-full flex-col items-center gap-6 px-6 py-0 md:flex-row md:gap-8 md:px-10 lg:px-16"
>
<!-- Left Content: Title, Stats, Button -->
<div
class="flex min-h-0 min-w-0 flex-1 flex-col items-start justify-end gap-6 md:gap-8 lg:gap-10"
>
<!-- Title and Stats Section -->
<div class="flex w-full flex-col gap-4">
<!-- Title -->
<div class="flex w-full flex-col gap-2">
<h3
class="min-w-full text-3xl leading-tight font-bold text-white md:text-4xl lg:text-5xl"
>
{call.title}
</h3>
</div>
<!-- Stats Row (Horizontal on desktop, vertical on mobile) -->
<div class="flex w-full flex-col items-start gap-4 md:h-16 md:flex-row md:gap-6">
<!-- Donation Amount -->
<div class="flex flex-col items-start text-white">
<p class="text-base leading-normal font-normal">
{$t("me.matchfunding.callCard.donationCall")}
</p>
<p class="text-2xl leading-snug font-bold md:text-3xl">
{formattedDonation}
</p>
</div>
<!-- Participating Projects -->
<div class="flex flex-col items-start text-white">
<p class="text-base leading-normal font-normal">
{$t("me.matchfunding.callCard.participatingProjects")}
</p>
<p class="text-2xl leading-snug font-bold md:text-3xl">
{call.participatingProjects}
{$t("me.matchfunding.callCard.projects")}
</p>
</div>
<!-- Successful Projects -->
<div class="flex flex-col items-start text-white">
<p class="text-base leading-normal font-normal">
{$t("me.matchfunding.callCard.successfulProjects")}
</p>
<p class="text-2xl leading-snug font-bold md:text-3xl">
{call.successfulProjects}
{$t("me.matchfunding.callCard.projects")}
</p>
</div>
</div>
</div>
<!-- Call to Action Button -->
<a
href={callUrl}
class="bg-variant1 text-secondary focus:ring-primary flex items-center justify-center gap-2 rounded-3xl px-6 py-4 text-base leading-normal font-bold no-underline transition-all duration-200 hover:opacity-90 focus:ring-2 focus:outline-none"
>
{$t("me.matchfunding.callCard.viewCall")}
</a>
</div>
<!-- Right: Logo Card -->
{#if call.logo}
<div
class="border-grey flex size-48 shrink-0 flex-col items-center justify-center gap-2 rounded-[32px] border border-solid bg-white p-8 md:size-56 lg:size-64"
>
<div class="relative aspect-[250/124] w-full shrink-0">
<img
alt={call.title}
src={call.logo}
class="pointer-events-none absolute inset-0 size-full max-w-none object-contain object-center"
/>
</div>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import BaseActivityCard from "./BaseActivityCard.svelte";
import { formatAmountWithSymbol } from "../../utils/currencies";
import type { MatchfundingCardData } from "../../types/me-page";
interface Props {
/**
* Current language locale
*/
lang: string;
/**
* Optional matchfunding data for filled state
* If undefined/null, card is not shown
*/
data?: MatchfundingCardData;
}
let { lang, data }: Props = $props();
const hasData = $derived(!!(data && data.totalCalls > 0));
const formattedTotal = $derived(
data?.totalDonated
? formatAmountWithSymbol(data.totalDonated.amount, data.totalDonated.currency, lang)
: "",
);
</script>
{#if hasData}
<BaseActivityCard
titleKey="me.matchfunding.card.title"
leftStatLabel="me.matchfunding.card.calls"
leftStatValue={data?.totalCalls ?? 0}
rightStatLabel="me.matchfunding.card.donated"
rightStatValue={formattedTotal}
recentTitleKey="me.matchfunding.card.recent"
illustrationPath="/images/profile/ilustration-matchfunding.png"
primaryActionLabel="me.matchfunding.card.viewAll"
primaryActionHref={lang === "es" ? "/me/matchfunding" : `/${lang}/me/matchfunding`}
secondaryActionLabel="me.matchfunding.card.create"
secondaryActionHref={lang === "es" ? "/matchfunding/new" : `/${lang}/matchfunding/new`}
isEmpty={false}
>
{#if data?.recentCalls}
{#each data.recentCalls.slice(0, 2) as call}
<li class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-black">
{formatAmountWithSymbol(
call.donationAmount.amount,
call.donationAmount.currency,
lang,
)}
</span>
<span class="text-sm font-semibold text-black"> - </span>
<span class="text-content text-sm">
{call.title}
</span>
</li>
{/each}
{/if}
</BaseActivityCard>
{/if}
@@ -0,0 +1,198 @@
<script lang="ts">
import { onMount } from "svelte";
import { t } from "../../i18n/store";
import { extractId } from "../../utils/extractId";
import { toCollectionItems } from "../../utils/hydra.ts";
import MatchfundingCallCard from "./MatchfundingCallCard.svelte";
import Carousel from "../Carousel.svelte";
import {
apiMatchCallsGetCollection,
apiAccountingsIdGet,
apiProjectSupportsGetCollection,
apiMatchCallSubmissionsGetCollection,
} from "../../openapi/client/sdk.gen.ts";
import { createClient } from "@hey-api/client-fetch";
import type { MatchCall, MatchCallSubmission } from "../../openapi/client/types.gen.ts";
import type { MatchfundingCall } from "../../types/me-page";
interface Props {
lang: string;
user: {
id: number;
accountingId: string;
isAdmin?: boolean;
};
}
let { lang, user }: Props = $props();
// Create a client instance configured to use the API relay
// This ensures all authenticated requests go through the server-side proxy
const relayClient = createClient({
baseUrl: "/api/relay",
});
let matchfundingCalls = $state<MatchfundingCall[]>([]);
let loading = $state(true);
let error = $state(false);
async function fetchMatchfundingCalls() {
loading = true;
error = false;
try {
const headers = {
"Accept-Language": lang,
};
// Fetch match calls for this user (filtered by manager ID)
const { data: callsData, error: callsError } = await apiMatchCallsGetCollection({
client: relayClient,
query: {
"managers.id": user.id,
itemsPerPage: 30,
} as any,
headers,
});
if (callsError) {
console.error("Failed to fetch match calls:", callsError);
error = true;
return;
}
const calls = toCollectionItems<MatchCall>(callsData);
if (calls.length > 0) {
// Transform calls to MatchfundingCall format
const transformedCalls = (
await Promise.all(
calls.map(async (call) => {
try {
if (!call.accounting) return null;
// Extract accounting ID from IRI
const accountingId = extractId(call.accounting);
if (!accountingId) return null;
// Fetch all data in parallel for better performance
const [
{ data: accounting, error: accountingError },
{ data: supportsData },
{ data: submissionsData },
] = await Promise.all([
// Fetch accounting data for donation amount
apiAccountingsIdGet({
client: relayClient,
path: { id: accountingId },
headers,
}),
// Fetch participating projects via ProjectSupport (filtered by call's accounting ID)
apiProjectSupportsGetCollection({
client: relayClient,
query: {
"origin.id": accountingId,
itemsPerPage: 100,
} as any,
headers,
}),
// Fetch successful projects (accepted submissions)
apiMatchCallSubmissionsGetCollection({
client: relayClient,
query: {
"call.id": call.id,
itemsPerPage: 100,
} as any,
headers,
}),
]);
if (accountingError || !accounting) {
console.error(
`Failed to fetch accounting for call ${call.id}:`,
accountingError,
);
return null;
}
const participatingProjects =
toCollectionItems(supportsData).length || 0;
const submissions =
toCollectionItems<MatchCallSubmission>(submissionsData);
const successfulProjects = submissions.filter(
(s) => s.status === "accepted",
).length;
return {
id: call.id!,
title: call.title!,
description: call.description || "",
slug: (call as any).slug || `call-${call.id}`,
imageUrl: (call as any).backgroundImage || (call as any).logo,
logo: (call as any).logo,
donationAmount: accounting.balance!,
participatingProjects,
successfulProjects,
status: call.status || "active",
territory: call.territory?.toString() || undefined,
} as MatchfundingCall;
} catch (err) {
console.error(`Error transforming match call ${call.id}:`, err);
return null;
}
}),
)
).filter(Boolean) as MatchfundingCall[];
matchfundingCalls = transformedCalls;
} else {
matchfundingCalls = [];
}
} catch (err) {
console.error("Error fetching matchfunding calls:", err);
error = true;
} finally {
loading = false;
}
}
onMount(() => {
fetchMatchfundingCalls();
});
</script>
{#if loading}
<!-- Loading State -->
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-bold text-black md:text-3xl">
{$t("me.matchfunding.section.title")}
</h2>
<!-- Single hero card skeleton matching actual dimensions -->
<div class="bg-grey h-64 w-full animate-pulse rounded-[32px] md:h-80 lg:h-96"></div>
</div>
{:else if error}
<!-- Error State -->
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-bold text-black md:text-3xl">
{$t("me.matchfunding.section.title")}
</h2>
<p class="text-content text-base leading-normal">
{$t("me.matchfunding.section.error")}
</p>
</div>
{:else if matchfundingCalls.length > 0}
<!-- Filled State -->
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-bold text-black md:text-3xl">
{$t("me.matchfunding.section.title")}
</h2>
<Carousel itemsPerGroup={1} gap={24} showDots={false}>
{#each matchfundingCalls as call (call.id)}
<MatchfundingCallCard {lang} {call} />
{/each}
</Carousel>
</div>
{/if}
<!-- Empty state: don't show anything if no calls -->
@@ -0,0 +1,144 @@
<script lang="ts">
import { onMount } from "svelte";
import Carousel from "../Carousel.svelte";
import CampaignCard from "../home/CampaignCard.svelte";
import { apiProjectsGetCollection, apiAccountingsIdGet } from "../../openapi/client/sdk.gen.ts";
import { t } from "../../i18n/store";
import { extractId } from "../../utils/extractId";
import { toCollectionItems } from "../../utils/hydra.ts";
import type { Money, Project, Accounting } from "../../openapi/client/types.gen.ts";
import type { Campaign } from "../../types/campaign";
interface Props {
lang: string;
user: {
id: number;
token: string;
accountingId: string;
isAdmin?: boolean;
};
}
let { lang, user }: Props = $props();
let ownedProjects = $state<Campaign[]>([]);
let loading = $state(true);
async function fetchOwnedProjects() {
loading = true;
try {
const headers = {
"Accept-Language": lang,
Authorization: `Bearer ${user.token}`,
};
// Get user's owned projects that are currently in campaign
const userIri = `/v4/users/${user.id}`;
const { data: projects, error: projectsError } = await apiProjectsGetCollection({
query: {
owner: userIri,
status: "in_campaign",
itemsPerPage: 10,
},
headers,
});
if (projectsError) {
console.error("Failed to fetch owned projects:", projectsError);
return;
}
const projectItems = toCollectionItems<Project>(projects);
if (projectItems.length > 0) {
// Transform projects to Campaign format
const campaigns = (
await Promise.all(
projectItems.map(async (project) => {
try {
if (!project.accounting) return null;
// Fetch accounting data to get balance
const accountingId = extractId(project.accounting);
if (!accountingId) return null;
const { data: accounting, error: accountingError } =
await apiAccountingsIdGet({
path: { id: accountingId },
headers,
});
if (accountingError || !accounting) {
console.error(
`Failed to fetch accounting for project ${project.slug}:`,
accountingError,
);
return null;
}
// Calculate days remaining
let daysRemaining: number | undefined;
if (project.calendar?.minimum) {
const endDate = new Date(project.calendar.minimum);
const today = new Date();
const diffTime = endDate.getTime() - today.getTime();
daysRemaining = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
return {
id: project.slug!,
title: project.title!,
image: project.video?.thumbnail!,
minimum: project.budget?.minimum?.money!,
optimum: project.budget?.optimum?.money,
obtained: accounting.balance as Money,
status: project.status,
category: project.categories?.[0], // Get first category
daysRemaining,
} as Campaign;
} catch (error) {
console.error(
`Error fetching accounting for project ${project.slug}:`,
error,
);
return null;
}
}),
)
).filter(Boolean) as Campaign[];
ownedProjects = campaigns;
} else {
ownedProjects = [];
}
} catch (error) {
console.error("Error fetching owned projects:", error);
} finally {
loading = false;
}
}
onMount(() => {
fetchOwnedProjects();
});
</script>
{#if !loading && ownedProjects.length > 0}
<div class="flex flex-col gap-6">
<h2 class="text-3xl font-bold text-black md:text-4xl">
{$t("me.ownedProjects.title")}
</h2>
<Carousel itemsPerGroup={3} gap={24} showDots={false}>
{#each ownedProjects as campaign, index (campaign.id)}
<CampaignCard
size={index === 0 ? "large" : "small"}
{campaign}
showOwnerActions={true}
/>
{/each}
</Carousel>
</div>
{/if}
@@ -0,0 +1,56 @@
<script lang="ts">
import { t } from "../../i18n/store";
import ChevronDown from "../../svgs/ChevronDown.svelte";
interface Props {
/**
* Currently selected period (year as string)
*/
selectedPeriod: string;
/**
* Callback when period changes
*/
onChange?: (period: string) => void;
}
let { selectedPeriod = new Date().getFullYear().toString(), onChange }: Props = $props();
// Generate year options (current year - 10 years)
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 10 }, (_, i) => currentYear - i);
function handleChange(event: Event) {
const select = event.target as HTMLSelectElement;
onChange?.(select.value);
}
</script>
<div class="relative h-14 w-52">
<select
id="period-select"
class="border-secondary focus:ring-secondary h-full w-full cursor-pointer rounded-lg border bg-white pr-12 pl-4 text-base leading-normal text-black focus:border-transparent focus:ring-2 focus:outline-none"
style="appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: none;"
value={selectedPeriod}
onchange={handleChange}
aria-label={$t("me.period.label")}
>
{#each years as year}
<option value={year.toString()}>
{$t("me.period.year", { year })}
</option>
{/each}
</select>
<label
for="period-select"
class="pointer-events-none absolute -top-[5px] left-3 bg-white px-1 text-xs leading-none font-medium text-black"
>
{$t("me.period.label")}
</label>
<!-- Dropdown icon -->
<div class="pointer-events-none absolute top-1/2 right-4 -translate-y-1/2">
<ChevronDown />
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More