first drafts for inventory sync page

This commit is contained in:
2026-01-09 18:03:54 -07:00
parent 846f56e50b
commit a7c8fe4d64
40 changed files with 1310 additions and 121 deletions
+119 -35
View File
@@ -1,5 +1,8 @@
package accounts
// to generate StoreWithContext
//go:generate concurry -s Store
import (
"context"
"errors"
@@ -15,20 +18,51 @@ type (
db *pgxpool.Pool
}
StoreWithContext struct {
ctx context.Context
db *Store
}
Account struct {
UserID string
ID int64
Email string
AccountIDs
OAuthUser
Email string
}
OAuthUser struct {
UserID string
}
AccountShop struct {
AccountShopIDs
Platform Platform
Name string
}
Listing struct {
AccountIDs
Platform Platform
ShopID string
ListingID int64
SKU string
Name string
Description string
Count int64
}
// ids
AccountIDs struct {
AccountID int64
}
AccountShopIDs struct {
AccountIDs
ShopID string
}
Platform string
)
const (
Etsy Platform = "Etsy"
Tiktok Platform = "Tiktok"
Wix Platform = "Wix"
)
func NewStore(db *pgxpool.Pool) *Store {
@@ -38,10 +72,7 @@ func NewStore(db *pgxpool.Pool) *Store {
}
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
db: db,
}
return NewStoreWithContext(ctx, db)
}
func (db *Store) CreateAccount(ctx context.Context, userID, email string) (Account, error) {
@@ -78,16 +109,20 @@ func (db *Store) CreateAccount(ctx context.Context, userID, email string) (Accou
}
return Account{
UserID: userID,
ID: acctID,
Email: email,
OAuthUser: OAuthUser{
UserID: userID,
},
AccountIDs: AccountIDs{
AccountID: acctID,
},
Email: email,
}, nil
}
func (db *Store) GetAccount(ctx context.Context, id int64) (Account, error) {
rows, err := db.db.Query(
ctx,
"SELECT email FROM accounts WHERE account_id = @account_id",
"SELECT email, user_id FROM accounts WHERE account_id = @account_id",
pgx.NamedArgs{
"account_id": id,
},
@@ -96,7 +131,12 @@ func (db *Store) GetAccount(ctx context.Context, id int64) (Account, error) {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
email, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[string])
type Row struct {
Email string
User_id string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
@@ -106,8 +146,13 @@ func (db *Store) GetAccount(ctx context.Context, id int64) (Account, error) {
}
return Account{
ID: id,
Email: email,
AccountIDs: AccountIDs{
AccountID: id,
},
OAuthUser: OAuthUser{
r.User_id,
},
Email: r.Email,
}, nil
}
@@ -143,16 +188,20 @@ func (db *Store) GetAccountByUserID(ctx context.Context, userID string) (Account
}
return Account{
UserID: userID,
ID: r.Account_ID,
Email: r.Email,
AccountIDs: AccountIDs{
AccountID: r.Account_ID,
},
OAuthUser: OAuthUser{
UserID: userID,
},
Email: r.Email,
}, nil
}
func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account, error) {
rows, err := db.db.Query(
ctx,
"SELECT account_id FROM accounts WHERE email = @email",
"SELECT account_id, user_id FROM accounts WHERE email = @email",
pgx.NamedArgs{
"email": email,
},
@@ -161,7 +210,12 @@ func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account,
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
id, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
type Row struct {
Account_id int64
User_id string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
@@ -171,7 +225,12 @@ func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account,
}
return Account{
ID: id,
AccountIDs: AccountIDs{
AccountID: r.Account_id,
},
OAuthUser: OAuthUser{
UserID: r.User_id,
},
Email: email,
}, nil
}
@@ -213,28 +272,53 @@ func (db *Store) GetUserAndAccountByAccessToken(ctx context.Context, accessToken
return OAuthUser{}, nil, fmt.Errorf("failed to scan row: %w", err)
}
user := OAuthUser{UserID: r.User_ID}
var acct *Account
if r.Account_ID != nil {
acct = &Account{
UserID: r.User_ID,
ID: *r.Account_ID,
Email: *r.Email,
AccountIDs: AccountIDs{
AccountID: *r.Account_ID,
},
OAuthUser: user,
Email: *r.Email,
}
}
return OAuthUser{UserID: r.User_ID}, acct, nil
return user, acct, nil
}
func (db *StoreWithContext) CreateAccount(userID, email string) (Account, error) {
return db.db.CreateAccount(db.ctx, userID, email)
func (db *Store) GetShops(ctx context.Context, acctID int64) ([]AccountShop, error) {
var shops []AccountShop
for _, v := range devShops {
if v.AccountID != acctID {
continue
}
shops = append(shops, v)
}
return shops, nil
}
func (db *StoreWithContext) GetAccount(id int64) (Account, error) {
return db.db.GetAccount(db.ctx, id)
func (db *Store) GetListingsForShop(ctx context.Context, acctID int64, shopID string) ([]Listing, error) {
var vs []Listing
for _, v := range devListings {
if v.AccountID != acctID {
continue
}
if v.ShopID != shopID {
continue
}
vs = append(vs, v)
}
return vs, nil
}
func (db *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
acct, err := db.db.GetAccountByUserID(db.ctx, userID)
// additional context
func (db *Store) GetAccountPointerByUserID(ctx context.Context, userID string) (*Account, error) {
acct, err := db.GetAccountByUserID(ctx, userID)
if err == nil {
return &acct, nil
}
+243
View File
@@ -0,0 +1,243 @@
package accounts
var (
devShops = []AccountShop{
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: 6,
},
ShopID: "2",
},
Platform: Etsy,
Name: "Etsy 1",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: 6,
},
ShopID: "5",
},
Platform: Etsy,
Name: "Etsy 2",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: 6,
},
ShopID: "8",
},
Platform: Etsy,
Name: "Etsy 3",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: 6,
},
ShopID: "11",
},
Platform: Tiktok,
Name: "Tiktok 1",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: 6,
},
ShopID: "14",
},
Platform: Tiktok,
Name: "Tiktok 2",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: 6,
},
ShopID: "17",
},
Platform: Tiktok,
Name: "Tiktok 3",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: 6,
},
ShopID: "20",
},
Platform: Wix,
Name: "Wix 1",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: 6,
},
ShopID: "23",
},
Platform: Wix,
Name: "Wix 2",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: 6,
},
ShopID: "26",
},
Platform: Wix,
Name: "Wix 3",
},
}
devListings = []Listing{
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Etsy,
ShopID: "1",
ListingID: 1,
SKU: "sku 1",
Name: "name 1",
Description: "description 1",
Count: 51,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Etsy,
ShopID: "1",
ListingID: 2,
SKU: "sku 2",
Name: "name 2",
Description: "description 2",
Count: 52,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Etsy,
ShopID: "1",
ListingID: 3,
SKU: "sku 3",
Name: "name 3",
Description: "description 3",
Count: 53,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Tiktok,
ShopID: "4",
ListingID: 4,
SKU: "sku 4",
Name: "name 4",
Description: "description 4",
Count: 54,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Tiktok,
ShopID: "4",
ListingID: 5,
SKU: "sku 5",
Name: "name 5",
Description: "description 5",
Count: 55,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Tiktok,
ShopID: "4",
ListingID: 6,
SKU: "sku 6",
Name: "name 6",
Description: "description 6",
Count: 56,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Wix,
ShopID: "7",
ListingID: 7,
SKU: "sku 7",
Name: "name 7",
Description: "description 7",
Count: 57,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Wix,
ShopID: "7",
ListingID: 8,
SKU: "sku 8",
Name: "name 8",
Description: "description 8",
Count: 58,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Wix,
ShopID: "7",
ListingID: 9,
SKU: "sku 9",
Name: "name 9",
Description: "description 9",
Count: 59,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Etsy,
ShopID: "2",
ListingID: 10,
SKU: "sku 10",
Name: "name 10",
Description: "description 10",
Count: 60,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Etsy,
ShopID: "2",
ListingID: 11,
SKU: "sku 11",
Name: "name 11",
Description: "description 11",
Count: 61,
},
{
AccountIDs: AccountIDs{
AccountID: 6,
},
Platform: Etsy,
ShopID: "2",
ListingID: 12,
SKU: "sku 12",
Name: "name 12",
Description: "description 12",
Count: 62,
},
}
)
@@ -0,0 +1,52 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package accounts
import (
"context"
)
type StoreWithContext struct {
ctx context.Context
*Store
}
func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
Store: v,
}
}
func (v_ctx *StoreWithContext) CreateAccount(userID string, email string) (Account, error) {
return v_ctx.Store.CreateAccount(v_ctx.ctx, userID, email)
}
func (v_ctx *StoreWithContext) GetAccount(id int64) (Account, error) {
return v_ctx.Store.GetAccount(v_ctx.ctx, id)
}
func (v_ctx *StoreWithContext) GetAccountByUserID(userID string) (Account, error) {
return v_ctx.Store.GetAccountByUserID(v_ctx.ctx, userID)
}
func (v_ctx *StoreWithContext) GetAccountByEmail(email string) (Account, error) {
return v_ctx.Store.GetAccountByEmail(v_ctx.ctx, email)
}
func (v_ctx *StoreWithContext) GetUserAndAccountByAccessToken(accessToken string) (OAuthUser, *Account, error) {
return v_ctx.Store.GetUserAndAccountByAccessToken(v_ctx.ctx, accessToken)
}
func (v_ctx *StoreWithContext) GetShops(acctID int64) ([]AccountShop, error) {
return v_ctx.Store.GetShops(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) GetListingsForShop(acctID int64, shopID string) ([]Listing, error) {
return v_ctx.Store.GetListingsForShop(v_ctx.ctx, acctID, shopID)
}
func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID)
}
+2 -13
View File
@@ -20,6 +20,7 @@ import (
)
//go:generate oapi-codegen -generate types,client -package generated_client -o generated_client/client.go openapi.3.0.2.json
//go:generate concurry -s Platform
// TODO:
// - [x] document the flow in the README.md
@@ -36,11 +37,6 @@ type (
apiSharedSecret string
db *pgxpool.Pool
}
PlatformWithContext struct {
ctx context.Context
p *Platform
}
)
// oauth scopes
@@ -77,10 +73,7 @@ func NewPlatform(oAuthRedirectURI func(acctID int64) string, apiKeystring, apiSh
}
func (p *Platform) WithContext(ctx context.Context) *PlatformWithContext {
return &PlatformWithContext{
ctx: ctx,
p: p,
}
return NewPlatformWithContext(ctx, p)
}
func (p *Platform) GenerateConnectionURLForNewAccount(ctx context.Context, acctID int64) (*url.URL, error) {
@@ -113,10 +106,6 @@ func (p *Platform) GenerateConnectionURLForNewAccount(ctx context.Context, acctI
}, nil
}
func (p *PlatformWithContext) GenerateConnectionURLForNewAccount(acctID int64) (*url.URL, error) {
return p.p.GenerateConnectionURLForNewAccount(p.ctx, acctID)
}
// HandleNewAuthCode handles the auth code to get api access
func (p *Platform) HandleNewAuthCode(ctx context.Context, acctID int64, state, authCode string) (bool, error) {
// look up matching oauth request
@@ -0,0 +1,63 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package etsy
import (
"context"
"github.com/google/uuid"
"net/url"
"time"
)
type PlatformWithContext struct {
ctx context.Context
*Platform
}
func NewPlatformWithContext(ctx context.Context, v *Platform) *PlatformWithContext {
return &PlatformWithContext{
ctx: ctx,
Platform: v,
}
}
func (v_ctx *PlatformWithContext) GenerateConnectionURLForNewAccount(acctID int64) (*url.URL, error) {
return v_ctx.Platform.GenerateConnectionURLForNewAccount(v_ctx.ctx, acctID)
}
func (v_ctx *PlatformWithContext) HandleNewAuthCode(acctID int64, state string, authCode string) (bool, error) {
return v_ctx.Platform.HandleNewAuthCode(v_ctx.ctx, acctID, state, authCode)
}
func (v_ctx *PlatformWithContext) parseAccessCodeResponseBody(body []byte) (string, string, time.Time, int64, error) {
return v_ctx.Platform.parseAccessCodeResponseBody(v_ctx.ctx, body)
}
func (v_ctx *PlatformWithContext) getNewUserShopID(accessToken string, userID int64) (int64, error) {
return v_ctx.Platform.getNewUserShopID(v_ctx.ctx, accessToken, userID)
}
func (v_ctx *PlatformWithContext) GetUserPointerByAccountID(acctID int64) (*EtsyUser, error) {
return v_ctx.Platform.GetUserPointerByAccountID(v_ctx.ctx, acctID)
}
func (v_ctx *PlatformWithContext) saveNewEtsyUser(user EtsyUser, tokens etsyAccessTokens) error {
return v_ctx.Platform.saveNewEtsyUser(v_ctx.ctx, user, tokens)
}
func (v_ctx *PlatformWithContext) createNewOAuthRequest(acctID int64) (oauth2Request, error) {
return v_ctx.Platform.createNewOAuthRequest(v_ctx.ctx, acctID)
}
func (v_ctx *PlatformWithContext) getOauthRequest(state uuid.UUID) (oauth2Request, bool, error) {
return v_ctx.Platform.getOauthRequest(v_ctx.ctx, state)
}
func (v_ctx *PlatformWithContext) InvalidateState(state string) error {
return v_ctx.Platform.InvalidateState(v_ctx.ctx, state)
}
func (v_ctx *PlatformWithContext) deleteOauthRequest(state uuid.UUID) error {
return v_ctx.Platform.deleteOauthRequest(v_ctx.ctx, state)
}
-4
View File
@@ -73,10 +73,6 @@ func (p *Platform) GetUserPointerByAccountID(ctx context.Context, acctID int64)
}, nil
}
func (p *PlatformWithContext) GetUserPointerByAccountID(acctID int64) (*EtsyUser, error) {
return p.p.GetUserPointerByAccountID(p.ctx, acctID)
}
func (p *Platform) saveNewEtsyUser(
ctx context.Context,
user EtsyUser,
+1 -21
View File
@@ -16,12 +16,6 @@ type (
db *pgxpool.Pool
}
// StoreWithContext allows currying all Store methods by context.Context
StoreWithContext struct {
ctx context.Context
db *Store
}
Event struct {
Platform string
StoreID string
@@ -38,10 +32,7 @@ func NewStore(db *pgxpool.Pool) *Store {
}
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
db: db,
}
return NewStoreWithContext(ctx, db)
}
func (db *Store) Save(ctx context.Context, e *Event) error {
@@ -81,10 +72,6 @@ func (db *Store) Save(ctx context.Context, e *Event) error {
return nil
}
func (db *StoreWithContext) Save(e *Event) error {
return db.db.Save(db.ctx, e)
}
func (db *Store) LoadEventsForStore(
ctx context.Context,
platform string,
@@ -125,10 +112,3 @@ func (db *Store) LoadEventsForStore(
return evts, nil
}
func (db *StoreWithContext) LoadEventsForStore(
platform string,
storeID string,
) ([]Event, error) {
return db.db.LoadEventsForStore(db.ctx, platform, storeID)
}
@@ -0,0 +1,28 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package raw_events
import (
"context"
)
type StoreWithContext struct {
ctx context.Context
*Store
}
func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
Store: v,
}
}
func (v_ctx *StoreWithContext) Save(e *Event) error {
return v_ctx.Store.Save(v_ctx.ctx, e)
}
func (v_ctx *StoreWithContext) LoadEventsForStore(platform string, storeID string) ([]Event, error) {
return v_ctx.Store.LoadEventsForStore(v_ctx.ctx, platform, storeID)
}
+1 -1
View File
@@ -28,5 +28,5 @@ func (s *Server) createAccount(r *http.Request) (response.Response, error) {
return nil, response.Errorf("failed to create account: %w", err)
}
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.ID)), nil
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil
}
+1 -1
View File
@@ -198,7 +198,7 @@ func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error
}
id := getIdentity(r.Context())
if id.Account == nil || id.Account.ID != acctID {
if id.Account == nil || id.Account.AccountID != acctID {
return response.Unauthorized().
Msgf("user does not have access to account %d", acctID)
}
+1 -1
View File
@@ -44,7 +44,7 @@ func (s *Server) login(r *http.Request) (response.Response, error) {
return nil, response.Errorf("failed to create account: %w", err)
}
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.ID)), nil
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil
}
// GET /login/callback
+12
View File
@@ -42,6 +42,10 @@ func (b bodyRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(b)
}
func (b bodyRes) HTML(body []byte) Response {
return HTML(body).wrap(b)
}
func (b bodyRes) JSON(body any) Response {
return JSON(body).wrap(b)
}
@@ -82,3 +86,11 @@ func (b bodyRes) getCookies() []http.Cookie {
return nil
}
func (b bodyRes) getContentType() (contentType string, ok bool) {
if b.res != nil {
return b.res.getContentType()
}
return "", false
}
+12
View File
@@ -42,6 +42,10 @@ func (c cookieRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(c)
}
func (c cookieRes) HTML(body []byte) Response {
return HTML(body).wrap(c)
}
func (c cookieRes) JSON(body any) Response {
return JSON(body).wrap(c)
}
@@ -85,3 +89,11 @@ func (c cookieRes) getCookies() []http.Cookie {
return []http.Cookie{c.cookie}
}
func (c cookieRes) getContentType() (contentType string, ok bool) {
if c.res != nil {
return c.res.getContentType()
}
return "", false
}
+93
View File
@@ -0,0 +1,93 @@
package response
import (
"bytes"
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/site/redirect"
)
type (
htmlRes struct {
body []byte
res Response
}
)
var _ Response = htmlRes{}
func HTML(body []byte) Response {
return htmlRes{
body: body,
}
}
func (h htmlRes) String() string {
if h.res != nil {
return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res)
}
return fmt.Sprintf(`{"body": %q}`, h.body)
}
func (h htmlRes) wrap(res Response) Response {
h.res = res
return h
}
func (h htmlRes) Status(code int) Response {
return Status(code).wrap(h)
}
func (h htmlRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(h)
}
func (h htmlRes) HTML(body []byte) Response {
h.body = body
return h
}
func (h htmlRes) JSON(body any) Response {
return JSON(body).wrap(h)
}
func (h htmlRes) Body(body io.ReadCloser) Response {
return Body(body).wrap(h)
}
func (h htmlRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(h)
}
func (h htmlRes) getStatus() (int, bool) {
if h.res == nil {
return 0, false
}
return h.res.getStatus()
}
func (h htmlRes) getRedirect() (code redirect.Code, to string, ok bool) {
if h.res == nil {
return 0, "", false
}
return h.res.getRedirect()
}
func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) {
return io.NopCloser(bytes.NewBuffer(h.body)), true, nil
}
func (h htmlRes) getCookies() []http.Cookie {
if h.res != nil {
return h.res.getCookies()
}
return nil
}
func (h htmlRes) getContentType() (contentType string, ok bool) {
return "text/html", true
}
+8
View File
@@ -44,6 +44,10 @@ func (j jsonRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(j)
}
func (j jsonRes) HTML(body []byte) Response {
return HTML(body).wrap(j)
}
func (j jsonRes) JSON(body any) Response {
j.body = body
return j
@@ -85,3 +89,7 @@ func (j jsonRes) getCookies() []http.Cookie {
return nil
}
func (j jsonRes) getContentType() (contentType string, ok bool) {
return "application/json", true
}
+12
View File
@@ -83,6 +83,10 @@ func (r redirectRes) Redirect(code redirect.Code, to string) Response {
return r
}
func (r redirectRes) HTML(body []byte) Response {
return HTML(body).wrap(r)
}
func (r redirectRes) JSON(body any) Response {
return JSON(body).wrap(r)
}
@@ -122,3 +126,11 @@ func (r redirectRes) getCookies() []http.Cookie {
return nil
}
func (r redirectRes) getContentType() (contentType string, ok bool) {
if r.res != nil {
return r.res.getContentType()
}
return "", false
}
+2
View File
@@ -12,6 +12,7 @@ type (
Status(int) Response
Redirect(code redirect.Code, to string) Response
Body(io.ReadCloser) Response
HTML([]byte) Response
JSON(any) Response
Cookie(http.Cookie) Response
@@ -19,6 +20,7 @@ type (
getBody() (body io.ReadCloser, ok bool, err error)
getRedirect() (code redirect.Code, to string, ok bool)
getCookies() []http.Cookie
getContentType() (contentType string, ok bool)
wrap(Response) Response
}
+12
View File
@@ -44,6 +44,10 @@ func (s statusRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(s)
}
func (s statusRes) HTML(body []byte) Response {
return HTML(body).wrap(s)
}
func (s statusRes) JSON(body any) Response {
return JSON(body).wrap(s)
}
@@ -83,3 +87,11 @@ func (s statusRes) getCookies() []http.Cookie {
return nil
}
func (s statusRes) getContentType() (contentType string, ok bool) {
if s.res != nil {
return s.res.getContentType()
}
return "", false
}
+5 -1
View File
@@ -14,6 +14,10 @@ func Write(w http.ResponseWriter, r *http.Request, res Response) {
hdrs.Add("Set-Cookie", ck.String())
}
if ct, ok := res.getContentType(); ok {
hdrs.Add("Content-Type", ct)
}
if code, to, ok := res.getRedirect(); ok {
http.Redirect(w, r, to, code.Int())
return
@@ -44,7 +48,7 @@ func Write(w http.ResponseWriter, r *http.Request, res Response) {
}
func WriteError(w http.ResponseWriter, err error) {
var status int
status := http.StatusInternalServerError
if e, ok := GetError(err); ok {
if status, ok = e.GetStatus(); !ok {
+15 -7
View File
@@ -1,8 +1,13 @@
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--border-thin: 1px;
--border-medium: 2px;
}
@layer base {
@@ -19,16 +24,19 @@
}
select {
border-width: 2px;
border-width: var(--border-medium);
border-color: var(--border);
border-radius: var(--radius-lg);
}
.border-thin {
border-width: 1px;
border-width: var(--border-thin);
}
.border-medium {
border-width: var(--border-medium);
}
.border-button {
border-width: 2px;
border-width: var(--border-medium);
border-radius: var(--radius-lg);
}
}
+76
View File
@@ -0,0 +1,76 @@
@layer components {
table {
display: block; /* fixes scrolling when in a flexbox */
border-collapse: collapse;
thead {
border-color: var(--table-border);
border-width: var(--border-thin) var(--border-thin) 0 var(--border-thin);
& > tr {
border-color: var(--table-body-border);
& > :is(th, td) {
border-color: var(--table-body-border);
background-color: var(--table-thead);
}
}
& + tbody {
border-top-width: var(--border-thin);
}
}
tbody {
border-color: var(--table-body-border) var(--table-border);
border-left-width: var(--border-thin);
border-right-width: var(--border-thin);
border-bottom-width: 0;
& > tr {
border-color: var(--table-border);
& > :is(th, td) {
border-color: var(--table-border);
background-color: var(--table-tbody);
}
}
&:has(+ tfoot) {
border-bottom-width: var(--border-thin);
}
}
tfoot {
border-color: var(--table-border);
border-width: 0 var(--border-thin) var(--border-thin) var(--border-thin);
& > tr {
border-color: var(--table-body-border);
& > :is(th, td) {
border-color: var(--table-body-border);
background-color: var(--table-tfoot);
}
}
}
th,
td {
padding: 0.5em;
}
tr {
border-width: 0 0 var(--border-thin) 0;
&:last-child {
border: none;
}
}
th, td {
border-width: 0 var(--border-thin) 0 0;
&:last-child {
border: none;
}
}
}
}
+128 -5
View File
@@ -17,6 +17,7 @@
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--radius-lg: var(--radius);
--default-font-family: var(--font-sans);
@@ -180,6 +181,9 @@
.mt-\[3em\] {
margin-top: 3em;
}
.mb-\[0\.5em\] {
margin-bottom: 0.5em;
}
.mb-\[1em\] {
margin-bottom: 1em;
}
@@ -195,18 +199,27 @@
.min-h-full {
min-height: 100%;
}
.w-full {
width: 100%;
}
.max-w-\[50\%\] {
max-width: 50%;
}
.max-w-full {
max-width: 100%;
}
.min-w-fit {
min-width: fit-content;
}
.grow {
flex-grow: 1;
}
.basis-full {
flex-basis: 100%;
}
.cursor-pointer {
cursor: pointer;
}
.flex-col {
flex-direction: column;
}
@@ -231,6 +244,9 @@
.rounded-lg {
border-radius: var(--radius);
}
.rounded-sm {
border-radius: calc(var(--radius) - 4px);
}
.border-\[1px\] {
border-style: var(--tw-border-style);
border-width: 1px;
@@ -244,6 +260,9 @@
.bg-card {
background-color: var(--card);
}
.p-\[0\.5em\] {
padding: 0.5em;
}
.p-\[1em\] {
padding: 1em;
}
@@ -271,9 +290,30 @@
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
}
.font-semibold {
--tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold);
}
.text-nowrap {
text-wrap: nowrap;
}
.underline {
text-decoration-line: underline;
}
.hover\:bg-accent {
&:hover {
@media (hover: hover) {
background-color: var(--accent);
}
}
}
.hover\:underline {
&:hover {
@media (hover: hover) {
text-decoration-line: underline;
}
}
}
}
@layer base {
select {
@@ -355,6 +395,12 @@
--sidebar-accent-foreground: var(--base-800);
--sidebar-border: var(--base-200);
--sidebar-ring: var(--primary-300);
--table-thead: var(--base-400);
--table-tbody: var(--base-200);
--table-tfoot: var(--base-400);
--table-border: var(--base-300);
--table-body-border: var(--base-500);
--table-inner-border: var(--base-300);
--display-color: var(--foreground);
--text-color: var(--foreground);
@media (prefers-color-scheme: dark) {
@@ -389,10 +435,18 @@
--sidebar-accent-foreground: var(--base-200);
--sidebar-border: var(--base-800);
--sidebar-ring: var(--primary-300);
--table-thead: var(--base-600);
--table-tbody: var(--base-800);
--table-tfoot: var(--base-600);
--table-border: var(--base-700);
--table-body-border: var(--base-500);
--table-inner-border: var(--base-700);
}
}
* {
color: var(--foreground);
@layer theme {
* {
color: var(--foreground);
}
}
.dark {
--background: var(--base-950);
@@ -477,6 +531,10 @@
background-color: var(--sidebar-accent);
}
}
:root {
--border-thin: 1px;
--border-medium: 2px;
}
@layer base {
header, footer {
border-color: var(--sidebar-border);
@@ -488,18 +546,83 @@
border-top-width: 1px;
}
select {
border-width: 2px;
border-width: var(--border-medium);
border-color: var(--border);
border-radius: var(--radius-lg);
}
.border-thin {
border-width: 1px;
border-width: var(--border-thin);
}
.border-medium {
border-width: var(--border-medium);
}
.border-button {
border-width: 2px;
border-width: var(--border-medium);
border-radius: var(--radius-lg);
}
}
@layer components {
table {
display: block;
border-collapse: collapse;
thead {
border-color: var(--table-border);
border-width: var(--border-thin) var(--border-thin) 0 var(--border-thin);
& > tr {
border-color: var(--table-body-border);
& > :is(th, td) {
border-color: var(--table-body-border);
background-color: var(--table-thead);
}
}
& + tbody {
border-top-width: var(--border-thin);
}
}
tbody {
border-color: var(--table-body-border) var(--table-border);
border-left-width: var(--border-thin);
border-right-width: var(--border-thin);
border-bottom-width: 0;
& > tr {
border-color: var(--table-border);
& > :is(th, td) {
border-color: var(--table-border);
background-color: var(--table-tbody);
}
}
&:has(+ tfoot) {
border-bottom-width: var(--border-thin);
}
}
tfoot {
border-color: var(--table-border);
border-width: 0 var(--border-thin) var(--border-thin) var(--border-thin);
& > tr {
border-color: var(--table-body-border);
& > :is(th, td) {
border-color: var(--table-body-border);
background-color: var(--table-tfoot);
}
}
}
th, td {
padding: 0.5em;
}
tr {
border-width: 0 0 var(--border-thin) 0;
&:last-child {
border: none;
}
}
th, td {
border-width: 0 var(--border-thin) 0 0;
&:last-child {
border: none;
}
}
}
}
@property --tw-border-style {
syntax: "*";
inherits: false;
+1
View File
@@ -14,3 +14,4 @@
@import "./interactivity.css";
@import "./svg.css";
@import "./accessibility.css";
@import "./components.css";
+18 -2
View File
@@ -80,6 +80,13 @@
--sidebar-border: var(--base-200);
--sidebar-ring: var(--primary-300);
--table-thead: var(--base-400);
--table-tbody: var(--base-200);
--table-tfoot: var(--base-400);
--table-border: var(--base-300);
--table-body-border: var(--base-500);
--table-inner-border: var(--base-300);
--display-color: var(--foreground);
--text-color: var(--foreground);
@@ -115,11 +122,20 @@
--sidebar-accent-foreground: var(--base-200);
--sidebar-border: var(--base-800);
--sidebar-ring: var(--primary-300);
--table-thead: var(--base-600);
--table-tbody: var(--base-800);
--table-tfoot: var(--base-600);
--table-border: var(--base-700);
--table-body-border: var(--base-500);
--table-inner-border: var(--base-700);
}
}
* {
color: var(--foreground);
@layer theme {
* {
color: var(--foreground);
}
}
.dark {
+30 -16
View File
@@ -15,11 +15,33 @@ import (
)
// GET /
// compiles the page template or component template matching the url
func (s *Server) serveTemplates(r *http.Request) (response.Response, error) {
ctx := r.Context()
name, pathParams := getPageTemplateNameForURL(r.URL)
name, args := s.getTemplateNameAndArgs(r, "internal/site/templates/component_bodies")
templateArgs := []any{
b, err := s.templater.ExecuteComponentBody(name, args...)
if err == nil {
return response.HTML(b), nil
}
if !isFileNotFoundError(err) {
return s.handleTemplateError(err, args...)
}
name, args = s.getTemplateNameAndArgs(r, "internal/site/templates/page_bodies")
if b, err = s.templater.ExecutePage(name, args...); err == nil {
return response.HTML(b), nil
}
return s.handleTemplateError(err, args...)
}
func (s *Server) getTemplateNameAndArgs(r *http.Request, templateDir string) (name string, args []any) {
ctx := r.Context()
name, pathParams := getTemplateNameForURL(r.URL, templateDir)
return name, []any{
"Request",
r,
// add services and data here
@@ -47,22 +69,15 @@ func (s *Server) serveTemplates(r *http.Request) (response.Response, error) {
"Auth",
newTemplateAuthenticator(r),
}
b, err := s.templater.ExecutePage(name, templateArgs...)
if err != nil {
return s.handleTemplateError(err, templateArgs...)
}
return response.Body(io.NopCloser(bytes.NewBuffer(b))), nil
}
// TODO: clean this up...
// TODO: somehow tell what the path params are and pass them up.
// - then consider pushing this functionality into the template library.
//
// getPageTemplateNameForURL eliminate any trailing .html or /, and checks for any
// getComponentTemplateNameForURL eliminate any trailing .html or /, and checks for any
// file with path parameters in the name, eg '{abc}.html.tmpl', prefering exact filename matches.
func getPageTemplateNameForURL(u *url.URL) (name string, params map[string]string) {
func getTemplateNameForURL(u *url.URL, templateDir string) (name string, params map[string]string) {
fp := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSuffix(u.Path, ".html"), "/"), "/")
if fp == "" {
// "/" maps to "/index"
@@ -72,17 +87,16 @@ func getPageTemplateNameForURL(u *url.URL) (name string, params map[string]strin
fpParts := strings.Split(fp, "/")
res := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(fpParts)
for _, combs := range res {
const pageBodiesPrefix = "internal/site/templates/page_bodies"
pattern := path.Join(pageBodiesPrefix, path.Join(combs...)) + ".html.tmpl"
pattern := path.Join(templateDir, path.Join(combs...)) + ".html.tmpl"
matches, _ := filepath.Glob(pattern)
if len(matches) == 0 {
pattern := path.Join(pageBodiesPrefix, path.Join(combs...), "index") + ".html.tmpl"
pattern := path.Join(templateDir, path.Join(combs...), "index") + ".html.tmpl"
matches, _ = filepath.Glob(pattern)
}
if len(matches) > 0 {
match := matches[0]
name = strings.TrimPrefix(strings.TrimSuffix(match, ".html.tmpl"), pageBodiesPrefix+"/")
name = strings.TrimPrefix(strings.TrimSuffix(match, ".html.tmpl"), templateDir+"/")
patternParts := strings.Split(name, "/")
params = make(map[string]string)
@@ -0,0 +1,65 @@
{{/* .Identity.Account.AccountID | .PathParams.acctID | .AccountID */}}
{{- $dot := or .dot . }}
{{- .Auth.ByMatchingAccountID 2 }}
{{- $acctID := 0 }}
{{- if $dot.Identity }}
{{- $acctID = $dot.Identity.Account.AccountID }}
{{- else if .PathParams }}
{{- $acctID = $dot.PathParams.acctID | parseInt64 }}
{{- else }}
{{- $acctID = $dot.AccountID }}
{{- end }}
{{- $stores := $dot.Accounts.GetShops $acctID -}}
<table id="inventory-table-2" class="max-w-full overflow-x-scroll">
<thead>
<tr>
<th>
Shop
</th>
<th>
Listing
</th>
<th>
SKU
</th>
<th>
Description
</th>
<th>
</th>
</tr>
</thead>
<tbody>
{{/* TODO: list existing row */}}
{{- componentBody "accounts/{acctID}/inventory/sync-table-v2/new-row" "dot" $dot }}
{{/* TODO: save the saved rows */}}
</tbody>
<tfoot>
<tr>
<th>
Shop
</th>
<th>
Listing
</th>
<th>
SKU
</th>
<th>
Description
</th>
<th>
</th>
</tr>
</tfoot>
</table>
@@ -0,0 +1,64 @@
{{/* .Identity.Account.AccountID, .Accounts */}}
{{- $dot := or .dot . -}}
{{- $acctID := $dot.Identity.Account.AccountID }}
{{- $shops := $dot.Accounts.GetShops $acctID -}}
<tr _="
on setListing(listing)
set the innerHTML of (the last <td[data-id=sku]/> in me) to listing.sku
set the innerHTML of (the last <td[data-id=description]/> in me) to listing.description
-- TODO: only remove if there isn't already a 'new row'
remove @disabled from (the <button/> in last <td[data-id=save]/> in me)
on resetListing
set the innerHTML of (the last <td[data-id=sku]/> in me) to '-'
set the innerHTML of (the last <td[data-id=description]/> in me) to '-'
add @disabled to (the <button/> in last <td[data-id=save]/> in me)
"
>
<td>
<select
class="min-w-fit cursor-pointer"
hx-get="/accounts/{{$acctID}}/platforms/{platform}/shops/{shopID}/listing-select"
hx-vals='js:{
platform: event.target.value.replace(/-[^ ]*/, ""),
shopID: event.target.value.replace(/[^ ]*-/, "")
}'
hx-target="next td"
_="
on change
log event.target.value
send resetListing() to closest <tr/>
"
>
<option disabled selected>- Shops -</option>
{{- range $shop := $shops }}
<option value="{{$shop.Platform}}-{{$shop.ShopID}}">
{{ $shop.Name }} - {{$shop.Platform}} - {{$shop.ShopID}}
</option>
{{- end }}
</select>
</td>
<td>
-
</td>
<td data-id="sku">
-
</td>
<td data-id="description">
-
</td>
<td data-id="save">
<button
disabled
hx-get="/accounts/{{$acctID}}/inventory/sync-table-v2/new-row"
hx-target="closest tr"
hx-swap="afterend"
>
Save
</button>
</td>
</tr>
@@ -0,0 +1,46 @@
{{- $dot := or .dot . }}
{{- $dot.Auth.ByMatchingAccountID 2 }}
{{- $acctID := $dot.Identity.Account.AccountID }}
{{- $stores := $dot.Accounts.GetShops $acctID -}}
<table id="inventory-table" class="max-w-full overflow-x-scroll">
<thead>
<tr>
{{- range $store := $stores }}
<th class="text-nowrap">
{{ $store.Platform }}: {{ $store.Name }}
</th>
<th class="text-nowrap">
Count
</th>
{{- end }}
<th>
</th>
</tr>
</thead>
<tbody>
{{- componentBody "accounts/{acctID}/inventory/sync-table/new-row" "dot" $dot }}
</tbody>
<tfoot>
<tr>
{{- range $store := $stores }}
<th class="text-nowrap">
{{ $store.Platform }}: {{ $store.Name }}
</th>
<th class="text-nowrap">
Count
</th>
{{- end }}
<th>
</th>
</tr>
</tfoot>
</table>
@@ -0,0 +1,54 @@
{{- $dot := or .dot .}}
{{- $dot.Auth.ByMatchingAccountID 2 }}
{{- $acctID := $dot.Identity.Account.AccountID }}
{{- $shops := $dot.Accounts.GetShops $acctID -}}
<tr>
{{- range $shop := $shops }}
{{- $listings := $dot.Accounts.GetListingsForShop $acctID $shop.ShopID }}
<td>
<select
class="min-w-fit cursor-pointer"
_="
on change
set dataset to the dataset of the first of my selectedOptions
set listingShowcase to the <#listing-showcase/>
if <#listing-showcase/> then
send displayListing(
id: dataset.id,
name: dataset.name,
description: dataset.description,
count: dataset.count
) to listingShowcase
end
set the innerHTML of the next <td/> to the dataset.count
"
>
<option disabled selected>- Listings -</option>
{{- range $listing := $listings }}
<option
value="{{$listing.ListingID}}"
data-id="{{$listing.ListingID}}"
data-name="{{$listing.Name}}"
data-description="{{$listing.Description}}"
data-count="{{$listing.Count}}"
>
{{ $listing.Name }}
</option>
{{- end }}
</select>
</td>
<td>
</td>
{{- end }}
<td>
<button class="cursor-pointer underline w-full">
Save
</button>
</td>
</tr>
@@ -0,0 +1,40 @@
<table class="max-w-full"
id="listing-showcase"
_="
on displayListing(id, name, description, count)
set innerHTML of <#listing-showcase-id/> to id
set innerHTML of <#listing-showcase-name/> to name
set innerHTML of <#listing-showcase-description/> to description
set innerHTML of <#listing-showcase-count/> to count
"
>
<thead>
<th>
ID
</th>
<th>
Name
</th>
<th>
Description
</th>
<th>
Count
</th>
</thead>
<tbody>
<th id="listing-showcase-id">
-
</th>
<th id="listing-showcase-name">
-
</th>
<th id="listing-showcase-description">
-
</th>
<th id="listing-showcase-count">
-
</th>
</tbody>
</table>
@@ -0,0 +1,29 @@
{{/* .ShopID: default to path param "shopID" */}}
{{- $shopID := or (and .PathParams .PathParams.shopID) .ShopID }}
{{ $acctID := .Identity.Account.AccountID }}
<select
class="min-w-fit cursor-pointer"
_="
on input
set dataset to (my selectedOptions)[0].dataset
send setListing(listing: dataset) to the closest <tr/>
"
>
{{- $listings := .Accounts.GetListingsForShop $acctID $shopID }}
{{- $dot := . }}
<option disabled selected>- Listings -</option>
{{- range $listing := $listings }}
<option
value="{{$listing.SKU}}"
data-name="{{ $listing.Name }}"
data-sku="{{ $listing.SKU }}"
data-description="{{ $listing.Description }}"
data-count="{{ $listing.Count }}"
>
{{ $listing.Name }}
</option>
{{- end }}
</select>
@@ -27,7 +27,7 @@
{{- $acct := .dot.Identity.Account }}
{{- $acctID := 0 }}
{{- if $acct }}
{{- $acctID = $acct.ID }}
{{- $acctID = $acct.AccountID }}
{{- end }}
{{- if not $userID }}
+7 -6
View File
@@ -11,12 +11,13 @@
<script src="/scripts/htmx.min.js.gz"></script>
<script src="/scripts/_hyperscript.min.js.gz"></script>
<script src="https://unpkg.com/htmx-ext-path-params@2.0.0/path-params.js"></script>
{{/* This is where the page_head template will be inserted into the page */}}
{{- block "head" . }}{{ end }}
</head>
<body class="basis-full grow bg-background flex flex-col items-stretch">
<body class="basis-full grow bg-background flex flex-col items-stretch" hx-ext="path-params">
<header>
<nav class="flex flex-col items-center text-xl overflow-x-scroll overflow-y-hidden ">
{{- $path := or .Request.URL.Path "/" }}
@@ -33,7 +34,7 @@
{{- $acct := .Identity.Account }}
{{- $acctID := 0 }}
{{- if $acct }}
{{- $acctID = $acct.ID }}
{{- $acctID = $acct.AccountID }}
{{- end }}
{{- if not $userID }}
@@ -53,14 +54,14 @@
</li>
<li class="pt-[1em] pb-[1em]">
<a href="/accounts/{{$acctID}}/reports" class="p-[1em] font-bold {{ if eq $path (printf "/accounts/%d/reports" $acctID) }}selected{{ end }}">
Reports
<a href="/accounts/{{$acctID}}/inventory" class="p-[1em] font-bold {{ if eq $path (printf "/accounts/%d/inventory" $acctID) }}selected{{ end }}">
Inventory
</a>
</li>
<li class="pt-[1em] pb-[1em]">
<a href="/accounts/{{$acctID}}/inventory" class="p-[1em] font-bold {{ if eq $path (printf "/accounts/%d/inventory" $acctID) }}selected{{ end }}">
Inventory
<a href="/accounts/{{$acctID}}/reports" class="p-[1em] font-bold {{ if eq $path (printf "/accounts/%d/reports" $acctID) }}selected{{ end }}">
Reports
</a>
</li>
@@ -6,15 +6,15 @@
<h1>Account: {{ $acctID.Email }}</h1>
{{- $etsyUser := .Etsy.GetUserPointerByAccountID $acctID.ID }}
{{- $etsyUser := .Etsy.GetUserPointerByAccountID $acctID.AccountID }}
{{- if $etsyUser }}
<h3>Etsy User: {{ $etsyUser.UserID }}; Shop ID: {{ $etsyUser.ShopID }}</h3>
{{- else }}
<h3>
{{/* TODO: create this link dynamically, not EVERYTIME THE PAGE IS LOADED */}}
<a href="{{ printf "/webhooks/etsy/%d/new-account-link" $acctID.ID }}">
<a href="{{ printf "/webhooks/etsy/%d/new-account-link" $acctID.AccountID }}">
Link Your Etsy Store!
</a>
</h3>
{{- end }}
<h2><a href="/accounts/{{$acctID.ID}}/reports">View Reports</a></h2>
<h2><a href="/accounts/{{$acctID.AccountID}}/reports">View Reports</a></h2>
@@ -2,4 +2,38 @@
{{- define "title" }} Inventory++ {{ end }}
<h1>Inventory management page: WIP</h1>
<h1 class="text-center mb-[0.5em]">Inventory</h1>
<h2 class="text-center mb-[0.5em]">Sync Stores</h2>
{{ $acctID := .Identity.Account.AccountID }}
{{ $stores := .Accounts.GetShops $acctID }}
<section class="w-full flex flex-col items-center">
<h3>
Draft 2
</h3>
{{ componentBody "accounts/{acctID}/inventory/sync-table-v2" "dot" . }}
</section>
<section class="w-full flex flex-col items-center">
<h3>
Draft 1
</h3>
{{ componentBody "accounts/{acctID}/inventory/sync-table" "dot" . }}
</section>
<section class="flex justify-center mt-[1em]">
{{ componentBody "accounts/{acctID}/inventory/sync-table/showcase-table" "dot" . }}
</section>
<section class="w-full flex justify-center mt-[1em]">
<button
class="border-thin bg-card rounded-sm p-[0.5em] font-semibold cursor-pointer hover:underline hover:bg-accent"
hx-get="/accounts/{{$acctID}}/inventory/sync-table/new-row"
hx-target="#inventory-table > tbody"
hx-swap="beforeend"
>
Add Row
</button>
</section>
@@ -6,6 +6,7 @@
</section>
{{- if and .Identity.User.UserID (not .Identity.Account) }}
{{/* TODO: make account creation automatic as part of the user creation process */}}
<h2><a href="/account-creation">New Account</a></h2>
{{- end }}