Compare commits

..

No commits in common. "7a439a40cc87cd68b593e14ec6a7c49fa989a9dd" and "b1c653e7de82477cbae233710a858392ed8d3e76" have entirely different histories.

15 changed files with 504 additions and 785 deletions

View File

@ -8,7 +8,7 @@ values (1, 'demo@numerus', 'Demo User', 'demo', 'invoicer')
; ;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code) insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code)
values (1, 'Juli Verd', 'ES40404040D', 'Pesebre', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de lHort', 'Castelló dEmpúries', 'Girona', '17486', 'ES', 'EUR'); values (1, 'Juli Verd', 'ES40404040D', 'Pesebre', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de lHort', 'Castelló dEmpúries', 'Alt Empordà', '17486', 'ES', 'EUR');
insert into company_user (company_id, user_id) insert into company_user (company_id, user_id)
values (1, 1) values (1, 1)

View File

@ -3,7 +3,6 @@ package pkg
import ( import (
"context" "context"
"errors" "errors"
"html/template"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@ -83,75 +82,54 @@ type Tax struct {
Rate int Rate int
} }
type taxDetailsForm struct {
*contactForm
Currency *SelectField
}
func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDetailsForm {
return &taxDetailsForm{
contactForm: newContactForm(ctx, conn, locale),
Currency: &SelectField{
Name: "currency",
Label: pgettext("input", "Currency", locale),
Options: MustGetOptions(ctx, conn, "select currency_code, currency_symbol from currency order by currency_code"),
Selected: "EUR",
},
}
}
func (form *taxDetailsForm) Parse(r *http.Request) error {
if err := form.contactForm.Parse(r); err != nil {
return err
}
form.Currency.FillValue(r)
return nil
}
func (form *taxDetailsForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator()
validator.CheckValidSelectOption(form.Currency, gettext("Selected currency is not valid.", form.locale))
return form.contactForm.Validate(ctx, conn) && validator.AllOK()
}
func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) *taxDetailsForm {
err := conn.QueryRow(ctx, "select business_name, substr(vatin::text, 3), trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code from company where company_id = $1", company.Id).Scan(form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country, form.Currency)
if err != nil {
panic(err)
}
return form
}
type TaxDetailsPage struct { type TaxDetailsPage struct {
DetailsForm *taxDetailsForm BusinessName string
NewTaxForm *newTaxForm VATIN string
Taxes []*Tax TradeName string
Phone string
Email string
Web string
Address string
City string
Province string
PostalCode string
CountryCode string
Countries []CountryOption
CurrencyCode string
Currencies []CurrencyOption
Taxes []Tax
} }
func CompanyTaxDetailsHandler() http.Handler { func CompanyTaxDetailsHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) page := &TaxDetailsPage{}
page := &TaxDetailsPage{
DetailsForm: newTaxDetailsForm(r.Context(), conn, locale),
NewTaxForm: newNewTaxForm(locale),
}
company := mustGetCompany(r) company := mustGetCompany(r)
conn := getConn(r)
if r.Method == "POST" { if r.Method == "POST" {
if err := page.DetailsForm.Parse(r); err != nil { r.ParseForm()
http.Error(w, err.Error(), http.StatusBadRequest) page.BusinessName = r.FormValue("business_name")
return page.CountryCode = r.FormValue("country")
} page.VATIN = page.CountryCode + r.FormValue("vatin")
if ok := page.DetailsForm.Validate(r.Context(), conn); ok { page.TradeName = r.FormValue("trade_name")
form := page.DetailsForm page.Phone = r.FormValue("phone")
conn.MustExec(r.Context(), "update company set business_name = $1, vatin = ($11 || $2)::vatin, trade_name = $3, phone = parse_packed_phone_number($4, $11), email = $5, web = $6, address = $7, city = $8, province = $9, postal_code = $10, country_code = $11, currency_code = $12 where company_id = $13", form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country, form.Currency, company.Id) page.Email = r.FormValue("email")
http.Redirect(w, r, "/company/"+company.Slug+"/tax-details", http.StatusSeeOther) page.Web = r.FormValue("web")
return page.Address = r.FormValue("address")
} page.City = r.FormValue("city")
w.WriteHeader(http.StatusUnprocessableEntity) page.Province = r.FormValue("province")
page.PostalCode = r.FormValue("postal_code")
page.CurrencyCode = r.FormValue("currency")
conn.MustExec(r.Context(), "update company set business_name = $1, vatin = $2, trade_name = $3, phone = parse_packed_phone_number($4, $11), email = $5, web = $6, address = $7, city = $8, province = $9, postal_code = $10, country_code = $11, currency_code = $12 where company_id = $13", page.BusinessName, page.VATIN, page.TradeName, page.Phone, page.Email, page.Web, page.Address, page.City, page.Province, page.PostalCode, page.CountryCode, page.CurrencyCode, company.Id)
http.Redirect(w, r, "/company/"+company.Slug+"/tax-details", http.StatusSeeOther)
} else { } else {
page.DetailsForm.mustFillFromDatabase(r.Context(), conn, company) err := conn.QueryRow(r.Context(), "select business_name, substr(vatin::text, 3), trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code from company where company_id = $1", company.Id).Scan(&page.BusinessName, &page.VATIN, &page.TradeName, &page.Phone, &page.Email, &page.Web, &page.Address, &page.City, &page.Province, &page.PostalCode, &page.CountryCode, &page.CurrencyCode)
if err != nil {
panic(err)
}
} }
page.Countries = mustGetCountryOptions(r.Context(), conn, locale)
page.Currencies = mustGetCurrencyOptions(r.Context(), conn)
page.Taxes = mustGetTaxes(r.Context(), conn, company) page.Taxes = mustGetTaxes(r.Context(), conn, company)
mustRenderAppTemplate(w, r, "tax-details.gohtml", page) mustRenderAppTemplate(w, r, "tax-details.gohtml", page)
}) })
@ -165,16 +143,62 @@ func mustGetCompany(r *http.Request) *Company {
return company return company
} }
func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax { func mustGetCountryOptions(ctx context.Context, conn *Conn, locale *Locale) []CountryOption {
rows, err := conn.Query(ctx, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language)
if err != nil {
panic(err)
}
defer rows.Close()
var countries []CountryOption
for rows.Next() {
var country CountryOption
err = rows.Scan(&country.Code, &country.Name)
if err != nil {
panic(err)
}
countries = append(countries, country)
}
if rows.Err() != nil {
panic(rows.Err())
}
return countries
}
func mustGetCurrencyOptions(ctx context.Context, conn *Conn) []CurrencyOption {
rows, err := conn.Query(ctx, "select currency_code, currency_symbol from currency order by currency_code")
if err != nil {
panic(err)
}
defer rows.Close()
var currencies []CurrencyOption
for rows.Next() {
var currency CurrencyOption
err = rows.Scan(&currency.Code, &currency.Symbol)
if err != nil {
panic(err)
}
currencies = append(currencies, currency)
}
if rows.Err() != nil {
panic(rows.Err())
}
return currencies
}
func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []Tax {
rows, err := conn.Query(ctx, "select tax_id, name, (rate * 100)::integer from tax where company_id = $1 order by rate, name", company.Id) rows, err := conn.Query(ctx, "select tax_id, name, (rate * 100)::integer from tax where company_id = $1 order by rate, name", company.Id)
if err != nil { if err != nil {
panic(err) panic(err)
} }
defer rows.Close() defer rows.Close()
var taxes []*Tax var taxes []Tax
for rows.Next() { for rows.Next() {
tax := &Tax{} var tax Tax
err = rows.Scan(&tax.Id, &tax.Name, &tax.Rate) err = rows.Scan(&tax.Id, &tax.Name, &tax.Rate)
if err != nil { if err != nil {
panic(err) panic(err)
@ -188,52 +212,6 @@ func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
return taxes return taxes
} }
type newTaxForm struct {
locale *Locale
Name *InputField
Rate *InputField
}
func newNewTaxForm(locale *Locale) *newTaxForm {
return &newTaxForm{
locale: locale,
Name: &InputField{
Name: "tax_name",
Label: pgettext("input", "Tax name", locale),
Type: "text",
Required: true,
},
Rate: &InputField{
Name: "tax_rate",
Label: pgettext("input", "Rate (%)", locale),
Type: "number",
Required: true,
Attributes: []template.HTMLAttr{
"min=-99",
"max=99",
},
},
}
}
func (form *newTaxForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Name.FillValue(r)
form.Rate.FillValue(r)
return nil
}
func (form *newTaxForm) Validate() bool {
validator := newFormValidator()
validator.CheckRequiredInput(form.Name, gettext("Tax name can not be empty.", form.locale))
if validator.CheckRequiredInput(form.Rate, gettext("Tax rate can not be empty.", form.locale)) {
validator.CheckValidInteger(form.Rate, -99, 99, gettext("Tax rate must be an integer between -99 and 99.", form.locale))
}
return validator.AllOK()
}
func CompanyTaxHandler() http.Handler { func CompanyTaxHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
param := r.URL.Path param := r.URL.Path
@ -244,26 +222,12 @@ func CompanyTaxHandler() http.Handler {
company := mustGetCompany(r) company := mustGetCompany(r)
if taxId, err := strconv.Atoi(param); err == nil { if taxId, err := strconv.Atoi(param); err == nil {
conn.MustExec(r.Context(), "delete from tax where tax_id = $1", taxId) conn.MustExec(r.Context(), "delete from tax where tax_id = $1", taxId)
http.Redirect(w, r, "/company/"+company.Slug+"/tax-details", http.StatusSeeOther)
} else { } else {
locale := getLocale(r) r.ParseForm()
form := newNewTaxForm(locale) name := r.FormValue("name")
if err := form.Parse(r); err != nil { rate, _ := strconv.Atoi(r.FormValue("rate"))
http.Error(w, err.Error(), http.StatusBadRequest) conn.MustExec(r.Context(), "insert into tax (company_id, name, rate) values ($1, $2, $3 / 100::decimal)", company.Id, name, rate)
return
}
if form.Validate() {
conn.MustExec(r.Context(), "insert into tax (company_id, name, rate) values ($1, $2, $3 / 100::decimal)", company.Id, form.Name, form.Rate.Integer())
http.Redirect(w, r, "/company/"+company.Slug+"/tax-details", http.StatusSeeOther)
} else {
w.WriteHeader(http.StatusUnprocessableEntity)
}
page := &TaxDetailsPage{
DetailsForm: newTaxDetailsForm(r.Context(), conn, locale).mustFillFromDatabase(r.Context(), conn, company),
NewTaxForm: form,
Taxes: mustGetTaxes(r.Context(), conn, company),
}
mustRenderAppTemplate(w, r, "tax-details.gohtml", page)
} }
http.Redirect(w, r, "/company/"+company.Slug+"/tax-details", http.StatusSeeOther)
}) })
} }

