Compare commits
4 Commits
main
...
feature/ad
Author | SHA1 | Date | |
---|---|---|---|
a13669901f | |||
46690f9241 | |||
8adc44f6c1 | |||
cbccb35ee8 |
31
Build.PL
31
Build.PL
@ -9,16 +9,27 @@ my $build = Module::Build->new(
|
|||||||
dist_author => 'Sergio Iglesias <contact@owlcode.tech>',
|
dist_author => 'Sergio Iglesias <contact@owlcode.tech>',
|
||||||
dist_abstract => 'Redland Official user management.',
|
dist_abstract => 'Redland Official user management.',
|
||||||
requires => {
|
requires => {
|
||||||
'Mojolicious' => 0,
|
'Mojolicious' => 0,
|
||||||
'DBI' => 0,
|
'DBI' => 0,
|
||||||
'Path::Tiny' => 0,
|
'Path::Tiny' => 0,
|
||||||
'DBD::Pg' => 0,
|
'DBD::Pg' => 0,
|
||||||
'Const::Fast' => 0,
|
'Const::Fast' => 0,
|
||||||
'DateTime::Format::ISO8601' => 0,
|
'DateTime::Format::ISO8601' => 0,
|
||||||
'DateTime::Format::Mail' => 0,
|
'DateTime::Format::Mail' => 0,
|
||||||
'Crypt::Bcrypt' => 0,
|
'Crypt::Bcrypt' => 0,
|
||||||
'SVG' => 0,
|
'SVG' => 0,
|
||||||
'JSON' => 0,
|
'JSON' => 0,
|
||||||
|
'Moo' => 0,
|
||||||
|
'Types::Standard' => 0,
|
||||||
|
'Params::ValidationCompiler' => 0,
|
||||||
|
'Crypt::URandom' => 0,
|
||||||
|
'DateTime::Format::Pg' => 0,
|
||||||
|
'Email::MIME' => 0,
|
||||||
|
'Email::Sender::Simple' => 0,
|
||||||
|
'Email::Sender::Transport::SMTP' => 0,
|
||||||
|
'List::AllUtils' => 0,
|
||||||
|
'Capture::Tiny' => 0,
|
||||||
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
$build->create_build_script;
|
$build->create_build_script;
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<p>Para jugarlo desde Play Station necesitarás la aplicación Bedrock Together.</p>
|
<p>Para jugarlo desde Play Station necesitarás la aplicación Bedrock Together.</p>
|
||||||
|
|
||||||
<p>Ahora mismo estamos en <b>Beta Abierta</b>, el servidor cuando termine dicha beta costará una pequeña cantidad por persona para sufragar los gastos del servidor. (Nombre de dominio, alojamiento, desarrollo, etc.)</p>
|
<p>Ahora mismo estamos en <b>Beta Abierta</b>, el servidor cuando termine dicha beta costará una pequeña cantidad por persona para sufragar los gastos del servidor. (Nombre de dominio, alojamiento, desarrollo, promoción, etc.)</p>
|
||||||
</description>
|
</description>
|
||||||
<priority>0</priority>
|
<priority>0</priority>
|
||||||
<menu_text><img alt="Redland Official logo" class="index-image-menu" src="/img/redland-logo.webp"/></menu_text>
|
<menu_text><img alt="Redland Official logo" class="index-image-menu" src="/img/redland-logo.webp"/></menu_text>
|
||||||
|
135
js/functions.js
Normal file
135
js/functions.js
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"use strict";
|
||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import ReactCrop, {
|
||||||
|
centerCrop,
|
||||||
|
makeAspectCrop,
|
||||||
|
PixelCrop,
|
||||||
|
} from "react-image-crop";
|
||||||
|
import "react-image-crop/src/ReactCrop.scss";
|
||||||
|
|
||||||
|
export async function blobImgCrop(image, crop) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvasPreview(image, canvas, crop);
|
||||||
|
|
||||||
|
const blob = await toBlob(canvas);
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
console.error("Failed to create blob");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBlob(canvas) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
canvas.toBlob(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function canvasPreview(image, canvas, crop) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("No 2d context");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaleX = image.naturalWidth / image.width;
|
||||||
|
const scaleY = image.naturalHeight / image.height;
|
||||||
|
// devicePixelRatio slightly increases sharpness on retina devices
|
||||||
|
// at the expense of slightly slower render times and needing to
|
||||||
|
// size the image back down if you want to download/upload and be
|
||||||
|
// true to the images natural size.
|
||||||
|
const pixelRatio = window.devicePixelRatio;
|
||||||
|
// const pixelRatio = 1
|
||||||
|
|
||||||
|
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
|
||||||
|
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
|
||||||
|
|
||||||
|
ctx.scale(pixelRatio, pixelRatio);
|
||||||
|
ctx.imageSmoothingQuality = "high";
|
||||||
|
|
||||||
|
const cropX = crop.x * scaleX;
|
||||||
|
const cropY = crop.y * scaleY;
|
||||||
|
|
||||||
|
const centerX = image.naturalWidth / 2;
|
||||||
|
const centerY = image.naturalHeight / 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
// 5) Move the crop origin to the canvas origin (0,0)
|
||||||
|
ctx.translate(-cropX, -cropY);
|
||||||
|
// 4) Move the origin to the center of the original position
|
||||||
|
ctx.translate(centerX, centerY);
|
||||||
|
// 1) Move the center of the image to the origin (0,0)
|
||||||
|
ctx.translate(-centerX, -centerY);
|
||||||
|
ctx.drawImage(
|
||||||
|
image,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
image.naturalWidth,
|
||||||
|
image.naturalHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
image.naturalWidth,
|
||||||
|
image.naturalHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarCropper(props) {
|
||||||
|
const [crop, setCrop] = React.useState({
|
||||||
|
unit: "%",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
});
|
||||||
|
if (props["onStart"]) {
|
||||||
|
props["onStart"](crop);
|
||||||
|
}
|
||||||
|
return React.createElement(
|
||||||
|
ReactCrop,
|
||||||
|
{
|
||||||
|
aspect: 1,
|
||||||
|
crop: crop,
|
||||||
|
onChange: (c) => {
|
||||||
|
setCrop(c);
|
||||||
|
if (props["onChange"]) props["onChange"](c);
|
||||||
|
},
|
||||||
|
minWidth: 50,
|
||||||
|
minHeight: 50,
|
||||||
|
onComplete: (local_crop, pcrop) => {
|
||||||
|
if (props["onComplete"]) {
|
||||||
|
props["onComplete"](local_crop);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
React.createElement("img", {
|
||||||
|
src: props.src,
|
||||||
|
onLoad: (e) => {
|
||||||
|
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
|
||||||
|
|
||||||
|
const crop_local = centerCrop(
|
||||||
|
makeAspectCrop(
|
||||||
|
{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
);
|
||||||
|
|
||||||
|
setCrop(crop_local);
|
||||||
|
console.log(props["onLoad"]);
|
||||||
|
if (props["onLoad"]) {
|
||||||
|
props["onLoad"](crop_local);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
90
js/index.js
Normal file
90
js/index.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import * as ReactDOM from "react-dom/client";
|
||||||
|
import * as f from "/js/functions";
|
||||||
|
|
||||||
|
const avatar_cropper = document.querySelector("#avatar-cropper");
|
||||||
|
const avatar_selector = document.querySelector("#avatar-selector");
|
||||||
|
const button_select_avatar = document.querySelector("#select-avatar");
|
||||||
|
const root = ReactDOM.createRoot(avatar_cropper);
|
||||||
|
|
||||||
|
if (!window.a) {
|
||||||
|
window.a = require("/js/index");
|
||||||
|
}
|
||||||
|
|
||||||
|
export let crop;
|
||||||
|
export let img_to_crop;
|
||||||
|
|
||||||
|
let avatar_selector_on_file_lambda = () => {
|
||||||
|
if (avatar_selector.files && avatar_selector.files.length > 0) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener("load", () => {
|
||||||
|
const image = new Image();
|
||||||
|
// We use the event listener load to check if it is a valid image.
|
||||||
|
// This check has to be repeated in the server of course.
|
||||||
|
image.addEventListener("load", () => {
|
||||||
|
avatar_cropper.style.display = "block";
|
||||||
|
const local_image_to_crop = document.createElement("img");
|
||||||
|
local_image_to_crop.src = reader.result?.toString();
|
||||||
|
img_to_crop = local_image_to_crop;
|
||||||
|
if (img_to_crop.width < 50 || img_to_crop.height < 50) {
|
||||||
|
alert('El avatar es demasiado pequeño, busca un imagen de al menos 50x50 pixeles.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
root.render(
|
||||||
|
React.createElement(f.AvatarCropper, {
|
||||||
|
src: reader.result?.toString() || "",
|
||||||
|
onStart: (c) => {
|
||||||
|
crop = c;
|
||||||
|
button_select_avatar.removeAttribute("disabled");
|
||||||
|
},
|
||||||
|
onChange: (c) => {
|
||||||
|
crop = c;
|
||||||
|
button_select_avatar.removeAttribute("disabled");
|
||||||
|
},
|
||||||
|
onLoad: (c) => {
|
||||||
|
crop = c;
|
||||||
|
button_select_avatar.removeAttribute("disabled");
|
||||||
|
},
|
||||||
|
onComplete: (c) => {
|
||||||
|
crop = c;
|
||||||
|
button_select_avatar.removeAttribute("disabled");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
image.addEventListener("error", () => {
|
||||||
|
alert("No parece que esto sea una imagen.");
|
||||||
|
});
|
||||||
|
image.src = reader.result?.toString();
|
||||||
|
});
|
||||||
|
reader.readAsDataURL(avatar_selector.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
avatar_selector_on_file_lambda();
|
||||||
|
avatar_selector.addEventListener("change", avatar_selector_on_file_lambda);
|
||||||
|
|
||||||
|
button_select_avatar.addEventListener("click", () => {
|
||||||
|
if (img_to_crop.src == null || crop == null) {
|
||||||
|
console.log("Still not ready to transmit");
|
||||||
|
}
|
||||||
|
const form_data = new FormData();
|
||||||
|
f.blobImgCrop(img_to_crop, crop).then((result) => {
|
||||||
|
form_data.append('file', result);
|
||||||
|
fetch('/usuario/actualizar-avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form_data,
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
window.location = '/perfil';
|
||||||
|
} else {
|
||||||
|
response.json().then((object) => {
|
||||||
|
alert(object.description);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
@ -1,5 +1,8 @@
|
|||||||
package MyRedland;
|
package MyRedland;
|
||||||
|
|
||||||
|
use MyRedland::Lusers;
|
||||||
|
use MyRedland::Stripe;
|
||||||
|
use MyRedland::SubscriptionOrders;
|
||||||
use MyRedland::Controller::Metrics;
|
use MyRedland::Controller::Metrics;
|
||||||
|
|
||||||
use Mojo::Base 'Mojolicious', -signatures;
|
use Mojo::Base 'Mojolicious', -signatures;
|
||||||
@ -21,6 +24,49 @@ sub startup ($self) {
|
|||||||
$self->config(
|
$self->config(
|
||||||
hypnotoad => { proxy => 1, listen => ['http://localhost:3000'] } );
|
hypnotoad => { proxy => 1, listen => ['http://localhost:3000'] } );
|
||||||
$self->secrets($self->config->{secrets});
|
$self->secrets($self->config->{secrets});
|
||||||
|
$self->sessions->default_expiration(0);
|
||||||
|
my $luser_dao = MyRedland::Lusers->new(app => $self);
|
||||||
|
$self->helper( current_user => sub {
|
||||||
|
my $c = shift;
|
||||||
|
my $user_uuid = $c->session->{user_uuid};
|
||||||
|
if (!defined $user_uuid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $user = $luser_dao->find_by_uuid(uuid => $user_uuid);
|
||||||
|
if (!defined $user) {
|
||||||
|
delete $c->session->{user_uuid};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
});
|
||||||
|
|
||||||
|
my $subscription_order_dao = MyRedland::SubscriptionOrders->new(app => $self);
|
||||||
|
$self->helper( current_order => sub {
|
||||||
|
my $c = shift;
|
||||||
|
my $maybe_new_order = shift;
|
||||||
|
if (defined $maybe_new_order) {
|
||||||
|
if (!$maybe_new_order->isa('MyRedland::SubscriptionOrder')) {
|
||||||
|
die "Only MyRedland::SubscriptionOrder must be passed to current_order";
|
||||||
|
}
|
||||||
|
$c->session->{subscription_order} = $maybe_new_order->uuid;
|
||||||
|
}
|
||||||
|
my $order_uuid = $c->session->{subscription_order};
|
||||||
|
if (!defined $order_uuid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return $subscription_order_dao->find_by_uuid( uuid => $order_uuid );
|
||||||
|
});
|
||||||
|
|
||||||
|
my $stripe = MyRedland::Stripe->new( app => $self );
|
||||||
|
$self->helper( current_payment_intent => sub {
|
||||||
|
my $c = shift;
|
||||||
|
my $current_order = $c->current_order;
|
||||||
|
if (!defined $current_order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $payment_intent_id = $current_order->payment_intent_id;
|
||||||
|
return $stripe->retrieve_payment_intent( payment_intent_id => $payment_intent_id );
|
||||||
|
});
|
||||||
|
|
||||||
# Router
|
# Router
|
||||||
my $r = $self->routes;
|
my $r = $self->routes;
|
||||||
@ -29,6 +75,24 @@ sub startup ($self) {
|
|||||||
$r->get('/')->to('Page#index');
|
$r->get('/')->to('Page#index');
|
||||||
|
|
||||||
# $r->get('/:post')->to('Page#post');
|
# $r->get('/:post')->to('Page#post');
|
||||||
|
$r->get('/perfil')->to('User#profile');
|
||||||
|
$r->get('/perfil/pago-exitoso')->to('User#payment_success_get');
|
||||||
|
$r->get('/perfil/verifica-el-correo')->to('User#mail_verify');
|
||||||
|
$r->get('/perfil/configura-tu-avatar')->to('User#setup_avatar_get');
|
||||||
|
$r->get('/perfil/opciones-de-subscripcion')->to('User#subscription_options');
|
||||||
|
$r->get('/perfil/subscribirse')->to('User#subscribe_get');
|
||||||
|
$r->get('/usuario/:username/verificacion')->to('User#user_verification');
|
||||||
|
$r->get('/usuario/avatar')->to('User#get_avatar');
|
||||||
|
$r->post('/usuario/actualizar-avatar')->to('User#setup_avatar');
|
||||||
|
$r->post('/orden-de-subscripcion/renovacion-automatica-api')->to('SubscriptionOrder#renew_auto_api');
|
||||||
|
$r->post('/orden-de-subscripcion/guardar-tarjeta-api')->to('SubscriptionOrder#save_card_api');
|
||||||
|
$r->get('/orden-de-subscripcion/estado')->to('SubscriptionOrder#get_status');
|
||||||
|
$r->get('/logout')->to('User#logout_get');
|
||||||
|
$r->post('/logout')->to('User#logout');
|
||||||
|
$r->get('/login')->to('User#login_get');
|
||||||
|
$r->post('/login')->to('User#login');
|
||||||
|
$r->get('/registrate')->to('User#sign_up_get');
|
||||||
|
$r->post('/registrate')->to('User#sign_up');
|
||||||
$r->get('/stats')->to('Metrics#stats');
|
$r->get('/stats')->to('Metrics#stats');
|
||||||
$r->get('/<:category>.rss')->to('Page#category_rss');
|
$r->get('/<:category>.rss')->to('Page#category_rss');
|
||||||
$r->get('/:category')->to('Page#category');
|
$r->get('/:category')->to('Page#category');
|
||||||
|
138
lib/MyRedland/Controller/SubscriptionOrder.pm
Normal file
138
lib/MyRedland/Controller/SubscriptionOrder.pm
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
package MyRedland::Controller::SubscriptionOrder;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use Mojo::Base 'Mojolicious::Controller';
|
||||||
|
|
||||||
|
use MyRedland::Stripe;
|
||||||
|
use MyRedland::SubscriptionOrders;
|
||||||
|
|
||||||
|
sub save_card_api {
|
||||||
|
my $self = shift;
|
||||||
|
my $params = $self->req->json;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
my $so = $self->current_order;
|
||||||
|
my $stripe = MyRedland::Stripe->new( app => $self->app );
|
||||||
|
if ( !defined $user || !defined $so ) {
|
||||||
|
$self->render(
|
||||||
|
json => {
|
||||||
|
error =>
|
||||||
|
'No puedes hacer eso, para hacer esto debes tener un usuario loggeado y una orden de suscripción.'
|
||||||
|
},
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $so->user->uuid ne $user->uuid ) {
|
||||||
|
say STDERR 'Orden de subscripción no coincidente con usuario.';
|
||||||
|
$self->render(
|
||||||
|
json => { error => 'El servidor ha llegado a un estado erroneo.' },
|
||||||
|
status => 500
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !defined $params->{new_value} ) {
|
||||||
|
$self->render(
|
||||||
|
json => { error => 'Debes pasar new_value', },
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !$params->{new_value} && $so->renew_auto ) {
|
||||||
|
$self->render(
|
||||||
|
json => {
|
||||||
|
error =>
|
||||||
|
'No puedes deshabilitar el guardado de tarjeta sin deshabilitar la renovación automática'
|
||||||
|
},
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $new_value = !!$params->{new_value};
|
||||||
|
my $subscription_order_dao =
|
||||||
|
MyRedland::SubscriptionOrders->new( app => $self->app );
|
||||||
|
$so->save_card( $new_value ? 1 : 0 );
|
||||||
|
$so = $subscription_order_dao->update(
|
||||||
|
subscription_order => $so,
|
||||||
|
fields => [qw/save_card/]
|
||||||
|
);
|
||||||
|
$self->render( json => { text => 'Success' } );
|
||||||
|
}
|
||||||
|
|
||||||
|
sub renew_auto_api {
|
||||||
|
my $self = shift;
|
||||||
|
my $params = $self->req->json;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
my $so = $self->current_order;
|
||||||
|
my $stripe = MyRedland::Stripe->new( app => $self->app );
|
||||||
|
if ( !defined $user || !defined $so ) {
|
||||||
|
$self->render(
|
||||||
|
json => {
|
||||||
|
error =>
|
||||||
|
'No puedes hacer eso, para hacer esto debes tener un usuario loggeado y una orden de suscripción.'
|
||||||
|
},
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $so->user->uuid ne $user->uuid ) {
|
||||||
|
say STDERR 'Orden de subscripción no coincidente con usuario.';
|
||||||
|
$self->render(
|
||||||
|
json => { error => 'El servidor ha llegado a un estado erroneo.' },
|
||||||
|
status => 500
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !defined $params->{new_value} ) {
|
||||||
|
$self->render(
|
||||||
|
json => { error => 'Debes pasar new_value', },
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $subscription_order_dao =
|
||||||
|
MyRedland::SubscriptionOrders->new( app => $self->app );
|
||||||
|
my $new_value = $params->{new_value};
|
||||||
|
$so->save_card(1);
|
||||||
|
$so->renew_auto( $new_value ? 1 : 0 );
|
||||||
|
$so = $subscription_order_dao->update(
|
||||||
|
subscription_order => $so,
|
||||||
|
fields => [qw/renew_auto save_card/]
|
||||||
|
);
|
||||||
|
|
||||||
|
$self->render( json => { text => 'Success' } );
|
||||||
|
}
|
||||||
|
|
||||||
|
sub get_status {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
my $so = $self->current_order;
|
||||||
|
if ( !defined $user || !defined $so ) {
|
||||||
|
$self->render(
|
||||||
|
json => {
|
||||||
|
error =>
|
||||||
|
'No puedes hacer eso, para hacer esto debes tener un usuario loggeado y una orden de suscripción.'
|
||||||
|
},
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $so->user->uuid ne $user->uuid ) {
|
||||||
|
say STDERR 'Orden de subscripción no coincidente con usuario.';
|
||||||
|
$self->render(
|
||||||
|
json => { error => 'El servidor ha llegado a un estado erroneo.' },
|
||||||
|
status => 500
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$self->render(
|
||||||
|
json => {
|
||||||
|
save_card => $so->save_card,
|
||||||
|
renew_auto => $so->renew_auto
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
1;
|
608
lib/MyRedland/Controller/User.pm
Normal file
608
lib/MyRedland/Controller/User.pm
Normal file
@ -0,0 +1,608 @@
|
|||||||
|
package MyRedland::Controller::User;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use Mojo::Base 'Mojolicious::Controller';
|
||||||
|
|
||||||
|
use Digest::SHA qw/sha512_hex/;
|
||||||
|
use Data::Dumper;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
|
||||||
|
use Crypt::URandom qw/urandom/;
|
||||||
|
use Crypt::Bcrypt qw/bcrypt/;
|
||||||
|
use Capture::Tiny qw/capture/;
|
||||||
|
|
||||||
|
use MyRedland::Categories;
|
||||||
|
use MyRedland::Lusers;
|
||||||
|
use Mojo::Template;
|
||||||
|
use Mojo::URL;
|
||||||
|
use Mojo::Util qw/url_escape/;
|
||||||
|
|
||||||
|
use MyRedland::Mail;
|
||||||
|
use MyRedland::Products;
|
||||||
|
use MyRedland::Stripe;
|
||||||
|
use Path::Tiny;
|
||||||
|
|
||||||
|
my $PROJECT_ROOT = path(__FILE__)->parent->parent->parent->parent;
|
||||||
|
my $UPLOADS = $PROJECT_ROOT->child('uploads');
|
||||||
|
$UPLOADS->mkpath;
|
||||||
|
my $mt = Mojo::Template->new( auto_escape => 1 );
|
||||||
|
|
||||||
|
sub login_get {
|
||||||
|
my $self = shift;
|
||||||
|
if ( my $user = $self->current_user ) {
|
||||||
|
$self->_already_logged_in;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $categories = MyRedland::Categories->new->Retrieve;
|
||||||
|
$self->stash( categories => $categories );
|
||||||
|
$self->stash( current_slug => $categories->{index}{slug} );
|
||||||
|
$self->render;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub setup_avatar {
|
||||||
|
my $self = shift;
|
||||||
|
my $upload = $self->req->upload('file');
|
||||||
|
my $user = $self->current_user;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
$self->render(
|
||||||
|
status => 401,
|
||||||
|
json => {
|
||||||
|
status => 401,
|
||||||
|
code => 'NOTLOGGED',
|
||||||
|
description => 'No estás loggeado.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !$user->verified ) {
|
||||||
|
$self->render(
|
||||||
|
status => 401,
|
||||||
|
json => {
|
||||||
|
status => 401,
|
||||||
|
code => 'NOTVALIDYET',
|
||||||
|
description =>
|
||||||
|
'Tu usuario no está validado, comprueba tu correo electrónico.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !defined $upload ) {
|
||||||
|
$self->render(
|
||||||
|
status => 400,
|
||||||
|
json => {
|
||||||
|
status => 400,
|
||||||
|
code => 'NOFILE',
|
||||||
|
description =>
|
||||||
|
'No se recibió ningún fichero. (¿Usaste un multipart/form-data?)'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $tempdir = Path::Tiny->tempdir;
|
||||||
|
my $file = $tempdir->child('tempfile');
|
||||||
|
$upload->move_to($file);
|
||||||
|
system 'file', $file;
|
||||||
|
my ( $stdout, $stderr, $error ) = capture {
|
||||||
|
system qw/identify -format "%wx%h"/, $file;
|
||||||
|
};
|
||||||
|
if ( $error != 0 ) {
|
||||||
|
$self->render(
|
||||||
|
status => 400,
|
||||||
|
json => {
|
||||||
|
status => 400,
|
||||||
|
code => 'INVFILE',
|
||||||
|
description =>
|
||||||
|
'Archivo inválido. (El comando identify no lo reconoció)'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my ( $width, $height ) = $stdout =~ /^"(\d+)x(\d+)"$/;
|
||||||
|
if ( $width < 50 ) {
|
||||||
|
$self->render(
|
||||||
|
status => 400,
|
||||||
|
json => {
|
||||||
|
status => 400,
|
||||||
|
code => 'SMALLFILE',
|
||||||
|
description =>
|
||||||
|
'El archivo debería tener 50x50 píxeles como mínimo.'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $width != $height ) {
|
||||||
|
$self->render(
|
||||||
|
status => 400,
|
||||||
|
json => {
|
||||||
|
status => 400,
|
||||||
|
code => 'NOTASQUARE',
|
||||||
|
description =>
|
||||||
|
'La imagen debe ser cuadrada, debe tener el mismo numero de pixeles de ancho que de alto.'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $converted_file = $tempdir->child('converted.png');
|
||||||
|
( $stdout, $stderr, $error ) = capture {
|
||||||
|
system 'convert', $file, $converted_file;
|
||||||
|
};
|
||||||
|
if ( $error != 0 ) {
|
||||||
|
say STDERR $stdout;
|
||||||
|
say STDERR $stderr;
|
||||||
|
$self->render(
|
||||||
|
status => 500,
|
||||||
|
json => {
|
||||||
|
status => 500,
|
||||||
|
code => 'SERVERCONVERSIONFAILED',
|
||||||
|
description =>
|
||||||
|
'El servidor no fué capaz de convertir el fichero a png, prueba a enviar otro formato.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $sha512 = sha512_hex( $converted_file->slurp );
|
||||||
|
$user->avatar($sha512);
|
||||||
|
my $users_dao = MyRedland::Lusers->new( app => $self->app );
|
||||||
|
$user = $users_dao->update( user => $user, fields => [qw/avatar/] );
|
||||||
|
system 'cp', $converted_file, $UPLOADS->child($sha512);
|
||||||
|
|
||||||
|
$self->render(
|
||||||
|
status => 200,
|
||||||
|
json => {
|
||||||
|
status => 200,
|
||||||
|
code => 'SUCESS',
|
||||||
|
description => 'Your avatar was correctly setup.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub get_avatar {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
$self->render( status => 401, text => 'Still not logged in.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !$user->avatar ) {
|
||||||
|
$self->render( status => 400, text => 'Avatar still not setup.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$self->render(
|
||||||
|
format => 'png',
|
||||||
|
data => $UPLOADS->child( $user->avatar )->slurp
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
sub setup_avatar_get {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
$self->_must_be_logged;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !$user->verified ) {
|
||||||
|
$self->_must_be_verified;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$self->render;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _must_be_verified {
|
||||||
|
my $self = shift;
|
||||||
|
$self->render(
|
||||||
|
status => 400,
|
||||||
|
text => 'Debes verificar tu correo antes de hacer eso'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _already_logged_in {
|
||||||
|
my $self = shift;
|
||||||
|
$self->render( text => 'Ya estás loggeado.', status => 403 );
|
||||||
|
}
|
||||||
|
|
||||||
|
sub login {
|
||||||
|
my $self = shift;
|
||||||
|
if ( my $user = $self->current_user ) {
|
||||||
|
$self->_already_logged_in;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $username = $self->param('username');
|
||||||
|
my $password = $self->param('password');
|
||||||
|
my $users_dao = MyRedland::Lusers->new( app => $self->app );
|
||||||
|
my $user = $users_dao->find_by_username( username => $username );
|
||||||
|
my $ip = $self->tx->remote_address;
|
||||||
|
if ( !$user ) {
|
||||||
|
say "No such user for login $username from $ip.";
|
||||||
|
$self->_invalid_login;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !$user->check_password($password) ) {
|
||||||
|
say "Invalid login attempt for @{[$user->username]} from $ip.";
|
||||||
|
$self->_invalid_login;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$self->_create_session_for_user($user);
|
||||||
|
$self->res->headers->location('/perfil');
|
||||||
|
$self->render(
|
||||||
|
status => 302,
|
||||||
|
text => 'Exito logueandose, redirigiendo...'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _invalid_login {
|
||||||
|
my $self = shift;
|
||||||
|
$self->render( text => 'Login invalido.', status => 401 );
|
||||||
|
}
|
||||||
|
|
||||||
|
sub sign_up_get {
|
||||||
|
my $self = shift;
|
||||||
|
if ( my $user = $self->current_user ) {
|
||||||
|
$self->_already_logged_in;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $categories = MyRedland::Categories->new->Retrieve;
|
||||||
|
$self->stash( categories => $categories );
|
||||||
|
$self->stash( current_slug => $categories->{index}{slug} );
|
||||||
|
$self->render;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub sign_up {
|
||||||
|
my $self = shift;
|
||||||
|
if ( my $user = $self->current_user ) {
|
||||||
|
$self->_already_logged_in;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $username = $self->param('username');
|
||||||
|
my $password = $self->param('password');
|
||||||
|
my $repeat_password = $self->param('repeat_password');
|
||||||
|
my $email = $self->param('email');
|
||||||
|
if ( !defined $username || length($username) < 5 ) {
|
||||||
|
$self->render(
|
||||||
|
text => 'El nombre de usuario debe ser mayor a 4 caracteres.',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $username =~ /\// ) {
|
||||||
|
$self->render(
|
||||||
|
text => '"/" es un carácter prohibido en el nombre de usuario',
|
||||||
|
status => 400,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !defined $password
|
||||||
|
|| !defined $repeat_password
|
||||||
|
|| length $password < 10 )
|
||||||
|
{
|
||||||
|
$self->render(
|
||||||
|
text => 'La contraseña debe contener al menos 10 caracteres.',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $password =~ /^\d*$/ ) {
|
||||||
|
$self->render(
|
||||||
|
text =>
|
||||||
|
'La contraseña no puede estar compuesta solo por números debido a que es extremadamente inseguro.',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !defined $email || $email !~ /@.*\.\w+$/ ) {
|
||||||
|
$self->render(
|
||||||
|
text =>
|
||||||
|
'El email que has enviado no parece valido, contacta con contact@owlcode.tech desde dicho email si estás seguro que lo es.',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $password ne $repeat_password ) {
|
||||||
|
$self->render(
|
||||||
|
text => 'La contraseña no coincide con la repetición.',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $password_hash = bcrypt( $password, '2b', 12, urandom(16) );
|
||||||
|
|
||||||
|
my $users_dao = MyRedland::Lusers->new( app => $self->app );
|
||||||
|
my $user;
|
||||||
|
eval {
|
||||||
|
$user = $users_dao->create(
|
||||||
|
username => $username,
|
||||||
|
password => $password_hash,
|
||||||
|
email => $email
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if ($@) {
|
||||||
|
say STDERR $@;
|
||||||
|
if ( $@ =~ /duplicate.*username/ ) {
|
||||||
|
$self->render(
|
||||||
|
text =>
|
||||||
|
'El nombre de usuario ya existe, prueba a recuperar contraseña.',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $@ =~ /duplicate.*email/ ) {
|
||||||
|
$self->render(
|
||||||
|
text => 'El email ya existe, prueba a recuperar contraseña.',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$self->render(
|
||||||
|
text =>
|
||||||
|
'Error desconocido de base de datos contacta con contact@owlcode.tech.',
|
||||||
|
status => 500
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$self->_create_session_for_user($user);
|
||||||
|
say "Created user @{[$user->username]} with email @{[$user->email]}.";
|
||||||
|
if ( $user->verified ) {
|
||||||
|
$self->res->headers->location('/perfil/configura-tu-avatar');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
my $mailer = MyRedland::Mail->new( app => $self->app );
|
||||||
|
my $current_url = $self->req->url->to_abs;
|
||||||
|
$current_url->path('');
|
||||||
|
$current_url->query('');
|
||||||
|
my $url = Mojo::URL->new(
|
||||||
|
"${current_url}usuario/@{[url_escape($user->username)]}/verificacion"
|
||||||
|
);
|
||||||
|
$url->query( a => $user->mail_verification_payload );
|
||||||
|
my $html_email = $mt->render( <<'EOF', $url );
|
||||||
|
% my ($url) = @_;
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<p>Hemos recibido una solicitud de registro en Redland Official con tu dirección de correo, si no has sido tú por favor ignora este email.</p>
|
||||||
|
|
||||||
|
<p>El enlace de verificación es <a href="<%= $url %>"><%= $url %></a>.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
EOF
|
||||||
|
my $text_email = <<"EOF";
|
||||||
|
Hemos recibido una solicitud de registro en Redland Official con tu dirección de correo, si no has sido tú por favor ignora este email.
|
||||||
|
|
||||||
|
El enlace de verificación es $url.
|
||||||
|
EOF
|
||||||
|
$mailer->sendmail(
|
||||||
|
to => $user->email,
|
||||||
|
subject => 'Verifica tu email.',
|
||||||
|
html => $html_email,
|
||||||
|
text => $text_email,
|
||||||
|
);
|
||||||
|
$self->res->headers->location('/perfil/verifica-el-correo');
|
||||||
|
}
|
||||||
|
$self->render( text => 'Login success.', status => 302 );
|
||||||
|
}
|
||||||
|
|
||||||
|
sub user_verification {
|
||||||
|
my $self = shift;
|
||||||
|
my $username_to_verify = $self->param('username');
|
||||||
|
my $verification_payload = $self->param('a');
|
||||||
|
my $user = $self->current_user;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
my $url = Mojo::URL->new('/login');
|
||||||
|
my $current_url = $self->req->url;
|
||||||
|
$url->query( redirect => $current_url );
|
||||||
|
$self->res->headers->location($url);
|
||||||
|
$self->render( text => 'Debes loggearte antes.', status => 302 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $user->username ne $username_to_verify ) {
|
||||||
|
$self->render(
|
||||||
|
text =>
|
||||||
|
'Has tratado de verificar una cuenta estando loggeado con otra, este incidente será reportado para futuras acciones de moderación.',
|
||||||
|
status => 403
|
||||||
|
);
|
||||||
|
say STDERR
|
||||||
|
"@{[$user->username]} attempted to verify the account $username_to_verify, possible hacking or multiaccount.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $verification_payload ne $user->mail_verification_payload ) {
|
||||||
|
$self->render(
|
||||||
|
text => 'La url de verificación no es correcta, prueba de nuevo.',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $current_date = DateTime->now;
|
||||||
|
if ( $current_date > $user->mail_verification_expiration ) {
|
||||||
|
$self->render(
|
||||||
|
text => 'Este enlace ya expiró, genera otro desde tu perfil',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$user->verified(1);
|
||||||
|
my $users_dao = MyRedland::Lusers->new( app => $self->app );
|
||||||
|
$user = $users_dao->update( user => $user, fields => [qw/verified/] );
|
||||||
|
say "@{[$user->username]} was verified.";
|
||||||
|
$self->res->headers->location('/perfil/configura-tu-avatar');
|
||||||
|
$self->render( text => 'Usuario verificado con exito.', status => 302 );
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _create_session_for_user {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = shift;
|
||||||
|
my $session = $self->session;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
die "User is not defined.";
|
||||||
|
}
|
||||||
|
$session->{user_uuid} = $user->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _must_be_logged {
|
||||||
|
my $self = shift;
|
||||||
|
$self->render(
|
||||||
|
text => 'Debes estar loggeado para acceder esta parte.',
|
||||||
|
status => 401
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub logout_get {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
$self->_must_be_logged;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $categories = MyRedland::Categories->new->Retrieve;
|
||||||
|
$self->stash( categories => $categories );
|
||||||
|
$self->stash( current_slug => $categories->{index}{slug} );
|
||||||
|
$self->render;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub profile {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
$self->_must_be_logged;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $categories = MyRedland::Categories->new->Retrieve;
|
||||||
|
$self->stash( categories => $categories );
|
||||||
|
$self->stash( current_slug => $categories->{index}{slug} );
|
||||||
|
$self->render;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub logout {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
$self->_must_be_logged;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( defined $self->param('yes') ) {
|
||||||
|
for my $key ( keys %{ $self->session } ) {
|
||||||
|
|
||||||
|
# We do not want to store any session data after logout because of the security implications.
|
||||||
|
# We use session root keys for things such as storing the current SubscriptionOrder.
|
||||||
|
delete $self->session->{$key};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$self->res->headers->location('/');
|
||||||
|
$self->render( text => 'Action succeded', status => 302 );
|
||||||
|
}
|
||||||
|
|
||||||
|
sub subscription_options {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
$self->_must_be_logged;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !$user->verified ) {
|
||||||
|
$self->_must_be_verified;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$self->render;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub subscribe_get {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
if ( !defined $user ) {
|
||||||
|
$self->_must_be_logged;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( !$user->verified ) {
|
||||||
|
$self->_must_be_verified;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $product = $self->param('product');
|
||||||
|
if ( !defined $product ) {
|
||||||
|
$self->render( text => 'Debes indicar un producto.', status => 400 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $products_dao = MyRedland::Products->new;
|
||||||
|
$product = $products_dao->find_by_id( id => $product );
|
||||||
|
if ( !defined $product ) {
|
||||||
|
$self->render( text => 'No se encontró ese producto.', status => 404 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $stripe = MyRedland::Stripe->new( app => $self->app );
|
||||||
|
if ( !defined $user->stripe_customer_id ) {
|
||||||
|
$user = $stripe->create_customer_for_user($user);
|
||||||
|
}
|
||||||
|
my $payment_intent = $self->current_payment_intent;
|
||||||
|
if ( $self->_check_new_so_conditions_subscription_get($product) ) {
|
||||||
|
say STDERR "Creating new SubscriptionOrder for @{[$user->username]}.";
|
||||||
|
my $subscription_orders_dao =
|
||||||
|
MyRedland::SubscriptionOrders->new( app => $self->app );
|
||||||
|
my $pi = $stripe->create_payment_intent(
|
||||||
|
user => $user,
|
||||||
|
price => $product->price,
|
||||||
|
off_session => 0
|
||||||
|
);
|
||||||
|
my $payment_intent_id = $pi->{id};
|
||||||
|
my $client_secret = $pi->{client_secret};
|
||||||
|
my $subscription_order = $subscription_orders_dao->create(
|
||||||
|
product => $product,
|
||||||
|
user => $user,
|
||||||
|
payment_intent_id => $payment_intent_id,
|
||||||
|
client_secret => $client_secret,
|
||||||
|
renew_auto => 1,
|
||||||
|
save_card => 1,
|
||||||
|
paid => 0,
|
||||||
|
to_pay => $product->price
|
||||||
|
);
|
||||||
|
$self->current_order($subscription_order);
|
||||||
|
}
|
||||||
|
my $nonce = unpack 'H*', urandom(15);
|
||||||
|
$self->res->headers->content_security_policy(
|
||||||
|
"default-src 'self' 'nonce-$nonce'; connect-src 'self' https://api.stripe.com https://maps.googleapis.com; frame-src https://js.stripe.com https://hooks.stripe.com; script-src 'self' 'nonce-$nonce' https://js.stripe.com https://maps.googleapis.com"
|
||||||
|
);
|
||||||
|
$self->render(
|
||||||
|
product => $product,
|
||||||
|
subscription_order => $self->current_order,
|
||||||
|
nonce => $nonce,
|
||||||
|
config => $self->app->config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _check_new_so_conditions_subscription_get {
|
||||||
|
my $self = shift;
|
||||||
|
my $product = shift;
|
||||||
|
my $payment_intent = $self->current_payment_intent;
|
||||||
|
my $user = $self->current_user;
|
||||||
|
return
|
||||||
|
!defined $self->current_order
|
||||||
|
|| $product->id ne $self->current_order->product->id
|
||||||
|
|| $user->uuid ne $self->current_order->user->uuid
|
||||||
|
|| $payment_intent->{status} eq 'succeeded';
|
||||||
|
}
|
||||||
|
|
||||||
|
sub payment_success_get {
|
||||||
|
my $self = shift;
|
||||||
|
my $current_order = $self->current_order;
|
||||||
|
if ( !defined $current_order ) {
|
||||||
|
$self->render(
|
||||||
|
text =>
|
||||||
|
'No hay ninguna orden guardada en tu navegador, esto no quiere decir que tu orden no exista, contacta con contact@owlcode.tech en caso de duda.',
|
||||||
|
status => 400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
my $payment_intent = $self->current_payment_intent;
|
||||||
|
if (!defined $payment_intent) {
|
||||||
|
$self->render(
|
||||||
|
text => 'No hay orden de pago, esto es un error del servidor.',
|
||||||
|
status => 500,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$self->render( so => $current_order, pi => $payment_intent );
|
||||||
|
}
|
||||||
|
1;
|
@ -25,6 +25,46 @@ sub MIGRATIONS {
|
|||||||
path TEXT,
|
path TEXT,
|
||||||
FOREIGN KEY (path) REFERENCES paths(path)
|
FOREIGN KEY (path) REFERENCES paths(path)
|
||||||
)',
|
)',
|
||||||
|
'ALTER TABLE requests ADD PRIMARY KEY (uuid)',
|
||||||
|
'CREATE TABLE lusers (
|
||||||
|
uuid UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
verified BOOLEAN DEFAULT false,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
mail_verification_payload TEXT NOT NULL
|
||||||
|
)',
|
||||||
|
'ALTER TABLE lusers ADD COLUMN avatar TEXT DEFAULT \'\'',
|
||||||
|
'ALTER TABLE lusers ADD COLUMN mail_verification_expiration TIMESTAMP DEFAULT NOW() + interval \'1 day\'',
|
||||||
|
'ALTER TABLE lusers ADD COLUMN creation_date TIMESTAMP DEFAULT NOW()',
|
||||||
|
'ALTER TABLE lusers ADD COLUMN last_access TIMESTAMP DEFAULT NOW()',
|
||||||
|
'ALTER TABLE lusers ADD COLUMN stripe_customer_id TEXT DEFAULT NULL',
|
||||||
|
'CREATE TABLE subscription_orders (
|
||||||
|
uuid UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
product_id TEXT NOT NULL,
|
||||||
|
user_uuid UUID NOT NULL,
|
||||||
|
payment_intent_id TEXT NOT NULL,
|
||||||
|
client_secret TEXT NOT NULL,
|
||||||
|
renew_auto BOOLEAN NOT NULL,
|
||||||
|
paid BOOLEAN DEFAULT false,
|
||||||
|
create_date timestamp DEFAULT NOW(),
|
||||||
|
to_pay INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_uuid) REFERENCES lusers(uuid)
|
||||||
|
)',
|
||||||
|
'CREATE TABLE public_servers (
|
||||||
|
uuid UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
identifier TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL
|
||||||
|
)',
|
||||||
|
'INSERT INTO public_servers (name, identifier) VALUES (\'Principal Server\', \'server1\')',
|
||||||
|
'CREATE TABLE subscriptions (
|
||||||
|
public_server_uuid UUID NOT NULL,
|
||||||
|
user_uuid UUID NOT NULL,
|
||||||
|
valid_until timestamp NOT NULL,
|
||||||
|
renew_auto BOOLEAN NOT NULL,
|
||||||
|
PRIMARY KEY (public_server_uuid, user_uuid)
|
||||||
|
)',
|
||||||
|
'ALTER TABLE subscription_orders ADD COLUMN save_card BOOLEAN DEFAULT true',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
1;
|
1;
|
||||||
|
88
lib/MyRedland/Luser.pm
Normal file
88
lib/MyRedland/Luser.pm
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package MyRedland::Luser;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use Moo;
|
||||||
|
use Types::Standard qw/Maybe Str Bool InstanceOf/;
|
||||||
|
|
||||||
|
use Crypt::Bcrypt qw/bcrypt bcrypt_check/;
|
||||||
|
|
||||||
|
has uuid => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Str,
|
||||||
|
required => 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
has username => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Str,
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
has email => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => Str,
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
has verified => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => Bool,
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
has password => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => Str,
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
has mail_verification_payload => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => Str,
|
||||||
|
required => 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
has mail_verification_expiration => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => InstanceOf['DateTime'],
|
||||||
|
required => 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
has creation_date => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => InstanceOf['DateTime']
|
||||||
|
);
|
||||||
|
|
||||||
|
has last_access => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => InstanceOf['DateTime'],
|
||||||
|
);
|
||||||
|
|
||||||
|
has avatar => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => Str,
|
||||||
|
);
|
||||||
|
|
||||||
|
has stripe_customer_id => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => Maybe[Str],
|
||||||
|
);
|
||||||
|
|
||||||
|
sub check_password {
|
||||||
|
my $self = shift;
|
||||||
|
my $password = shift;
|
||||||
|
if (!defined $password) {
|
||||||
|
warn "Password not defined";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!length $password) {
|
||||||
|
# Not accepting empty passwords.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return bcrypt_check($password, $self->password);
|
||||||
|
}
|
||||||
|
1;
|
181
lib/MyRedland/Lusers.pm
Normal file
181
lib/MyRedland/Lusers.pm
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
package MyRedland::Lusers;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use DateTime::Format::Pg;
|
||||||
|
use Moo;
|
||||||
|
use Crypt::URandom;
|
||||||
|
use Types::Standard qw/InstanceOf Str ArrayRef/;
|
||||||
|
use Params::ValidationCompiler qw/validation_for/;
|
||||||
|
|
||||||
|
use MyRedland::DB;
|
||||||
|
use MyRedland::Luser;
|
||||||
|
use List::AllUtils qw/any/;
|
||||||
|
|
||||||
|
my $fpg = DateTime::Format::Pg->new;
|
||||||
|
|
||||||
|
my @FIELDS = qw/uuid username email verified
|
||||||
|
password mail_verification_payload avatar
|
||||||
|
mail_verification_expiration creation_date
|
||||||
|
last_access stripe_customer_id/;
|
||||||
|
|
||||||
|
has app => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => InstanceOf ['Mojolicious'],
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
has dbh => ( is => 'lazy', );
|
||||||
|
|
||||||
|
sub _build_dbh {
|
||||||
|
my $self = shift;
|
||||||
|
return MyRedland::DB->connect( $self->app );
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
username => { type => Str },
|
||||||
|
email => { type => Str },
|
||||||
|
password => { type => Str },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub create {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $dbh = $self->dbh;
|
||||||
|
my $username = $params{username};
|
||||||
|
my $email = $params{email};
|
||||||
|
my $password = $params{password};
|
||||||
|
my $test_mode = $self->app->config->{test_mode};
|
||||||
|
|
||||||
|
my $verified = 0;
|
||||||
|
|
||||||
|
if ($test_mode) {
|
||||||
|
$verified = 1;
|
||||||
|
}
|
||||||
|
my $mail_verification_payload = unpack 'H*',
|
||||||
|
Crypt::URandom::urandom(30);
|
||||||
|
my $avatar = '';
|
||||||
|
my $mail_verification_expiration =
|
||||||
|
$fpg->format_datetime( DateTime->now->add( days => 1 ) );
|
||||||
|
|
||||||
|
my $user_hash = $dbh->selectrow_hashref(
|
||||||
|
<<"EOF", undef, $username, $email, $verified, $password, $mail_verification_payload, $avatar, $mail_verification_expiration );
|
||||||
|
INSERT INTO lusers
|
||||||
|
(username, email, verified, password,
|
||||||
|
mail_verification_payload, avatar,
|
||||||
|
mail_verification_expiration)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING @{[join ', ', @FIELDS]};
|
||||||
|
EOF
|
||||||
|
$self->_convert_user_hash_dates($user_hash);
|
||||||
|
my $user = MyRedland::Luser->new(%$user_hash);
|
||||||
|
return $user;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
user => { type => InstanceOf ['MyRedland::Luser'] },
|
||||||
|
fields => { type => ArrayRef [Str] },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub update {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $user = $params{user};
|
||||||
|
my $fields = $params{fields};
|
||||||
|
my %updates;
|
||||||
|
for my $field (@$fields) {
|
||||||
|
if ( any { $field eq $_ } qw/verified email password mail_verification_expiration mail_verification_payload last_access avatar stripe_customer_id/) {
|
||||||
|
$updates{$field} = $user->$field;
|
||||||
|
next;
|
||||||
|
}
|
||||||
|
die "No such field $field.";
|
||||||
|
}
|
||||||
|
my $query = <<"EOF";
|
||||||
|
UPDATE lusers
|
||||||
|
SET @{[
|
||||||
|
join ', ', map { "$_ = ?" } @$fields
|
||||||
|
]} WHERE uuid = ?
|
||||||
|
RETURNING @{[join ', ', @FIELDS]};
|
||||||
|
EOF
|
||||||
|
my $dbh = $self->dbh;
|
||||||
|
my $user_hash = $dbh->selectrow_hashref($query, undef, @updates{@$fields}, $user->uuid);
|
||||||
|
$self->_convert_user_hash_dates($user_hash);
|
||||||
|
$user = MyRedland::Luser->new(%$user_hash);
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _convert_user_hash_dates {
|
||||||
|
my $self = shift;
|
||||||
|
my $user_hash = shift;
|
||||||
|
$user_hash->{mail_verification_expiration} =
|
||||||
|
$fpg->parse_datetime( $user_hash->{mail_verification_expiration} );
|
||||||
|
$user_hash->{creation_date} =
|
||||||
|
$fpg->parse_datetime( $user_hash->{creation_date} );
|
||||||
|
$user_hash->{last_access} =
|
||||||
|
$fpg->parse_datetime( $user_hash->{last_access} );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
uuid => { type => Str },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub find_by_uuid {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $uuid = $params{uuid};
|
||||||
|
my $dbh = $self->dbh;
|
||||||
|
my $user_hash = $dbh->selectrow_hashref( <<"EOF", undef, $uuid );
|
||||||
|
SELECT @{[join ', ', @FIELDS]} FROM lusers where uuid = ?;
|
||||||
|
EOF
|
||||||
|
if ( !defined $user_hash ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$self->_convert_user_hash_dates($user_hash);
|
||||||
|
my $user = MyRedland::Luser->new(%$user_hash);
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
username => { type => Str },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub find_by_username {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $username = $params{username};
|
||||||
|
my $dbh = $self->dbh;
|
||||||
|
my $user_hash = $dbh->selectrow_hashref( <<"EOF", undef, $username );
|
||||||
|
SELECT @{[join ', ', @FIELDS]} FROM lusers where username = ?;
|
||||||
|
EOF
|
||||||
|
if ( !defined $user_hash ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$self->_convert_user_hash_dates($user_hash);
|
||||||
|
my $user = MyRedland::Luser->new(%$user_hash);
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
1;
|
117
lib/MyRedland/Mail.pm
Normal file
117
lib/MyRedland/Mail.pm
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package MyRedland::Mail;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use Mojolicious;
|
||||||
|
|
||||||
|
use Encode qw/decode/;
|
||||||
|
|
||||||
|
use Moo;
|
||||||
|
use Types::Standard qw/InstanceOf Str/;
|
||||||
|
use Params::ValidationCompiler qw/validation_for/;
|
||||||
|
|
||||||
|
use Email::MIME;
|
||||||
|
use Email::Sender::Simple;
|
||||||
|
use Email::Sender::Transport::SMTP;
|
||||||
|
|
||||||
|
has app => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => InstanceOf ['Mojolicious'],
|
||||||
|
);
|
||||||
|
|
||||||
|
has _config => ( is => 'lazy', );
|
||||||
|
|
||||||
|
has _transport => ( is => 'lazy', );
|
||||||
|
|
||||||
|
sub _build__config {
|
||||||
|
my $self = shift;
|
||||||
|
my $app = $self->app;
|
||||||
|
if ( defined $app->config->{smtp} ) {
|
||||||
|
return $app->config->{smtp};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _build__transport {
|
||||||
|
my $self = shift;
|
||||||
|
my $smtp_config = $self->_config;
|
||||||
|
if ( !$smtp_config ) {
|
||||||
|
die "Unable to build transport due the lack of related config.";
|
||||||
|
}
|
||||||
|
my %options = %$smtp_config;
|
||||||
|
delete $options{From};
|
||||||
|
my $tranport = Email::Sender::Transport::SMTP->new(%$smtp_config);
|
||||||
|
return $tranport;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
text => { type => Str },
|
||||||
|
html => { type => Str },
|
||||||
|
to => { type => Str },
|
||||||
|
subject => { type => Str },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub sendmail {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $transport = $self->_transport;
|
||||||
|
my $from = $self->_config->{From};
|
||||||
|
my ( $text, $html, $to, $subject ) = @params{qw/text html to subject/};
|
||||||
|
my @parts = (
|
||||||
|
Email::MIME->create(
|
||||||
|
attributes => {
|
||||||
|
charset => 'UTF-8',
|
||||||
|
content_type => 'text/plain',
|
||||||
|
encoding => 'base64',
|
||||||
|
disposition => 'inline',
|
||||||
|
},
|
||||||
|
body_str => $text,
|
||||||
|
),
|
||||||
|
Email::MIME->create(
|
||||||
|
attributes => {
|
||||||
|
charset => 'UTF-8',
|
||||||
|
content_type => 'text/html',
|
||||||
|
encoding => 'base64',
|
||||||
|
disposition => 'inline',
|
||||||
|
},
|
||||||
|
body_str => $html,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
my $email = Email::MIME->create(
|
||||||
|
header_str => [
|
||||||
|
From => $from,
|
||||||
|
to => $to,
|
||||||
|
subject => $subject,
|
||||||
|
],
|
||||||
|
attributes => {
|
||||||
|
encoding => 'base64',
|
||||||
|
content_type => 'multipart/mixed',
|
||||||
|
},
|
||||||
|
parts => [
|
||||||
|
Email::MIME->create(
|
||||||
|
attributes => {
|
||||||
|
content_type => 'multipart/alternative',
|
||||||
|
encoding => 'base64',
|
||||||
|
},
|
||||||
|
parts => [ @parts, ],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
eval {
|
||||||
|
Email::Sender::Simple->send( $email, { transport => $self->_transport });
|
||||||
|
};
|
||||||
|
if ($@) {
|
||||||
|
warn $@;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
1;
|
@ -20,6 +20,7 @@ const my $CURRENT_FILE => __FILE__;
|
|||||||
const my $ROOT_PROJECT => path($CURRENT_FILE)->parent->parent->parent;
|
const my $ROOT_PROJECT => path($CURRENT_FILE)->parent->parent->parent;
|
||||||
const my $PUBLIC_DIR => $ROOT_PROJECT->child('public');
|
const my $PUBLIC_DIR => $ROOT_PROJECT->child('public');
|
||||||
const my $POSTS_DIR => $ROOT_PROJECT->child('content/posts');
|
const my $POSTS_DIR => $ROOT_PROJECT->child('content/posts');
|
||||||
|
$POSTS_DIR->mkpath;
|
||||||
const my $BURGUILLOS_LOGO => $PUBLIC_DIR->child('img/burguillos.png');
|
const my $BURGUILLOS_LOGO => $PUBLIC_DIR->child('img/burguillos.png');
|
||||||
const my $SVG_WIDTH => 1200;
|
const my $SVG_WIDTH => 1200;
|
||||||
const my $SVG_HEIGHT => 627;
|
const my $SVG_HEIGHT => 627;
|
||||||
|
40
lib/MyRedland/Product.pm
Normal file
40
lib/MyRedland/Product.pm
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package MyRedland::Product;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use Moo;
|
||||||
|
use Types::Standard qw/Str Int/;
|
||||||
|
|
||||||
|
has name => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Str,
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
has id => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Str,
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
has description => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Str,
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
has price => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Int,
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
has period => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Str,
|
||||||
|
required => 1,
|
||||||
|
);
|
||||||
|
1;
|
72
lib/MyRedland/Products.pm
Normal file
72
lib/MyRedland/Products.pm
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package MyRedland::Products;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use utf8;
|
||||||
|
|
||||||
|
use MyRedland::Product;
|
||||||
|
|
||||||
|
use Moo;
|
||||||
|
use Types::Standard qw/Str Int/;
|
||||||
|
use Params::ValidationCompiler qw/validation_for/;
|
||||||
|
|
||||||
|
has all => (
|
||||||
|
is => 'lazy'
|
||||||
|
);
|
||||||
|
|
||||||
|
has _all_by_id => (
|
||||||
|
is => 'lazy'
|
||||||
|
);
|
||||||
|
|
||||||
|
sub _build_all {
|
||||||
|
my $self = shift;
|
||||||
|
return [
|
||||||
|
MyRedland::Product->new(
|
||||||
|
id => 'MONTH',
|
||||||
|
name => 'Pago mensual',
|
||||||
|
description => 'Paga mes a mes tu subscripción al servidor principal.',
|
||||||
|
price => 300,
|
||||||
|
period => 'mes',
|
||||||
|
),
|
||||||
|
MyRedland::Product->new(
|
||||||
|
id => 'PAIRMONTHLY',
|
||||||
|
name => 'Pago mensual en pareja',
|
||||||
|
description => 'Paga mes a mes tu suscripción y la de otra persona, os ahorráis 1€/mes entre los dos.',
|
||||||
|
price => 500,
|
||||||
|
period => 'mes',
|
||||||
|
),
|
||||||
|
MyRedland::Product->new(
|
||||||
|
id => 'YEAR',
|
||||||
|
name => 'Pago anual',
|
||||||
|
description => 'Paga anualmente tu subscripción al servidor principal, pagas 1.92€/m, 1.08€/m menos, te ahorras 13€/año.',
|
||||||
|
price => 2300,
|
||||||
|
period => 'año',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _build__all_by_id {
|
||||||
|
my $self = shift;
|
||||||
|
my $all = $self->all;
|
||||||
|
return {
|
||||||
|
(map { $_->id => $_ } @$all),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
id => { type => Str },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
sub find_by_id {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $id = $params{id};
|
||||||
|
return $self->_all_by_id->{$id};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1;
|
182
lib/MyRedland/Stripe.pm
Normal file
182
lib/MyRedland/Stripe.pm
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
package MyRedland::Stripe;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use MyRedland::DB;
|
||||||
|
use MyRedland::Lusers;
|
||||||
|
use Mojo::UserAgent;
|
||||||
|
|
||||||
|
use Moo;
|
||||||
|
use Params::ValidationCompiler qw/validation_for/;
|
||||||
|
use Types::Standard qw/Str InstanceOf HashRef Int Bool/;
|
||||||
|
use MIME::Base64;
|
||||||
|
use Data::Dumper;
|
||||||
|
use Mojo::Util qw/url_escape/;
|
||||||
|
|
||||||
|
my $ua = Mojo::UserAgent->new;
|
||||||
|
|
||||||
|
has app => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => InstanceOf ['Mojolicious'],
|
||||||
|
);
|
||||||
|
|
||||||
|
has _dbh => ( is => 'lazy', );
|
||||||
|
|
||||||
|
has _stripe_secret => ( is => 'lazy', );
|
||||||
|
|
||||||
|
has _users_dao => ( is => 'lazy', );
|
||||||
|
|
||||||
|
sub _build__dbh {
|
||||||
|
my $self = shift;
|
||||||
|
return MyRedland::DB->new( app => $self->app );
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _build__stripe_secret {
|
||||||
|
my $self = shift;
|
||||||
|
return $self->app->config->{stripe_secret};
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _build__users_dao {
|
||||||
|
my $self = shift;
|
||||||
|
return MyRedland::Lusers->new( app => $self->app );
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
method => { type => Str },
|
||||||
|
url => { type => Str },
|
||||||
|
form => { type => HashRef, optional => 1 },
|
||||||
|
headers => { type => HashRef, optional => 1 },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub _make_request {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $method = $params{method};
|
||||||
|
my $url = $params{url};
|
||||||
|
my $headers = $params{headers} // {};
|
||||||
|
$headers->{Authorization} =
|
||||||
|
'Basic ' . encode_base64( $self->_stripe_secret . ':', '' );
|
||||||
|
my $form = $params{form};
|
||||||
|
my $tx = $ua->build_tx( $method, $url, $headers,
|
||||||
|
( ( defined $form ) ? ( form => $form ) : () ) );
|
||||||
|
return $ua->start($tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sub create_customer_for_user {
|
||||||
|
my $self = shift;
|
||||||
|
my $user = shift;
|
||||||
|
if ( defined $user->stripe_customer_id ) {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
my $response = $self->_make_request(
|
||||||
|
method => 'POST',
|
||||||
|
url => 'https://api.stripe.com/v1/customers'
|
||||||
|
)->result->json;
|
||||||
|
|
||||||
|
$user->stripe_customer_id( $response->{id} );
|
||||||
|
$user = $self->_users_dao->update(
|
||||||
|
user => $user,
|
||||||
|
fields => [qw/stripe_customer_id/]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
user => {
|
||||||
|
type => InstanceOf ['MyRedland::Luser'],
|
||||||
|
},
|
||||||
|
price => {
|
||||||
|
type => Int,
|
||||||
|
},
|
||||||
|
off_session => {
|
||||||
|
type => Bool
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub create_payment_intent {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my ( $user, $price, $off_session ) =
|
||||||
|
@params{qw/user price off_session/};
|
||||||
|
my $response = $self->_make_request(
|
||||||
|
method => 'POST',
|
||||||
|
url => 'https://api.stripe.com/v1/payment_intents',
|
||||||
|
form => {
|
||||||
|
currency => 'eur',
|
||||||
|
amount => $price,
|
||||||
|
'payment_method_types[]' => 'card',
|
||||||
|
customer => $user->stripe_customer_id,
|
||||||
|
( ($off_session) ? ( off_session => $off_session ) : () ),
|
||||||
|
}
|
||||||
|
)->result->json;
|
||||||
|
if ( defined $response->{error} ) {
|
||||||
|
die Data::Dumper::Dumper $response->{error};
|
||||||
|
}
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
payment_intent_id => {
|
||||||
|
type => Str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub retrieve_payment_intent {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my ($payment_intent_id) = $params{'payment_intent_id'};
|
||||||
|
my $response = $self->_make_request(
|
||||||
|
method => 'GET',
|
||||||
|
url => 'https://api.stripe.com/v1/payment_intents/'.url_escape($payment_intent_id),
|
||||||
|
)->result->json;
|
||||||
|
if ( defined $response->{error} ) {
|
||||||
|
die Data::Dumper::Dumper $response->{error};
|
||||||
|
}
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
payment_intent_id => {
|
||||||
|
type => Str
|
||||||
|
},
|
||||||
|
updates => {
|
||||||
|
type => HashRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub update_payment_intent {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my ($payment_intent_id, $updates) = @params{qw/payment_intent_id updates/};
|
||||||
|
my $response = $self->_make_request(
|
||||||
|
method => 'POST',
|
||||||
|
url => 'https://api.stripe.com/v1/payment_intents/'.url_escape($payment_intent_id),
|
||||||
|
form => $updates,
|
||||||
|
)->result->json;
|
||||||
|
print Data::Dumper::Dumper $response;
|
||||||
|
if ( defined $response->{error} ) {
|
||||||
|
die Data::Dumper::Dumper $response->{error};
|
||||||
|
}
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1;
|
56
lib/MyRedland/SubscriptionOrder.pm
Normal file
56
lib/MyRedland/SubscriptionOrder.pm
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package MyRedland::SubscriptionOrder;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use Moo;
|
||||||
|
use Types::Standard qw/Str Bool InstanceOf Int/;
|
||||||
|
|
||||||
|
has uuid => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Str,
|
||||||
|
);
|
||||||
|
has product => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => InstanceOf['MyRedland::Product'],
|
||||||
|
);
|
||||||
|
has user => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => InstanceOf['MyRedland::Luser'],
|
||||||
|
);
|
||||||
|
has payment_intent_id => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Str,
|
||||||
|
);
|
||||||
|
has client_secret => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Str,
|
||||||
|
);
|
||||||
|
|
||||||
|
has renew_auto => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => Bool,
|
||||||
|
);
|
||||||
|
|
||||||
|
has paid => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => Bool,
|
||||||
|
);
|
||||||
|
|
||||||
|
has to_pay => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => Int,
|
||||||
|
);
|
||||||
|
|
||||||
|
has create_date => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => InstanceOf['DateTime'],
|
||||||
|
);
|
||||||
|
|
||||||
|
has save_card => (
|
||||||
|
is => 'rw',
|
||||||
|
isa => Bool,
|
||||||
|
);
|
||||||
|
1;
|
146
lib/MyRedland/SubscriptionOrders.pm
Normal file
146
lib/MyRedland/SubscriptionOrders.pm
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package MyRedland::SubscriptionOrders;
|
||||||
|
|
||||||
|
use v5.34.1;
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
use Moo;
|
||||||
|
use Types::Standard qw/Str Bool InstanceOf ArrayRef Int/;
|
||||||
|
use Params::ValidationCompiler qw/validation_for/;
|
||||||
|
use DateTime::Format::Pg;
|
||||||
|
use Data::Dumper;
|
||||||
|
use List::AllUtils qw/any/;
|
||||||
|
|
||||||
|
use MyRedland::DB;
|
||||||
|
use MyRedland::Lusers;
|
||||||
|
use MyRedland::Products;
|
||||||
|
use MyRedland::SubscriptionOrder;
|
||||||
|
has app => (
|
||||||
|
is => 'ro',
|
||||||
|
isa => InstanceOf['Mojolicious'],
|
||||||
|
);
|
||||||
|
|
||||||
|
my $fpg = DateTime::Format::Pg->new;
|
||||||
|
|
||||||
|
my @FIELDS = qw/uuid product_id user_uuid payment_intent_id client_secret renew_auto paid create_date to_pay save_card/;
|
||||||
|
|
||||||
|
has dbh => ( is => 'lazy', );
|
||||||
|
|
||||||
|
sub _build_dbh {
|
||||||
|
my $self = shift;
|
||||||
|
return MyRedland::DB->connect( $self->app );
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
product => { type => InstanceOf['MyRedland::Product'] },
|
||||||
|
user => { type => InstanceOf['MyRedland::Luser'] },
|
||||||
|
payment_intent_id => { type => Str },
|
||||||
|
client_secret => { type => Str },
|
||||||
|
renew_auto => { type => Bool },
|
||||||
|
save_card => { type => Bool },
|
||||||
|
paid => { type => Bool },
|
||||||
|
to_pay => { type => Int },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub create {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $dbh = $self->dbh;
|
||||||
|
my ($product, $user, $payment_intent_id, $client_secret, $renew_auto, $paid, $to_pay, $save_card) = @params{qw/product user payment_intent_id client_secret
|
||||||
|
renew_auto paid to_pay save_card/};
|
||||||
|
my $returning_hash = $dbh->selectrow_hashref(
|
||||||
|
<<"EOF", undef, $product->id, $user->uuid, $client_secret, $renew_auto, $paid, $to_pay, $payment_intent_id, $save_card);
|
||||||
|
INSERT INTO subscription_orders
|
||||||
|
(product_id, user_uuid, client_secret, renew_auto,
|
||||||
|
paid, to_pay, payment_intent_id, save_card)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING @{[join ', ', @FIELDS]};
|
||||||
|
EOF
|
||||||
|
return $self->_convert_hash_to_object($returning_hash);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _convert_hash_to_object {
|
||||||
|
my $self = shift;
|
||||||
|
my $hash = shift;
|
||||||
|
my $users_dao = MyRedland::Lusers->new( app => $self->app );
|
||||||
|
my $products_dao = MyRedland::Products->new;
|
||||||
|
my $product_id = delete $hash->{product_id};
|
||||||
|
my $user_uuid = delete $hash->{user_uuid};
|
||||||
|
my $create_date = delete $hash->{create_date};
|
||||||
|
$hash->{create_date} = $fpg->parse_datetime($create_date);
|
||||||
|
if (!defined $product_id) {
|
||||||
|
# This should not happen.
|
||||||
|
die 'No product.';
|
||||||
|
}
|
||||||
|
if (!defined $user_uuid) {
|
||||||
|
# This should not happen.
|
||||||
|
die 'No user';
|
||||||
|
}
|
||||||
|
$hash->{user} = $users_dao->find_by_uuid(uuid => $user_uuid);
|
||||||
|
$hash->{product} = $products_dao->find_by_id(id => $product_id);
|
||||||
|
return MyRedland::SubscriptionOrder->new(%$hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
subscription_order => { type => InstanceOf ['MyRedland::SubscriptionOrder'] },
|
||||||
|
fields => { type => ArrayRef [Str] },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub update {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $subscription_order = $params{subscription_order};
|
||||||
|
my $fields = $params{fields};
|
||||||
|
my %updates;
|
||||||
|
for my $field (@$fields) {
|
||||||
|
if ( any { $field eq $_ } qw/renew_auto paid save_card/ ) {
|
||||||
|
$updates{$field} = $subscription_order->$field;
|
||||||
|
next;
|
||||||
|
}
|
||||||
|
die "No such field $field.";
|
||||||
|
}
|
||||||
|
my $query = <<"EOF";
|
||||||
|
UPDATE subscription_orders
|
||||||
|
SET @{[
|
||||||
|
join ', ', map { "$_ = ?" } @$fields
|
||||||
|
]} WHERE uuid = ?
|
||||||
|
RETURNING @{[join ', ', @FIELDS]};
|
||||||
|
EOF
|
||||||
|
my $dbh = $self->dbh;
|
||||||
|
my $hash = $dbh->selectrow_hashref($query, undef, @updates{@$fields}, $subscription_order->uuid);
|
||||||
|
return $self->_convert_hash_to_object($hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
my $validator = validation_for(
|
||||||
|
params => {
|
||||||
|
uuid => { type => Str },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
sub find_by_uuid {
|
||||||
|
my $self = shift;
|
||||||
|
my %params = $validator->(@_);
|
||||||
|
my $uuid = $params{uuid};
|
||||||
|
my $dbh = $self->dbh;
|
||||||
|
my $hash = $dbh->selectrow_hashref( <<"EOF", undef, $uuid );
|
||||||
|
SELECT @{[join ', ', @FIELDS]} FROM subscription_orders where uuid = ?;
|
||||||
|
EOF
|
||||||
|
if ( !defined $hash ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return $self->_convert_hash_to_object($hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1;
|
@ -3,5 +3,15 @@
|
|||||||
"bcrypt_pass_stats": ["change_for_bcrypted_password"],
|
"bcrypt_pass_stats": ["change_for_bcrypted_password"],
|
||||||
"db": {
|
"db": {
|
||||||
"database": "example"
|
"database": "example"
|
||||||
|
},
|
||||||
|
"stripe_secret": "secret",
|
||||||
|
"stripe_public": "public",
|
||||||
|
"test_mode": 1,
|
||||||
|
"smtp": {
|
||||||
|
"ssl": 1,
|
||||||
|
"hosts": [ "example.com" ],
|
||||||
|
"sasl_username": "exampleuser@example.com",
|
||||||
|
"sasl_password": "[redacted]",
|
||||||
|
"From": "Example User <exampleuser@example.com>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
59
package.json
Normal file
59
package.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "myredland",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Frontend for MyRedland",
|
||||||
|
"private": true,
|
||||||
|
"directories": {
|
||||||
|
"lib": "js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git@git.owlcode.tech:sergiotarxz/MyRedland.git"
|
||||||
|
},
|
||||||
|
"author": "Sergio Iglesias",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"babel-cli": "^6.26.0",
|
||||||
|
"babel-preset-react-app": "^3.1.2",
|
||||||
|
"browserify": "^17.0.0",
|
||||||
|
"react-image-crop": "^10.0.9",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"sass": "^1.56.2",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"css-loader": "^6.7.3",
|
||||||
|
"prettier": "^2.8.1",
|
||||||
|
"sass-loader": "^13.2.0",
|
||||||
|
"style-loader": "^3.3.1",
|
||||||
|
"webpack": "^5.75.0",
|
||||||
|
"webpack-cli": "^5.0.1"
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,22 @@ $color-secondary: #8eea6d;
|
|||||||
$accent-secondary: #fde68f;
|
$accent-secondary: #fde68f;
|
||||||
$primary-secondary: #590e11;
|
$primary-secondary: #590e11;
|
||||||
|
|
||||||
|
#payment-form {
|
||||||
|
background: $color_div;
|
||||||
|
color: $background_div;
|
||||||
|
padding: 10px;
|
||||||
|
div.submit-div {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
button {
|
||||||
|
background: $background_div;
|
||||||
|
font-size: 19px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@ -34,6 +50,36 @@ body {
|
|||||||
background-size: cover;
|
background-size: cover;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
div.divider {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100%;
|
||||||
|
div.side-menu {
|
||||||
|
width: 30%;
|
||||||
|
background: $color_div;
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 60px;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #DC143C;
|
||||||
|
border-bottom: #36454F 3px solid;
|
||||||
|
text-decoration: none;
|
||||||
|
background: $color_div;
|
||||||
|
&:hover {
|
||||||
|
color: $color_div;
|
||||||
|
background: $background_div;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div.main-page-contents {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
div.page-contents {
|
div.page-contents {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -69,6 +115,9 @@ body {
|
|||||||
b,p,li {
|
b,p,li {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
}
|
}
|
||||||
|
small {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 47px;
|
font-size: 47px;
|
||||||
}
|
}
|
||||||
@ -79,6 +128,29 @@ body {
|
|||||||
font-size: 60px;
|
font-size: 60px;
|
||||||
}
|
}
|
||||||
div.description {
|
div.description {
|
||||||
|
div.subscription-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
div.product {
|
||||||
|
width: 44%;
|
||||||
|
margin-right: 3%;
|
||||||
|
color: $background_div;
|
||||||
|
background: $color_div;
|
||||||
|
padding: 30px;
|
||||||
|
padding-top: 10px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
a.subscribe-button {
|
||||||
|
padding: 10px;
|
||||||
|
background: $background_div;
|
||||||
|
color: $color_div;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
input {
|
input {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@ -175,11 +247,38 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
display: none;
|
display: none;
|
||||||
a {
|
a {
|
||||||
|
height: 100%;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
background: $background_div;
|
background: $background_div;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
nav {
|
nav {
|
||||||
|
a.common-user-button {
|
||||||
|
float: right;
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
margin-right: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $background-page;
|
||||||
|
}
|
||||||
|
a.login-button {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
img {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.profile-button {
|
||||||
|
height: 50px;
|
||||||
|
width: 50px;
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
img {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 35px;
|
font-size: 35px;
|
||||||
@ -273,7 +372,8 @@ body {
|
|||||||
}
|
}
|
||||||
nav.desktop {
|
nav.desktop {
|
||||||
max-height: 60px;
|
max-height: 60px;
|
||||||
display: block;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
height: auto;
|
height: auto;
|
||||||
a {
|
a {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
|
BIN
public/img/menu.png
Normal file
BIN
public/img/menu.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.6 KiB |
5
public/img/person.svg
Normal file
5
public/img/person.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16" class="svg octicon-person" width="16" height="16" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10.5 5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0zm.061 3.073a4 4 0 1 0-5.123 0 6.004 6.004 0 0 0-3.431 5.142.75.75 0 0 0 1.498.07 4.5 4.5 0 0 1 8.99 0 .75.75 0 1 0 1.498-.07 6.005 6.005 0 0 0-3.432-5.142z"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 571 B |
@ -1,3 +1,11 @@
|
|||||||
|
% my $categories = stash 'categories';
|
||||||
|
% if (!defined $categories) {
|
||||||
|
% $categories = MyRedland::Categories->new->Retrieve;
|
||||||
|
% }
|
||||||
|
% my $current_slug = stash 'current_slug';
|
||||||
|
% if (!defined $current_slug) {
|
||||||
|
% $current_slug = $categories->{index}{slug};
|
||||||
|
% }
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
@ -20,20 +28,28 @@
|
|||||||
<div class="site-wrapper">
|
<div class="site-wrapper">
|
||||||
</div>
|
</div>
|
||||||
<div class="page-contents">
|
<div class="page-contents">
|
||||||
<nav class="desktop"><% for my $category_key (sort {
|
<nav class="desktop"><div><% for my $category_key (sort {
|
||||||
$categories->{$a}{priority} <=> $categories->{$b}{priority}
|
$categories->{$a}{priority} <=> $categories->{$b}{priority}
|
||||||
} keys %$categories) {
|
} keys %$categories) {
|
||||||
my $category = $categories->{$category_key};
|
my $category = $categories->{$category_key};
|
||||||
my $selected = defined($current_slug) && $category->{slug} eq $current_slug;
|
my $selected = defined($current_slug) && $category->{slug} eq $current_slug;
|
||||||
%><a class="<%=$selected && "selected" %>" href="<%= '/'.$category->{slug} %>"><%==$category->{menu_text}%></a><%
|
%><a class="<%=$selected && "selected" %>" href="<%= '/'.$category->{slug} %>"><%==$category->{menu_text}%></a><%
|
||||||
}
|
}
|
||||||
%></nav>
|
%></div><%
|
||||||
<nav class="mobile-shortcuts">
|
if ($self->current_user) {
|
||||||
|
if ($self->current_user->avatar) {
|
||||||
|
%><a class="common-user-button profile-button" href="/perfil"><img alt="Tu avatar" src="/usuario/avatar"/></a><%
|
||||||
|
} else {
|
||||||
|
%><a class="common-user-button login-button" href="/perfil"><img alt="Icono de login" src="/img/person.svg"/></a><%
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
%><a class="common-user-button login-button" href="/login"><img alt="Icono de login" src="/img/person.svg"/></a><%
|
||||||
|
}
|
||||||
|
%></nav><nav class="mobile-shortcuts">
|
||||||
<a class="go-to-index" href="<%='/'.$categories->{index}{slug}%>"><%== $categories->{index}{menu_text} %></a>
|
<a class="go-to-index" href="<%='/'.$categories->{index}{slug}%>"><%== $categories->{index}{menu_text} %></a>
|
||||||
<div></div>
|
<div></div>
|
||||||
<a href="#mobile-foldable" class="menu-expand"><img class="open-menu-icon" src="/img/menu.png" alt="Expandir el menú."/></a>
|
<a href="#mobile-foldable" class="menu-expand"><img class="open-menu-icon" src="/img/menu.png" alt="Expandir el menú."/></a>
|
||||||
</nav>
|
</nav><nav class="mobile-foldable" id="mobile-foldable"><%
|
||||||
<nav class="mobile-foldable" id="mobile-foldable"><%
|
|
||||||
my $first = 1;
|
my $first = 1;
|
||||||
for my $category_key (sort {
|
for my $category_key (sort {
|
||||||
$categories->{$a}{priority} <=> $categories->{$b}{priority}
|
$categories->{$a}{priority} <=> $categories->{$b}{priority}
|
||||||
@ -41,16 +57,24 @@
|
|||||||
my $category = $categories->{$category_key};
|
my $category = $categories->{$category_key};
|
||||||
%><a href="<%= '/'.$category->{slug} %>"><%==$category->{menu_text}%></a><%
|
%><a href="<%= '/'.$category->{slug} %>"><%==$category->{menu_text}%></a><%
|
||||||
}
|
}
|
||||||
%></nav>
|
%></nav><div class="divider">
|
||||||
|
% if (stash 'side_menu') {
|
||||||
<%= content %></body>
|
<div class="side-menu">
|
||||||
<hr/>
|
<%= content 'side_menu' %>
|
||||||
<div class="footer description">
|
</div>
|
||||||
<p>©2022 Sergio Iglesias</p>
|
% }
|
||||||
<p>Enterate de todas las novedades de Redland Official:</p>
|
<div class="main-page-contents">
|
||||||
<a class="suscribe-category-rss" href="/all.rss">
|
<%= content %>
|
||||||
<img src="/img/rss.svg" alt="Icono de suscripción rss"/>
|
<hr/>
|
||||||
</a>
|
<div class="footer description">
|
||||||
|
<p>©2022 Sergio Iglesias</p>
|
||||||
|
<p>Enterate de todas las novedades de Redland Official:</p>
|
||||||
|
<a class="suscribe-category-rss" href="/all.rss">
|
||||||
|
<img src="/img/rss.svg" alt="Icono de suscripción rss"/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
8
templates/layouts/side_menu.html.ep
Normal file
8
templates/layouts/side_menu.html.ep
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
% layout 'default';
|
||||||
|
% stash side_menu => 1;
|
||||||
|
% content_for 'side_menu' => begin
|
||||||
|
<a href="/perfil">Mi perfil</a>
|
||||||
|
<a href="/perfil/configura-tu-avatar">Avatar</a>
|
||||||
|
<a href="/perfil/opciones-de-subscripcion">Opciones de subscripción</a>
|
||||||
|
% end
|
||||||
|
<%= content %>
|
15
templates/user/login_get.html.ep
Normal file
15
templates/user/login_get.html.ep
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
% use MyRedland::Posts;
|
||||||
|
%
|
||||||
|
% layout 'default';
|
||||||
|
% title 'Login Redland Official';
|
||||||
|
<div class="description">
|
||||||
|
<h2>Página de Login.</h2>
|
||||||
|
<form action="/login" method="post">
|
||||||
|
Nombre de usuario
|
||||||
|
<input placeholder="Nombre de usuario" type="text" name="username"/>
|
||||||
|
Contraseña
|
||||||
|
<input placeholder="Contraseña" type="password" name="password"/>
|
||||||
|
<input type="submit" value="Enviar"/>
|
||||||
|
</form>
|
||||||
|
<p><small>¿No tienes cuenta? <a href="/registrate">Registrate</a></small></p>
|
||||||
|
</div>
|
10
templates/user/logout_get.html.ep
Normal file
10
templates/user/logout_get.html.ep
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
% layout 'default';
|
||||||
|
% title 'Logout - Redland Official';
|
||||||
|
<div class="description">
|
||||||
|
<h2>¿Seguro que deseas cerrar la sesión?</h2>
|
||||||
|
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<input type="submit" value="Sí, deseo cerrar la sesión." name="yes"/>
|
||||||
|
<input type="submit" value="No, sacame de aquí." name="no"/>
|
||||||
|
</form>
|
||||||
|
</div>
|
11
templates/user/mail_verify.html.ep
Normal file
11
templates/user/mail_verify.html.ep
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
% layout 'default';
|
||||||
|
% title 'Verifica el correo Redland Official';
|
||||||
|
<div class="description">
|
||||||
|
<h2>Verifica tu correo.</h2>
|
||||||
|
<p>Te hemos enviado un correo electrónico, pulsa sobre el enlace para verificar tu correo.</p>
|
||||||
|
<p>Recuerda que tendrás que estar logueado en la página para poder confirmar tu correo, nada más terminar el registro te logueamos automáticamente, pero es posible que al cerrar el navegador tu sesión sea terminada y tengas que loguearte de nuevo.</p>
|
||||||
|
|
||||||
|
<p>También es posible que quieras confirmar tu correo desde otro dispositivo, para ello tendrás que loguearte en ese otro dispositivo en redlandofficial.com, si no no podremos verificar que realmente eres tú quien pulsa el enlace, otra opción es reenviar el enlace al dispositivo desde el que te has registrado.</p>
|
||||||
|
|
||||||
|
<p>Si necesitas ayuda contacta con <a href="mailto:contact@owlcode.tech">contact@owlcode.tech</a>.</p>
|
||||||
|
</div>
|
9
templates/user/payment_success_get.html.ep
Normal file
9
templates/user/payment_success_get.html.ep
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
% title 'Pago exitoso - Redland Official';
|
||||||
|
% layout 'side_menu';
|
||||||
|
% my $so = stash 'so';
|
||||||
|
% my $pi = stash 'pi';
|
||||||
|
<div class="description">
|
||||||
|
<h2>Pago exitoso.</h2>
|
||||||
|
|
||||||
|
<p>Te has subscrito con exito en la modalidad "<%=$so->product->name%>" por la cantidad de <%=$so->to_pay/100%>€ al <%=$so->product->period%>.</p>
|
||||||
|
</div>
|
10
templates/user/profile.html.ep
Normal file
10
templates/user/profile.html.ep
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
% title 'Perfil - Redland Official';
|
||||||
|
% layout 'side_menu';
|
||||||
|
<div class="description">
|
||||||
|
<h2>Tu perfil.</h2>
|
||||||
|
|
||||||
|
<p>Tu nombre de usuario es <%= $self->current_user->username %></p>
|
||||||
|
<p>El estado de tu cuenta es <%= 'no' unless $self->current_user->verified %> verificado.</p>
|
||||||
|
|
||||||
|
<p><a href="/logout">Cerrar sesión</a></p>
|
||||||
|
</div>
|
13
templates/user/setup_avatar_get.html.ep
Normal file
13
templates/user/setup_avatar_get.html.ep
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
% layout 'side_menu';
|
||||||
|
% title 'Configura tu avatar Redland Official';
|
||||||
|
<div class="description">
|
||||||
|
<form action="/perfil">
|
||||||
|
<h2>Configura tu avatar.</h2>
|
||||||
|
|
||||||
|
<input type="file" id="avatar-selector" accept=".jpg, .png, .jpeg .webp">
|
||||||
|
<div style="display: none;" id="avatar-cropper"></div>
|
||||||
|
<input type="submit" id="omit-avatar-setup" value="Omitir"/>
|
||||||
|
<button disabled type="button" id="select-avatar">Seleccionar</button>
|
||||||
|
</form>
|
||||||
|
<script src="/js/npm.bundle.js"></script>
|
||||||
|
</div>
|
18
templates/user/sign_up_get.html.ep
Normal file
18
templates/user/sign_up_get.html.ep
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
% use MyRedland::Posts;
|
||||||
|
%
|
||||||
|
% layout 'default';
|
||||||
|
% title 'Login Redland Official';
|
||||||
|
<div class="description">
|
||||||
|
<h2>Página de Login.</h2>
|
||||||
|
<form action="/registrate" method="post">
|
||||||
|
Nombre de usuario
|
||||||
|
<input placeholder="Nombre de usuario" type="text" name="username"/>
|
||||||
|
Email
|
||||||
|
<input placeholder="Email" type="text" name="email"/>
|
||||||
|
Contraseña
|
||||||
|
<input placeholder="Contraseña" type="password" name="password"/>
|
||||||
|
Repite la contraseña
|
||||||
|
<input placeholder="Repite la contraseña" type="password" name="repeat_password"/>
|
||||||
|
<input type="submit" value="Enviar"/>
|
||||||
|
</form>
|
||||||
|
</div>
|
163
templates/user/subscribe_get.html.ep
Normal file
163
templates/user/subscribe_get.html.ep
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
% use JSON qw/encode_json/;
|
||||||
|
% title 'Suscribete - Redland Official';
|
||||||
|
% layout 'side_menu';
|
||||||
|
% my $product = stash 'product';
|
||||||
|
% my $so = stash 'subscription_order';
|
||||||
|
% my $current_payment_intent = $self->current_payment_intent;
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
<h2>Ultimando tu subscripción.</h2>
|
||||||
|
|
||||||
|
<input type="hidden" id="publishable-secret" value="<%=$config->{stripe_public}%>"/>
|
||||||
|
<input type="hidden" id="so-uuid" value="<%=$so->uuid%>"/>
|
||||||
|
|
||||||
|
<p>Estás suscribiendote en la modalidad "<%=$product->name%>"</p>
|
||||||
|
|
||||||
|
<p>Vas a pagar <%=$so->to_pay/100%>€ al <%=$product->period%>.</p>
|
||||||
|
|
||||||
|
<form id="payment-form">
|
||||||
|
<input type="checkbox" id="save-card"
|
||||||
|
<%=$so->save_card ? 'checked' : ''%> <%=$so->renew_auto ? 'disabled' : ''%>/>
|
||||||
|
<label>Guardar la tarjeta para futuros pagos.</label>
|
||||||
|
<input type="checkbox" id="renew-subscription" value="Renovar la subscripción automáticamente."
|
||||||
|
<%=$so->renew_auto ? 'checked': ''%>/>
|
||||||
|
<label>Renovar la subscripción automáticamente.</label>
|
||||||
|
|
||||||
|
<div id="card-element">
|
||||||
|
</div>
|
||||||
|
<div class="submit-div">
|
||||||
|
<button id="submit">Enviar pago</button>
|
||||||
|
</div>
|
||||||
|
<div id="error-message">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
<script nonce="<%=$nonce%>">
|
||||||
|
const publishable_secret_element = document.querySelector('#publishable-secret');
|
||||||
|
const stripe = Stripe(publishable_secret_element.value);
|
||||||
|
const options = JSON.parse(<%== encode_json(encode_json(
|
||||||
|
{
|
||||||
|
clientSecret => $so->client_secret,
|
||||||
|
appearance => {
|
||||||
|
theme => 'night',
|
||||||
|
variables => {
|
||||||
|
colorBackground => '#ffffff',
|
||||||
|
colorText => '#30313d',
|
||||||
|
colorDanger => '#df1b41',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));%>);
|
||||||
|
const clientSecret = options.clientSecret;
|
||||||
|
const appearance = options.appearance;
|
||||||
|
const renew_subscription_element = document.querySelector('#renew-subscription');
|
||||||
|
const style = {
|
||||||
|
base: {
|
||||||
|
background: '#191326',
|
||||||
|
color: "#DC143C",
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
fontSmoothing: "antialiased",
|
||||||
|
fontSize: "25px",
|
||||||
|
"::placeholder": {
|
||||||
|
color: "#DC143C",
|
||||||
|
background: '#191326',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
invalid: {
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
color: "#fa755a",
|
||||||
|
iconColor: "#fa755a"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const save_card_element = document.querySelector('#save-card');
|
||||||
|
|
||||||
|
const form = document.querySelector('#payment-form');
|
||||||
|
|
||||||
|
async function update_checkboxes() {
|
||||||
|
const response = await fetch('/orden-de-subscripcion/estado', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
save_card_element.checked = !!body.save_card;
|
||||||
|
renew_subscription_element.checked = !!body.renew_auto;
|
||||||
|
save_card_element.disabled = !!body.renew_auto;
|
||||||
|
renew_subscription_element.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payWithCard(stripe, card, client_secret) {
|
||||||
|
update_checkboxes().then(() => {
|
||||||
|
const data = {
|
||||||
|
payment_method: {
|
||||||
|
card: card
|
||||||
|
}
|
||||||
|
};
|
||||||
|
data.setup_future_usage = 'off_session';
|
||||||
|
stripe.confirmCardPayment(clientSecret, data)
|
||||||
|
.then(function(result) {
|
||||||
|
if (result.error) {
|
||||||
|
// Show error to your customer
|
||||||
|
showError(result.error.message);
|
||||||
|
} else {
|
||||||
|
window.location = '/perfil/pago-exitoso';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(error) {
|
||||||
|
const error_msg = document.querySelector("#error-message");
|
||||||
|
error_msg.textContent = error;
|
||||||
|
setTimeout(function() {
|
||||||
|
error_msg.textContent = "";
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_checkboxes();
|
||||||
|
|
||||||
|
save_card_element.addEventListener('change', () => {
|
||||||
|
save_card_element.disabled = true;
|
||||||
|
renew_subscription_element.disabled = true;
|
||||||
|
fetch('/orden-de-subscripcion/guardar-tarjeta-api', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({new_value: save_card_element.checked}),
|
||||||
|
}).then((response) => response.text()).then((text) => {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
if (body['error'] != null) {
|
||||||
|
alert(body['error']);
|
||||||
|
}
|
||||||
|
update_checkboxes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
renew_subscription_element.addEventListener('change', () => {
|
||||||
|
save_card_element.disabled = true;
|
||||||
|
renew_subscription_element.disabled = true;
|
||||||
|
fetch('/orden-de-subscripcion/renovacion-automatica-api', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({new_value: renew_subscription_element.checked}),
|
||||||
|
}).then((response) => response.text()).then( (text) => {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
if (body['error'] != null) {
|
||||||
|
alert(body['error']);
|
||||||
|
}
|
||||||
|
update_checkboxes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const elements = stripe.elements({clientSecret, appearance});
|
||||||
|
|
||||||
|
// Create and mount the Payment Element
|
||||||
|
const card = elements.create('card', { style, hideIcon: true });
|
||||||
|
card.mount('#card-element');
|
||||||
|
card.on("change", function (event) {
|
||||||
|
document.querySelector("#submit").disabled = event.empty;
|
||||||
|
document.querySelector("#error-message").textContent = event.error ? event.error.message : "";
|
||||||
|
});
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
payWithCard(stripe, card, options.clientSecret);
|
||||||
|
});
|
||||||
|
</script>
|
21
templates/user/subscription_options.html.ep
Normal file
21
templates/user/subscription_options.html.ep
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
% use MyRedland::Products;
|
||||||
|
% use Mojo::Util qw/xml_escape/;
|
||||||
|
%
|
||||||
|
% title 'Opciones de subscripción - Redland Official';
|
||||||
|
% layout 'side_menu';
|
||||||
|
% my $products_dao = MyRedland::Products->new;
|
||||||
|
% my $products = $products_dao->all;
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
<h2>Opciones de subscripción.</h2>
|
||||||
|
<div class="subscription-options">
|
||||||
|
% for my $product (@$products) {
|
||||||
|
<div class="product">
|
||||||
|
<p class="product-price"><%=$product->price/100%>€</p>
|
||||||
|
<p><%=$product->name%></p>
|
||||||
|
<p><%=$product->description%></p>
|
||||||
|
<a class="subscribe-button" href="/perfil/subscribirse?product=<%=$product->id%>">Subscribirse.</a>
|
||||||
|
</div>
|
||||||
|
% }
|
||||||
|
</div>
|
||||||
|
</div>
|
19
webpack.config.js
Normal file
19
webpack.config.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const devMode = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
devtool: "inline-source-map",
|
||||||
|
mode: 'development',
|
||||||
|
entry: path.resolve(__dirname, 'js/index.js'),
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'public/js'),
|
||||||
|
filename: 'npm.bundle.js'
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [{
|
||||||
|
test: /\.(sa|sc|c)ss$/i,
|
||||||
|
use: [devMode ? "style-loader" : MiniCssExtractPlugin.loader, "css-loader", "postcss-loader", "sass-loader"]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue
Block a user