View File

@ -2,7 +2,6 @@ package pkg
import ( import (
"context" "context"
"html/template"
"net/http" "net/http"
) )
@ -21,18 +20,22 @@ func ContactsHandler() http.Handler {
conn := getConn(r) conn := getConn(r)
company := getCompany(r) company := getCompany(r)
if r.Method == "POST" { if r.Method == "POST" {
locale := getLocale(r) r.ParseForm()
form := newContactForm(r.Context(), conn, locale) page := &NewContactPage{
if err := form.Parse(r); err != nil { BusinessName: r.FormValue("business_name"),
http.Error(w, err.Error(), http.StatusBadRequest) VATIN: r.FormValue("vatin"),
return TradeName: r.FormValue("trade_name"),
} Phone: r.FormValue("phone"),
if form.Validate(r.Context(), conn) { Email: r.FormValue("email"),
conn.MustExec(r.Context(), "insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, province, city, postal_code, country_code) values ($1, $2, ($12 || $3)::vatin, $4, parse_packed_phone_number($5, $12), $6, $7, $8, $9, $10, $11, $12)", company.Id, form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country) Web: r.FormValue("web"),
http.Redirect(w, r, "/company/"+company.Slug+"/contacts", http.StatusSeeOther) Address: r.FormValue("address"),
} else { City: r.FormValue("city"),
mustRenderAppTemplate(w, r, "contacts-new.gohtml", form) Province: r.FormValue("province"),
PostalCode: r.FormValue("postal_code"),
CountryCode: r.FormValue("country"),
} }
conn.MustExec(r.Context(), "insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, province, city, postal_code, country_code) values ($1, $2, ($12 || $3)::vatin, $4, parse_packed_phone_number($5, $12), $6, $7, $8, $9, $10, $11, $12)", company.Id, page.BusinessName, page.VATIN, page.TradeName, page.Phone, page.Email, page.Web, page.Address, page.City, page.Province, page.PostalCode, page.CountryCode)
http.Redirect(w, r, "/company/"+company.Slug+"/contacts", http.StatusSeeOther)
} else { } else {
page := &ContactsIndexPage{ page := &ContactsIndexPage{
Contacts: mustGetContactEntries(r.Context(), conn, company), Contacts: mustGetContactEntries(r.Context(), conn, company),
@ -65,160 +68,29 @@ func mustGetContactEntries(ctx context.Context, conn *Conn, company *Company) []
return entries return entries
} }
type contactForm struct { type NewContactPage struct {
locale *Locale BusinessName string
BusinessName *InputField VATIN string
VATIN *InputField TradeName string
TradeName *InputField Phone string
Phone *InputField Email string
Email *InputField Web string
Web *InputField Address string
Address *InputField City string
City *InputField Province string
Province *InputField PostalCode string
PostalCode *InputField CountryCode string
Country *SelectField Countries []CountryOption
}
func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactForm {
return &contactForm{
locale: locale,
BusinessName: &InputField{
Name: "business_name",
Label: pgettext("input", "Business name", locale),
Type: "text",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="organization"`,
},
},
VATIN: &InputField{
Name: "vatin",
Label: pgettext("input", "VAT number", locale),
Type: "text",
Required: true,
},
TradeName: &InputField{
Name: "trade_name",
Label: pgettext("input", "Trade name", locale),
Type: "text",
},
Phone: &InputField{
Name: "phone",
Label: pgettext("input", "Phone", locale),
Type: "tel",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="tel"`,
},
},
Email: &InputField{
Name: "email",
Label: pgettext("input", "Email", locale),
Type: "email",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="email"`,
},
},
Web: &InputField{
Name: "web",
Label: pgettext("input", "Web", locale),
Type: "url",
Attributes: []template.HTMLAttr{
`autocomplete="url"`,
},
},
Address: &InputField{
Name: "address",
Label: pgettext("input", "Address", locale),
Type: "text",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="address-line1"`,
},
},
City: &InputField{
Name: "city",
Label: pgettext("input", "City", locale),
Type: "text",
Required: true,
},
Province: &InputField{
Name: "province",
Label: pgettext("input", "Province", locale),
Type: "text",
Required: true,
},
PostalCode: &InputField{
Name: "postal_code",
Label: pgettext("input", "Postal code", locale),
Type: "text",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="postal-code"`,
},
},
Country: &SelectField{
Name: "country",
Label: pgettext("input", "Country", locale),
Options: mustGetCountryOptions(ctx, conn, locale),
Selected: "ES",
Attributes: []template.HTMLAttr{
`autocomplete="country"`,
},
},
}
}
func (form *contactForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.BusinessName.FillValue(r)
form.VATIN.FillValue(r)
form.TradeName.FillValue(r)
form.Phone.FillValue(r)
form.Email.FillValue(r)
form.Web.FillValue(r)
form.Address.FillValue(r)
form.City.FillValue(r)
form.Province.FillValue(r)
form.PostalCode.FillValue(r)
form.Country.FillValue(r)
return nil
}
func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator()
validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale))
if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) {
validator.CheckValidVATINInput(form.VATIN, form.Country.Selected, gettext("This value is not a valid VAT number.", form.locale))
}
if validator.CheckRequiredInput(form.Phone, gettext("Phone can not be empty.", form.locale)) {
validator.CheckValidPhoneInput(form.Phone, form.Country.Selected, gettext("This value is not a valid phone number.", form.locale))
}
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))
}
if form.Web.Val != "" {
validator.checkValidURL(form.Web, gettext("This value is not a valid web address. It should be like https://domain.com/.", form.locale))
}
validator.CheckRequiredInput(form.Address, gettext("Address can not be empty.", form.locale))
validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale))
validator.CheckRequiredInput(form.Province, gettext("Province can not be empty.", form.locale))
if validator.CheckRequiredInput(form.PostalCode, gettext("Postal code can not be empty.", form.locale)) {
validator.CheckValidPostalCode(ctx, conn, form.PostalCode, form.Country.Selected, gettext("This value is not a valid postal code.", form.locale))
}
validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale))
return validator.AllOK()
} }
func NewContactHandler() http.Handler { func NewContactHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
form := newContactForm(r.Context(), conn, locale) page := &NewContactPage{
mustRenderAppTemplate(w, r, "contacts-new.gohtml", form) CountryCode: "ES",
Countries: mustGetCountryOptions(r.Context(), conn, locale),
}
mustRenderAppTemplate(w, r, "contacts-new.gohtml", page)
}) })
} }

View File

@ -2,52 +2,29 @@ package pkg
import ( import (
"context" "context"
"database/sql/driver"
"errors"
"fmt"
"html/template"
"net/http"
"net/mail" "net/mail"
"net/url"
"regexp"
"strconv"
"strings"
) )
type Attribute struct {
Key, Val string
}
type InputField struct { type InputField struct {
Name string Name string
Label string Label string
Type string Type string
Val string Value string
Required bool Required bool
Attributes []template.HTMLAttr Errors []error
Errors []error
} }
func (field *InputField) Scan(value interface{}) error { func (field *InputField) Equals(other *InputField) bool {
if value == nil { return field.Value == other.Value
field.Val = ""
return nil
}
field.Val = fmt.Sprintf("%v", value)
return nil
} }
func (field *InputField) Value() (driver.Value, error) { func (field *InputField) IsEmpty() bool {
return field.Val, nil return field.Value == ""
} }
func (field *InputField) FillValue(r *http.Request) { func (field *InputField) HasValidEmail() bool {
field.Val = strings.TrimSpace(r.FormValue(field.Name)) _, err := mail.ParseAddress(field.Value)
} return err == nil
func (field *InputField) Integer() int {
value, _ := strconv.Atoi(field.Val)
return value
} }
type SelectOption struct { type SelectOption struct {
@ -56,29 +33,11 @@ type SelectOption struct {
} }
type SelectField struct { type SelectField struct {
Name string Name string
Label string Label string
Selected string Selected string
Options []*SelectOption Options []*SelectOption
Attributes []template.HTMLAttr Errors []error
Errors []error
}
func (field *SelectField) Scan(value interface{}) error {
if value == nil {
field.Selected = ""
return nil
}
field.Selected = fmt.Sprintf("%v", value)
return nil
}
func (field *SelectField) Value() (driver.Value, error) {
return field.Selected, nil
}
func (field *SelectField) FillValue(r *http.Request) {
field.Selected = r.FormValue(field.Name)
} }
func (field *SelectField) HasValidOption() bool { func (field *SelectField) HasValidOption() bool {
@ -112,81 +71,3 @@ func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interfa
return options return options
} }
func mustGetCountryOptions(ctx context.Context, conn *Conn, locale *Locale) []*SelectOption {
return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language)
}
type FormValidator struct {
Valid bool
}
func newFormValidator() *FormValidator {
return &FormValidator{true}
}
func (v *FormValidator) AllOK() bool {
return v.Valid
}
func (v *FormValidator) CheckRequiredInput(field *InputField, message string) bool {
return v.checkInput(field, field.Val != "", message)
}
func (v *FormValidator) CheckValidEmailInput(field *InputField, message string) bool {
_, err := mail.ParseAddress(field.Val)
return v.checkInput(field, err == nil, message)
}
func (v *FormValidator) CheckValidVATINInput(field *InputField, country string, message string) bool {
// TODO: actual VATIN validation
return v.checkInput(field, true, message)
}
func (v *FormValidator) CheckValidPhoneInput(field *InputField, country string, message string) bool {
// TODO: actual phone validation
return v.checkInput(field, true, message)
}
func (v *FormValidator) CheckPasswordConfirmation(password *InputField, confirm *InputField, message string) bool {
return v.checkInput(confirm, password.Val == confirm.Val, message)
}
func (v *FormValidator) CheckValidSelectOption(field *SelectField, message string) bool {
return v.checkSelect(field, field.HasValidOption(), message)
}
func (v *FormValidator) checkValidURL(field *InputField, message string) bool {
_, err := url.ParseRequestURI(field.Val)
return v.checkInput(field, err == nil, message)
}
func (v *FormValidator) CheckValidPostalCode(ctx context.Context, conn *Conn, field *InputField, country string, message string) bool {
pattern := "^" + conn.MustGetText(ctx, ".{1,255}", "select postal_code_regex from country where country_code = $1", country) + "$"
match, err := regexp.MatchString(pattern, field.Val)
if err != nil {
panic(err)
}
return v.checkInput(field, match, message)
}
func (v *FormValidator) CheckValidInteger(field *InputField, min int, max int, message string) bool {
value, err := strconv.Atoi(field.Val)
return v.checkInput(field, err == nil && value >= min && value <= max, message)
}
func (v *FormValidator) checkInput(field *InputField, ok bool, message string) bool {
if !ok {
field.Errors = append(field.Errors, errors.New(message))
v.Valid = false
}
return ok
}
func (v *FormValidator) checkSelect(field *SelectField, ok bool, message string) bool {
if !ok {
field.Errors = append(field.Errors, errors.New(message))
v.Valid = false
}
return ok
}

View File

@ -2,8 +2,6 @@ package pkg
import ( import (
"context" "context"
"errors"
"html/template"
"net" "net"
"net/http" "net/http"
"time" "time"
@ -19,56 +17,17 @@ const (
defaultRole = "guest" defaultRole = "guest"
) )
type loginForm struct { type LoginPage struct {
locale *Locale LoginError bool
Errors []error Email string
Email *InputField Password string
Password *InputField
} }
func newLoginForm(locale *Locale) *loginForm { type AppUser struct {
return &loginForm{ Email string
locale: locale, LoggedIn bool
Email: &InputField{ Role string
Name: "email", Language language.Tag
Label: pgettext("input", "Email", locale),
Type: "email",
Required: true,
Attributes: []template.HTMLAttr{
`autofocus="autofocus"`,
`autocomplete="username"`,
`autocapitalize="none"`,
},
},
Password: &InputField{
Name: "password",
Label: pgettext("input", "Password", locale),
Type: "password",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="current-password"`,
},
},
}
}
func (form *loginForm) Parse(r *http.Request) error {
err := r.ParseForm()
if err != nil {
return err
}
form.Email.FillValue(r)
form.Password.FillValue(r)
return nil
}
func (form *loginForm) Validate() bool {
validator := newFormValidator()
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))
}
validator.CheckRequiredInput(form.Password, gettext("Password can not be empty.", form.locale))
return validator.AllOK()
} }
func LoginHandler() http.Handler { func LoginHandler() http.Handler {
@ -78,28 +37,25 @@ func LoginHandler() http.Handler {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
locale := getLocale(r) r.ParseForm()
form := newLoginForm(locale) page := LoginPage{
Email: r.FormValue("email"),
Password: r.FormValue("password"),
}
if r.Method == "POST" { if r.Method == "POST" {
if err := form.Parse(r); err != nil { conn := getConn(r)
http.Error(w, err.Error(), http.StatusBadRequest) cookie := conn.MustGetText(r.Context(), "", "select login($1, $2, $3)", page.Email, page.Password, remoteAddr(r))
if cookie != "" {
setSessionCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
if form.Validate() { w.WriteHeader(http.StatusUnauthorized)
conn := getConn(r) page.LoginError = true
cookie := conn.MustGetText(r.Context(), "", "select login($1, $2, $3)", form.Email, form.Password, remoteAddr(r)) } else {
if cookie != "" { w.WriteHeader(http.StatusOK)
setSessionCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
form.Errors = append(form.Errors, errors.New(gettext("Invalid user or password.", locale)))
w.WriteHeader(http.StatusUnauthorized)
} else {
w.WriteHeader(http.StatusUnprocessableEntity)
}
} }
mustRenderWebTemplate(w, r, "login.gohtml", form) mustRenderWebTemplate(w, r, "login.gohtml", page)
}) })
} }
@ -135,13 +91,6 @@ func createSessionCookie(value string, duration time.Duration) *http.Cookie {
} }
} }
type AppUser struct {
Email string
LoggedIn bool
Role string
Language language.Tag
}
func CheckLogin(db *Db, next http.Handler) http.Handler { func CheckLogin(db *Db, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ctx = r.Context() var ctx = r.Context()

View File

@ -2,8 +2,9 @@ package pkg
import ( import (
"context" "context"
"html/template" "errors"
"net/http" "net/http"
"strings"
) )
type LanguageOption struct { type LanguageOption struct {
@ -12,60 +13,44 @@ type LanguageOption struct {
} }
type profileForm struct { type profileForm struct {
locale *Locale
Name *InputField Name *InputField
Email *InputField Email *InputField
Password *InputField Password *InputField
PasswordConfirm *InputField PasswordConfirm *InputField
Language *SelectField Language *SelectField
Valid bool
} }
func newProfileForm(ctx context.Context, conn *Conn, locale *Locale) *profileForm { func newProfileForm(ctx context.Context, conn *Conn, locale *Locale) *profileForm {
automaticOption := pgettext("language option", "Automatic", locale) automaticOption := pgettext("language option", "Automatic", locale)
languages := MustGetOptions(ctx, conn, "select 'und', $1 union all select lang_tag, endonym from language where selectable", automaticOption) languages := MustGetOptions(ctx, conn, "select 'und', $1 union all select lang_tag, endonym from language where selectable", automaticOption)
return &profileForm{ return &profileForm{
locale: locale,
Name: &InputField{ Name: &InputField{
Name: "name", Name: "name",
Label: pgettext("input", "User name", locale), Label: pgettext("input", "User name", locale),
Type: "text", Type: "text",
Required: true, Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="name"`,
},
}, },
Email: &InputField{ Email: &InputField{
Name: "email", Name: "email",
Label: pgettext("input", "Email", locale), Label: pgettext("input", "Email", locale),
Type: "email", Type: "email",
Required: true, Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="username"`,
},
}, },
Password: &InputField{ Password: &InputField{
Name: "password", Name: "password",
Label: pgettext("input", "Password", locale), Label: pgettext("input", "Password", locale),
Type: "password", Type: "password",
Attributes: []template.HTMLAttr{
`autocomplete="new-password"`,
},
}, },
PasswordConfirm: &InputField{ PasswordConfirm: &InputField{
Name: "password_confirm", Name: "password_confirm",
Label: pgettext("input", "Password Confirmation", locale), Label: pgettext("input", "Password Confirmation", locale),
Type: "password", Type: "password",
Attributes: []template.HTMLAttr{
`autocomplete="new-password"`,
},
}, },
Language: &SelectField{ Language: &SelectField{
Name: "language", Name: "language",
Label: pgettext("input", "Language", locale), Label: pgettext("input", "Language", locale),
Options: languages, Options: languages,
Attributes: []template.HTMLAttr{
`autocomplete="language"`,
},
}, },
} }
} }
@ -74,23 +59,41 @@ func (form *profileForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
return err return err
} }
form.Email.FillValue(r) form.Email.Value = strings.TrimSpace(r.FormValue("email"))
form.Name.FillValue(r) form.Name.Value = strings.TrimSpace(r.FormValue("name"))
form.Password.FillValue(r) form.Password.Value = strings.TrimSpace(r.FormValue("password"))
form.PasswordConfirm.FillValue(r) form.PasswordConfirm.Value = strings.TrimSpace(r.FormValue("password_confirm"))
form.Language.FillValue(r) form.Language.Selected = r.FormValue("language")
return nil return nil
} }
func (form *profileForm) Validate() bool { func (form *profileForm) Validate(locale *Locale) bool {
validator := newFormValidator() form.Valid = true
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) { if form.Email.IsEmpty() {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale)) form.AppendInputError(form.Email, errors.New(gettext("Email can not be empty.", locale)))
} else if !form.Email.HasValidEmail() {
form.AppendInputError(form.Email, errors.New(gettext("This value is not a valid email. It should be like name@domain.com.", locale)))
} }
validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale)) if form.Name.IsEmpty() {
validator.CheckPasswordConfirmation(form.Password, form.PasswordConfirm, gettext("Confirmation does not match password.", form.locale)) form.AppendInputError(form.Name, errors.New(gettext("Name can not be empty.", locale)))
validator.CheckValidSelectOption(form.Language, gettext("Selected language is not valid.", form.locale)) }
return validator.AllOK() if !form.PasswordConfirm.Equals(form.Password) {
form.AppendInputError(form.PasswordConfirm, errors.New(gettext("Confirmation does not match password.", locale)))
}
if !form.Language.HasValidOption() {
form.AppendSelectError(form.Language, errors.New(gettext("Selected language is not valid.", locale)))
}
return form.Valid
}
func (form *profileForm) AppendInputError(field *InputField, err error) {
field.Errors = append(field.Errors, err)
form.Valid = false
}
func (form *profileForm) AppendSelectError(field *SelectField, err error) {
field.Errors = append(field.Errors, err)
form.Valid = false
} }
func ProfileHandler() http.Handler { func ProfileHandler() http.Handler {
@ -104,12 +107,12 @@ func ProfileHandler() http.Handler {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
if ok := form.Validate(); ok { if ok := form.Validate(locale); ok {
//goland:noinspection SqlWithoutWhere //goland:noinspection SqlWithoutWhere
cookie := conn.MustGetText(r.Context(), "", "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", form.Name, form.Email, form.Language) cookie := conn.MustGetText(r.Context(), "", "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", form.Name.Value, form.Email.Value, form.Language.Selected)
setSessionCookie(w, cookie) setSessionCookie(w, cookie)
if form.Password.Val != "" { if form.Password.Value != "" {
conn.MustExec(r.Context(), "select change_password($1)", form.Password) conn.MustExec(r.Context(), "select change_password($1)", form.Password.Value)
} }
company := getCompany(r) company := getCompany(r)
http.Redirect(w, r, "/company/"+company.Slug+"/profile", http.StatusSeeOther) http.Redirect(w, r, "/company/"+company.Slug+"/profile", http.StatusSeeOther)
@ -117,8 +120,8 @@ func ProfileHandler() http.Handler {
} }
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
} else { } else {
form.Name.Val = conn.MustGetText(r.Context(), "", "select name from user_profile") form.Name.Value = conn.MustGetText(r.Context(), "", "select name from user_profile")
form.Email.Val = user.Email form.Email.Value = user.Email
form.Language.Selected = user.Language.String() form.Language.Selected = user.Language.String()
} }
mustRenderAppTemplate(w, r, "profile.gohtml", form) mustRenderAppTemplate(w, r, "profile.gohtml", form)

View File

@ -26,14 +26,6 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
} }
return "/company/" + company.Slug + uri return "/company/" + company.Slug + uri
}, },
"addInputAttr": func(attr string, field *InputField) *InputField {
field.Attributes = append(field.Attributes, template.HTMLAttr(attr))
return field
},
"addSelectAttr": func(attr string, field *SelectField) *SelectField {
field.Attributes = append(field.Attributes, template.HTMLAttr(attr))
return field
},
}) })
if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil { if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil {
panic(err) panic(err)

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-02-01 11:26+0100\n" "POT-Creation-Date: 2023-01-31 15:17+0100\n"
"PO-Revision-Date: 2023-01-18 17:08+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -44,15 +44,30 @@ msgstr "Tauler"
#: web/template/app.gohtml:43 #: web/template/app.gohtml:43
msgctxt "nav" msgctxt "nav"
msgid "Contacts" msgid "Customers"
msgstr "Contactes" msgstr "Clients"
#: web/template/login.gohtml:2 web/template/login.gohtml:15 #: web/template/login.gohtml:2 web/template/login.gohtml:13
msgctxt "title" msgctxt "title"
msgid "Login" msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/template/login.gohtml:19 #: web/template/login.gohtml:9
msgid "Invalid user or password"
msgstr "Nom dusuari o contrasenya incorrectes"
#: web/template/login.gohtml:17 web/template/tax-details.gohtml:27
#: web/template/contacts-new.gohtml:27 pkg/profile.go:37
msgctxt "input"
msgid "Email"
msgstr "Correu-e"
#: web/template/login.gohtml:22 pkg/profile.go:43
msgctxt "input"
msgid "Password"
msgstr "Contrasenya"
#: web/template/login.gohtml:25
msgctxt "action" msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entra" msgstr "Entra"
@ -84,8 +99,8 @@ msgstr "Desa canvis"
#: web/template/contacts-index.gohtml:2 #: web/template/contacts-index.gohtml:2
msgctxt "title" msgctxt "title"
msgid "Contacts" msgid "Customers"
msgstr "Contactes" msgstr "Clients"
#: web/template/contacts-index.gohtml:6 web/template/contacts-new.gohtml:60 #: web/template/contacts-index.gohtml:6 web/template/contacts-new.gohtml:60
msgctxt "action" msgctxt "action"
@ -113,8 +128,8 @@ msgid "Phone"
msgstr "Telèfon" msgstr "Telèfon"
#: web/template/contacts-index.gohtml:29 #: web/template/contacts-index.gohtml:29
msgid "No contacts added yet." msgid "No customers added yet."
msgstr "No hi ha cap contacte." msgstr "No hi ha cap client."
#: web/template/tax-details.gohtml:2 web/template/tax-details.gohtml:7 #: web/template/tax-details.gohtml:2 web/template/tax-details.gohtml:7
msgctxt "title" msgctxt "title"
@ -141,12 +156,6 @@ msgctxt "input"
msgid "Phone" msgid "Phone"
msgstr "Telèfon" msgstr "Telèfon"
#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27
#: pkg/login.go:33 pkg/profile.go:35
msgctxt "input"
msgid "Email"
msgstr "Correu-e"
#: web/template/tax-details.gohtml:31 web/template/contacts-new.gohtml:31 #: web/template/tax-details.gohtml:31 web/template/contacts-new.gohtml:31
msgctxt "input" msgctxt "input"
msgid "Web" msgid "Web"
@ -221,66 +230,42 @@ msgctxt "title"
msgid "New Contact" msgid "New Contact"
msgstr "Nou contacte" msgstr "Nou contacte"
#: pkg/login.go:44 pkg/profile.go:41 #: pkg/profile.go:26
msgctxt "input"
msgid "Password"
msgstr "Contrasenya"
#: pkg/login.go:66 pkg/profile.go:71
msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc."
#: pkg/login.go:67 pkg/profile.go:72
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Aquest valor no és un correu-e vàlid. Hauria de ser similar a nom@domini.cat."
#: pkg/login.go:69
msgid "Password can not be empty."
msgstr "No podeu deixar la contrasenya en blanc."
#: pkg/login.go:95
msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/profile.go:23
msgctxt "language option" msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automàtic" msgstr "Automàtic"
#: pkg/profile.go:29 #: pkg/profile.go:31
msgctxt "input" msgctxt "input"
msgid "User name" msgid "User name"
msgstr "Nom dusuari" msgstr "Nom dusuari"
#: pkg/profile.go:46 #: pkg/profile.go:48
msgctxt "input" msgctxt "input"
msgid "Password Confirmation" msgid "Password Confirmation"
msgstr "Confirmació contrasenya" msgstr "Confirmació contrasenya"
#: pkg/profile.go:51 #: pkg/profile.go:53
msgctxt "input" msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: pkg/profile.go:74 #: pkg/profile.go:74
msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc."
#: pkg/profile.go:76
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Aquest valor no és un correu-e vàlid. Hauria de ser similar a nom@domini.cat."
#: pkg/profile.go:79
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc." msgstr "No podeu deixar el nom en blanc."
#: pkg/profile.go:75 #: pkg/profile.go:82
msgid "Confirmation does not match password." msgid "Confirmation does not match password."
msgstr "La confirmació no és igual a la contrasenya." msgstr "La confirmació no és igual a la contrasenya."
#: pkg/profile.go:76 #: pkg/profile.go:85
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Heu seleccionat un idioma que no és vàlid." msgstr "Heu seleccionat un idioma que no és vàlid."
#~ msgctxt "nav"
#~ msgid "Customers"
#~ msgstr "Clients"
#~ msgctxt "title"
#~ msgid "Customers"
#~ msgstr "Clients"
#~ msgid "No customers added yet."
#~ msgstr "No hi ha cap client."

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-02-01 11:26+0100\n" "POT-Creation-Date: 2023-01-31 15:17+0100\n"
"PO-Revision-Date: 2023-01-18 17:45+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -44,15 +44,30 @@ msgstr "Panel"
#: web/template/app.gohtml:43 #: web/template/app.gohtml:43
msgctxt "nav" msgctxt "nav"
msgid "Contacts" msgid "Customers"
msgstr "Contactos" msgstr "Clientes"
#: web/template/login.gohtml:2 web/template/login.gohtml:15 #: web/template/login.gohtml:2 web/template/login.gohtml:13
msgctxt "title" msgctxt "title"
msgid "Login" msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/template/login.gohtml:19 #: web/template/login.gohtml:9
msgid "Invalid user or password"
msgstr "Nombre de usuario o contraseña inválido"
#: web/template/login.gohtml:17 web/template/tax-details.gohtml:27
#: web/template/contacts-new.gohtml:27 pkg/profile.go:37
msgctxt "input"
msgid "Email"
msgstr "Correo-e"
#: web/template/login.gohtml:22 pkg/profile.go:43
msgctxt "input"
msgid "Password"
msgstr "Contraseña"
#: web/template/login.gohtml:25
msgctxt "action" msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entrar" msgstr "Entrar"
@ -84,8 +99,8 @@ msgstr "Guardar cambios"
#: web/template/contacts-index.gohtml:2 #: web/template/contacts-index.gohtml:2
msgctxt "title" msgctxt "title"
msgid "Contacts" msgid "Customers"
msgstr "Contactos" msgstr "Clientes"
#: web/template/contacts-index.gohtml:6 web/template/contacts-new.gohtml:60 #: web/template/contacts-index.gohtml:6 web/template/contacts-new.gohtml:60
msgctxt "action" msgctxt "action"
@ -113,8 +128,8 @@ msgid "Phone"
msgstr "Teléfono" msgstr "Teléfono"
#: web/template/contacts-index.gohtml:29 #: web/template/contacts-index.gohtml:29
msgid "No contacts added yet." msgid "No customers added yet."
msgstr "No hay contactos." msgstr "No hay clientes."
#: web/template/tax-details.gohtml:2 web/template/tax-details.gohtml:7 #: web/template/tax-details.gohtml:2 web/template/tax-details.gohtml:7
msgctxt "title" msgctxt "title"
@ -141,12 +156,6 @@ msgctxt "input"
msgid "Phone" msgid "Phone"
msgstr "Teléfono" msgstr "Teléfono"
#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27
#: pkg/login.go:33 pkg/profile.go:35
msgctxt "input"
msgid "Email"
msgstr "Correo-e"
#: web/template/tax-details.gohtml:31 web/template/contacts-new.gohtml:31 #: web/template/tax-details.gohtml:31 web/template/contacts-new.gohtml:31
msgctxt "input" msgctxt "input"
msgid "Web" msgid "Web"
@ -221,66 +230,42 @@ msgctxt "title"
msgid "New Contact" msgid "New Contact"
msgstr "Nuevo contacto" msgstr "Nuevo contacto"
#: pkg/login.go:44 pkg/profile.go:41 #: pkg/profile.go:26
msgctxt "input"
msgid "Password"
msgstr "Contraseña"
#: pkg/login.go:66 pkg/profile.go:71
msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco."
#: pkg/login.go:67 pkg/profile.go:72
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Este valor no es un correo-e válido. Tiene que ser parecido a nombre@dominio.es."
#: pkg/login.go:69
msgid "Password can not be empty."
msgstr "No podéis dejar la contaseña en blanco."
#: pkg/login.go:95
msgid "Invalid user or password."
msgstr "Nombre de usuario o contraseña inválido."
#: pkg/profile.go:23
msgctxt "language option" msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automático" msgstr "Automático"
#: pkg/profile.go:29 #: pkg/profile.go:31
msgctxt "input" msgctxt "input"
msgid "User name" msgid "User name"
msgstr "Nombre de usuario" msgstr "Nombre de usuario"
#: pkg/profile.go:46 #: pkg/profile.go:48
msgctxt "input" msgctxt "input"
msgid "Password Confirmation" msgid "Password Confirmation"
msgstr "Confirmación contrasenya" msgstr "Confirmación contrasenya"
#: pkg/profile.go:51 #: pkg/profile.go:53
msgctxt "input" msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: pkg/profile.go:74 #: pkg/profile.go:74
msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco."
#: pkg/profile.go:76
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Este valor no es un correo-e válido. Tiene que ser parecido a nombre@dominio.es."
#: pkg/profile.go:79
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco." msgstr "No podéis dejar el nombre en blanco."
#: pkg/profile.go:75 #: pkg/profile.go:82
msgid "Confirmation does not match password." msgid "Confirmation does not match password."
msgstr "La confirmación no corresponde con la contraseña." msgstr "La confirmación no corresponde con la contraseña."
#: pkg/profile.go:76 #: pkg/profile.go:85
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Habéis escogido un idioma que no es válido." msgstr "Habéis escogido un idioma que no es válido."
#~ msgctxt "nav"
#~ msgid "Customers"
#~ msgstr "Clientes"
#~ msgctxt "title"
#~ msgid "Customers"
#~ msgstr "Clientes"
#~ msgid "No customers added yet."
#~ msgstr "No hay clientes."

View File

@ -40,7 +40,7 @@
<nav aria-label="{{( pgettext "Main" "title" )}}"> <nav aria-label="{{( pgettext "Main" "title" )}}">
<ul> <ul>
<li><a href="{{ companyURI "/" }}">{{( pgettext "Dashboard" "nav" )}}</a></li> <li><a href="{{ companyURI "/" }}">{{( pgettext "Dashboard" "nav" )}}</a></li>
<li><a href="{{ companyURI "/contacts" }}">{{( pgettext "Contacts" "nav" )}}</a></li> <li><a href="{{ companyURI "/contacts" }}">{{( pgettext "Customers" "nav" )}}</a></li>
</ul> </ul>
</nav> </nav>
<main> <main>

View File

@ -1,5 +1,5 @@
{{ define "title" -}} {{ define "title" -}}
{{( pgettext "Contacts" "title" )}} {{( pgettext "Customers" "title" )}}
{{- end }} {{- end }}
{{ define "content" }} {{ define "content" }}
@ -26,7 +26,7 @@
{{- end }} {{- end }}
{{ else }} {{ else }}
<tr> <tr>
<td colspan="4">{{( gettext "No contacts added yet." )}}</td> <td colspan="4">{{( gettext "No customers added yet." )}}</td>
</tr> </tr>
{{ end }} {{ end }}
</tbody> </tbody>

View File

@ -1,27 +1,65 @@
{{ define "title" -}} {{ define "title" -}}
{{( pgettext "New Contact" "title" )}} {{( pgettext "New Contact" "title" )}}
{{- end }} {{- end }}
{{ define "content" }} {{ define "content" }}
<section class="dialog-content"> <section class="dialog-content">
<h2>{{(pgettext "New Contact" "title")}}</h2> <h2>{{(pgettext "New Contact" "title")}}</h2>
<form method="POST" action="{{ companyURI "/contacts" }}"> <form method="POST" action="{{ companyURI "/contacts" }}">
{{ template "input-field" .BusinessName | addInputAttr "autofocus" }} <div class="input">
{{ template "input-field" .VATIN }} <input type="text" name="business_name" id="business_name" required="required" autofocus value="{{ .BusinessName }}" placeholder="{{( pgettext "Business name" "input" )}}">
{{ template "input-field" .TradeName }} <label for="business_name">{{( pgettext "Business name" "input" )}}</label>
{{ template "input-field" .Phone }} </div>
{{ template "input-field" .Email }} <div class="input">
{{ template "input-field" .Web }} <input type="text" name="vatin" id="vatin" required="required" value="{{ .VATIN }}" placeholder="{{( pgettext "VAT number" "input" )}}">
{{ template "input-field" .Address | addInputAttr `class="width-2x"` }} <label for="vatin">{{( pgettext "VAT number" "input" )}}</label>
{{ template "input-field" .City }} </div>
{{ template "input-field" .Province }} <div class="input">
{{ template "input-field" .PostalCode }} <input type="text" name="trade_name" id="trade_name" value="{{ .TradeName }}" placeholder="{{( pgettext "Trade name" "input" )}}">
{{ template "select-field" .Country | addSelectAttr `class="width-fixed"` }} <label for="trade_name">{{( pgettext "Trade name" "input" )}}</label>
</div>
<div class="input">
<input type="tel" name="phone" id="phone" required="required" value="{{ .Phone }}" placeholder="{{( pgettext "Phone" "input" )}}">
<label for="phone">{{( pgettext "Phone" "input" )}}</label>
</div>
<div class="input">
<input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
<label for="email">{{( pgettext "Email" "input" )}}</label>
</div>
<div class="input">
<input type="url" name="web" id="web" value="{{ .Web }}" placeholder="{{( pgettext "Web" "input" )}}">
<label for="web">{{( pgettext "Web" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="address" id="address" class="width-2x" required="required" value="{{ .Address }}" placeholder="{{( pgettext "Address" "input" )}}">
<label for="address">{{( pgettext "Address" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="city" id="city" required="required" value="{{ .City }}" placeholder="{{( pgettext "City" "input" )}}">
<label for="city">{{( pgettext "City" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="province" id="province" required="required" value="{{ .City }}" placeholder="{{( pgettext "Province" "input" )}}">
<label for="province">{{( pgettext "Province" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="postal_code" id="postal_code" required="required" value="{{ .PostalCode }}" placeholder="{{( pgettext "Postal code" "input" )}}">
<label for="postal_code">{{( pgettext "Postal code" "input" )}}</label>
</div>
<fieldset> <div class="input">
<button type="submit">{{( pgettext "New contact" "action" )}}</button> <select id="country" name="country" class="width-fixed">
</fieldset> {{- range $country := .Countries }}
<option value="{{ .Code }}" {{ if eq .Code $.CountryCode }}selected="selected"{{ end }}>{{ .Name }}</option>
{{- end }}
</select>
<label for="country">{{( pgettext "Country" "input" )}}</label>
</div>
</form> <fieldset>
</section> <button type="submit">{{( pgettext "New contact" "action" )}}</button>
</fieldset>
</form>
</section>
{{- end }} {{- end }}

View File

@ -1,36 +1,33 @@
{{ define "input-field" -}} {{ define "input-field" -}}
<div class="input {{ if .Errors }}has-errors{{ end }}"> <div class="input {{ if .Errors }}has-errors{{ end }}">
<input type="{{ .Type }}" name="{{ .Name }}" id="{{ .Name }}-field" <input type="{{ .Type }}" name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }} {{ if .Required }}required="required"{{ end }} value="{{ .Value }}" placeholder="{{ .Label }}">
{{ if .Required }}required="required"{{ end }} value="{{ .Val }}" placeholder="{{ .Label }}">
<label for="{{ .Name }}-field">{{ .Label }}</label> <label for="{{ .Name }}-field">{{ .Label }}</label>
{{- if .Errors }} {{ if .Errors }}
<ul> <ul>
{{- range $error := .Errors }} {{- range $error := .Errors }}
<li>{{ . }}</li> <li>{{ . }}</li>
{{- end }} {{- end }}
</ul> </ul>
{{- end }} {{ end }}
</div> </div>
{{- end }} {{- end }}
{{ define "select-field" -}} {{ define "select-field" -}}
<div class="input {{ if .Errors }}has-errors{{ end }}"> <div class="input {{ if .Errors }}has-errors{{ end }}">
<select id="{{ .Name }}-field" name="{{ .Name }}" <select id="{{ .Name }}-field" name="{{ .Name }}">
{{- range $attribute := .Attributes }} {{$attribute}} {{ end -}} {{- range $option := .Options }}
> <option value="{{ .Value }}"
{{- range $option := .Options }} {{ if eq .Value $.Selected }}selected="selected"{{ end }}>{{ .Label }}</option>
<option value="{{ .Value }}" {{- end }}
{{- if eq .Value $.Selected }} selected="selected"{{ end }}>{{ .Label }}</option>
{{- end }}
</select> </select>
<label for="{{ .Name }}-field">{{ .Label }}</label> <label for="{{ .Name }}-field">{{ .Label }}</label>
{{- if .Errors }} {{ if .Errors }}
<ul> <ul>
{{- range $error := .Errors }} {{- range $error := .Errors }}
<li>{{ . }}</li> <li>{{ . }}</li>
{{- end }} {{- end }}
</ul> </ul>
{{- end }} {{ end }}
</div> </div>
{{- end }} {{- end }}

View File

@ -1,22 +1,28 @@
{{ define "title" -}} {{ define "title" -}}
{{( pgettext "Login" "title" )}} {{( pgettext "Login" "title" )}}
{{- end }} {{- end }}
{{ define "content" }} {{ define "content" }}
<h1><img src="/static/numerus.svg" alt="Numerus" width="620" height="77"></h1> <h1><img src="/static/numerus.svg" alt="Numerus" width="620" height="77"></h1>
{{ if .Errors -}} {{ if .LoginError -}}
<div class="error" role="alert"> <div class="error" role="alert">
{{ range $error := .Errors -}} <p>{{( gettext "Invalid user or password" )}}</p>
<p>{{ $error }}</p> </div>
{{- end }} {{- end }}
</div> <section id="login">
{{- end }} <h2>{{( pgettext "Login" "title" )}}</h2>
<section id="login"> <form method="POST" action="/login">
<h2>{{( pgettext "Login" "title" )}}</h2> <div class="input">
<form method="POST" action="/login"> <input id="user_email" type="email" required autofocus name="email" autocapitalize="none" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
{{ template "input-field" .Email }} <label for="user_email">{{( pgettext "Email" "input" )}}</label>
{{ template "input-field" .Password }} </div>
<button type="submit">{{( pgettext "Login" "action" )}}</button>
</form> <div class="input">
</section> <input id="user_password" type="password" required name="password" autocomplete="current-password" value="{{ .Password }}" placeholder="{{( pgettext "Password" "input" )}}">
<label for="user_password">{{( pgettext "Password" "input" )}}</label>
</div>
<button type="submit">{{( pgettext "Login" "action" )}}</button>
</form>
</section>
{{- end }} {{- end }}

View File

@ -1,89 +1,136 @@
{{ define "title" -}} {{ define "title" -}}
{{( pgettext "Tax Details" "title" )}} {{( pgettext "Tax Details" "title" )}}
{{- end }} {{- end }}
{{ define "content" }} {{ define "content" }}
<section class="dialog-content"> <section class="dialog-content">
<h2>{{(pgettext "Tax Details" "title")}}</h2> <h2>{{(pgettext "Tax Details" "title")}}</h2>
{{ with .DetailsForm }} <form id="details" method="POST">
<form id="details" method="POST"> <div class="input">
{{ template "input-field" .BusinessName }} <input type="text" name="business_name" id="business_name" required="required" value="{{ .BusinessName }}" placeholder="{{( pgettext "Business name" "input" )}}">
{{ template "input-field" .VATIN }} <label for="business_name">{{( pgettext "Business name" "input" )}}</label>
{{ template "input-field" .TradeName }} </div>
{{ template "input-field" .Phone }} <div class="input">
{{ template "input-field" .Email }} <input type="text" name="vatin" id="vatin" required="required" value="{{ .VATIN }}" placeholder="{{( pgettext "VAT number" "input" )}}">
{{ template "input-field" .Web }} <label for="vatin">{{( pgettext "VAT number" "input" )}}</label>
{{ template "input-field" .Address | addInputAttr `class="width-2x"` }} </div>
{{ template "input-field" .City }} <div class="input">
{{ template "input-field" .Province }} <input type="text" name="trade_name" id="trade_name" value="{{ .TradeName }}" placeholder="{{( pgettext "Trade name" "input" )}}">
{{ template "input-field" .PostalCode }} <label for="trade_name">{{( pgettext "Trade name" "input" )}}</label>
{{ template "select-field" .Country | addSelectAttr `class="width-fixed"` }} </div>
<div class="input">
<input type="tel" name="phone" id="phone" required="required" value="{{ .Phone }}" placeholder="{{( pgettext "Phone" "input" )}}">
<label for="phone">{{( pgettext "Phone" "input" )}}</label>
</div>
<div class="input">
<input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
<label for="email">{{( pgettext "Email" "input" )}}</label>
</div>
<div class="input">
<input type="url" name="web" id="web" value="{{ .Web }}" placeholder="{{( pgettext "Web" "input" )}}">
<label for="web">{{( pgettext "Web" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="address" id="address" class="width-2x" required="required" value="{{ .Address }}" placeholder="{{( pgettext "Address" "input" )}}">
<label for="address">{{( pgettext "Address" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="city" id="city" required="required" value="{{ .City }}" placeholder="{{( pgettext "City" "input" )}}">
<label for="city">{{( pgettext "City" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="province" id="province" required="required" value="{{ .City }}" placeholder="{{( pgettext "Province" "input" )}}">
<label for="province">{{( pgettext "Province" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="postal_code" id="postal_code" required="required" value="{{ .PostalCode }}" placeholder="{{( pgettext "Postal code" "input" )}}">
<label for="postal_code">{{( pgettext "Postal code" "input" )}}</label>
</div>
<fieldset> <div class="input">
<legend id="currency-legend">{{( pgettext "Currency" "title" )}}</legend> <select id="country" name="country" class="width-fixed">
{{- range $country := .Countries }}
<option value="{{ .Code }}" {{ if eq .Code $.CountryCode }}selected="selected"{{ end }}>{{ .Name }}</option>
{{- end }}
</select>
<label for="country">{{( pgettext "Country" "input" )}}</label>
</div>
{{ template "select-field" .Currency }} <fieldset>
</fieldset> <legend id="currency-legend">{{( pgettext "Currency" "input" )}}</legend>
</form>
{{ end }}
<form id="newtax" method="POST" action="{{ companyURI "/tax" }}"> <select id="currency" name="currency" aria-labelledby="currency-legend">
</form> {{- range $currency := .Currencies }}
<option value="{{ .Code }}" {{ if eq .Code $.CurrencyCode }}selected="selected"{{ end }}>{{ .Symbol }} ({{ .Code }})</option>
{{- end }}
</select>
</fieldset>
</form>
<fieldset> <form id="newtax" method="POST" action="{{ companyURI "/tax" }}">
<table> </form>
<thead>
<tr>
<th width="50%"></th>
<th>{{( pgettext "Tax Name" "title" )}}</th>
<th>{{( pgettext "Rate (%)" "title" )}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{ with .Taxes }}
{{- range $tax := . }}
<tr>
<td></td>
<td>{{ .Name }}</td>
<td>{{ .Rate }}</td>
<td>
<form method="POST" action="{{ companyURI "/tax"}}/{{ .Id }}">
<input type="hidden" name="_method" value="DELETE"/>
<button class="icon" aria-label="{{( gettext "Delete tax" )}}" type="submit"><i
class="ri-delete-back-2-line"></i></button>
</form>
</td>
</tr>
{{- end }}
{{ else }}
<tr>
<td colspan="4">{{( gettext "No taxes added yet." )}}</td>
</tr>
{{ end }}
</tbody>
<tfoot>
<tr>
<th scope="row">{{( pgettext "New Line" "title")}}</th>
<td>
{{ template "input-field" .NewTaxForm.Name | addInputAttr `form="newtax"` }}
</td>
<td colspan="2">
{{ template "input-field" .NewTaxForm.Rate | addInputAttr `form="newtax"` }}
</td>
</tr>
<tr>
<td colspan="2"></td>
<td colspan="2">
<button form="newtax" type="submit">{{( pgettext "Add new tax" "action" )}}</button>
</td>
</tr>
</tfoot>
</table>
</fieldset>
<fieldset> <fieldset>
<button form="details" type="submit">{{( pgettext "Save changes" "action" )}}</button> <table>
</fieldset> <thead>
</section> <tr>
<th width="50%"></th>
<th>{{( pgettext "Tax Name" "title" )}}</th>
<th>{{( pgettext "Rate (%)" "title" )}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{ with .Taxes }}
{{- range $tax := . }}
<tr>
<td></td>
<td>{{ .Name }}</td>
<td>{{ .Rate }}</td>
<td>
<form method="POST" action="{{ companyURI "/tax"}}/{{ .Id }}">
<input type="hidden" name="_method" value="DELETE"/>
<button class="icon" aria-label="{{( gettext "Delete tax" )}}" type="submit"><i class="ri-delete-back-2-line"></i></button>
</form>
</td>
</tr>
{{- end }}
{{ else }}
<tr>
<td colspan="4">{{( gettext "No taxes added yet." )}}</td>
</tr>
{{ end }}
</tbody>
<tfoot>
<tr>
<th scope="row">{{( pgettext "New Line" "title")}}</th>
<td>
<div class="input">
<input form="newtax" type="text" name="name" id="tax_name" required="required"
placeholder="{{( pgettext "Tax name" "input" )}}">
<label for="tax_name">{{( pgettext "Tax name" "input" )}}</label>
</div>
</td>
<td colspan="2">
<div class="input">
<input form="newtax" type="number" name="rate" id="tax_rate" min="-99" max="99"
required="required" placeholder="{{( pgettext "Rate (%)" "input" )}}">
<label for="tax_rate">{{( pgettext "Rate (%)" "input" )}}</label>
</div>
</td>
</tr>
<tr>
<td colspan="2"></td>
<td colspan="2">
<button form="newtax" type="submit">{{( pgettext "Add new tax" "action" )}}</button>
</td>
</tr>
</tfoot>
</table>
</fieldset>
<fieldset>
<button form="details" type="submit">{{( pgettext "Save changes" "action" )}}</button>
</fieldset>
</section>
{{- end }} {{- end }}