diff --git a/Dockerfile b/Dockerfile index 54a2a14..34df0dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/serv FROM gcr.io/distroless/static-debian12 WORKDIR /app COPY --from=build /out/server /app/server -ENV ADDR=:8080 DB_PATH=/data/hakemsho.db CARDS_DIR=/data/cards +ENV ADDR=:8080 DB_PATH=/data/hakemsho.db CARDS_DIR=/data/cards CARPETS_DIR=/data/carpets EXPOSE 8080 VOLUME ["/data"] USER nonroot:nonroot diff --git a/cmd/server/main.go b/cmd/server/main.go index aa5a952..3911b95 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -83,9 +83,11 @@ func main() { // تصاویرِ کارت (هر اسکین یک پوشه) — از backend سرویس می‌شود تا اپ سبک بماند. r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir)) + // تصاویرِ فرش (به‌جای میز) — از پنل ادمین آپلود می‌شوند. + r.Handle("/carpets/*", static.CarpetsHandler(cfg.CarpetsDir)) // پنل ادمین (HTML سرور‌ساید، پشت Basic Auth) - r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir).Routes(cfg.AdminUser, cfg.AdminPass)) + r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir).Routes(cfg.AdminUser, cfg.AdminPass)) r.Route("/api", func(r chi.Router) { r.Route("/auth", func(r chi.Router) { @@ -108,6 +110,9 @@ func main() { r.Post("/shop/select-card", ecoH.SelectCard) r.Get("/chat-packs", ecoH.ChatPacks) r.Post("/shop/buy-chat-pack", ecoH.BuyChatPack) + r.Get("/carpets", ecoH.Carpets) + r.Post("/shop/buy-carpet", ecoH.BuyCarpet) + r.Post("/shop/select-carpet", ecoH.SelectCarpet) r.Post("/shop/purchase", ecoH.Purchase) r.Post("/rewards/daily", ecoH.Daily) r.Post("/rewards/ad", ecoH.AdReward) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 8d25fac..2d5260f 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -26,15 +26,16 @@ import ( var tplFS embed.FS type Handler struct { - db *sql.DB - eco *economy.Service - tpl *template.Template - cardsDir string // پوشه‌ی دیسکیِ آپلودِ اسکین‌ها (همان مسیری که static از آن سرو می‌کند) + db *sql.DB + eco *economy.Service + tpl *template.Template + cardsDir string // پوشه‌ی دیسکیِ آپلودِ اسکین‌ها (همان مسیری که static از آن سرو می‌کند) + carpetsDir string // پوشه‌ی دیسکیِ آپلودِ تصاویرِ فرش } -func New(db *sql.DB, eco *economy.Service, cardsDir string) *Handler { +func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir string) *Handler { tpl := template.Must(template.ParseFS(tplFS, "templates/*.html")) - return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir} + return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir} } // Routes زیرروترِ /admin را با Basic Auth برمی‌گرداند. @@ -51,10 +52,15 @@ func (h *Handler) Routes(user, pass string) http.Handler { r.Post("/users/coins", h.adjustCoins) r.Post("/users/grant", h.grantToUser) r.Post("/season/reset", h.resetSeason) + r.Get("/carpets", h.carpetsAdmin) + r.Post("/carpets/add", h.carpetAdd) + r.Post("/carpets/delete", h.carpetDelete) + r.Post("/carpets/{id}/upload", h.carpetUpload) r.Get("/chat", h.chatAdmin) r.Post("/chat/pack/add", h.chatPackAdd) r.Post("/chat/pack/delete", h.chatPackDelete) r.Post("/chat/message/add", h.chatMessageAdd) + r.Post("/chat/message", h.chatMessageUpdate) r.Post("/chat/message/delete", h.chatMessageDelete) return r } @@ -443,6 +449,86 @@ func (h *Handler) grantToUser(w http.ResponseWriter, r *http.Request) { } // rows یک کوئری را به []map[string]any تبدیل می‌کند (برای رندرِ عمومیِ جدول‌ها). +// --- مدیریتِ فرش‌ها --- + +func (h *Handler) carpetsAdmin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + carpets := h.rows(ctx, + `SELECT id,title,price_coins,vip,sort,enabled FROM carpets ORDER BY sort`, + "id", "title", "price_coins", "vip", "sort", "enabled") + h.render(w, "carpets.html", map[string]any{ + "Nav": "carpets", + "Carpets": carpets, + "Saved": r.URL.Query().Get("saved") == "1", + }) +} + +func (h *Handler) carpetAdd(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + id := r.FormValue("id") + if id == "" || strings.ContainsAny(id, "/\\.") { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + num := func(k string) int { v, _ := strconv.Atoi(r.FormValue(k)); return v } + vip := 0 + if r.FormValue("vip") == "on" { + vip = 1 + } + _, err := h.db.ExecContext(r.Context(), + `INSERT OR REPLACE INTO carpets (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)`, + id, r.FormValue("title"), num("price_coins"), vip, num("sort")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther) +} + +func (h *Handler) carpetDelete(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + _, _ = h.db.ExecContext(r.Context(), `DELETE FROM carpets WHERE id=?`, r.FormValue("id")) + http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther) +} + +// carpetUpload یک تصویرِ jpg/png را برای یک فرش آپلود و با نامِ .jpg ذخیره می‌کند. +func (h *Handler) carpetUpload(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" || strings.ContainsAny(id, "/\\.") { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + if h.carpetsDir == "" { + http.Error(w, "CARPETS_DIR تنظیم نشده است", http.StatusInternalServerError) + return + } + if err := r.ParseMultipartForm(16 << 20); err != nil { + http.Error(w, "bad upload", http.StatusBadRequest) + return + } + file, _, err := r.FormFile("image") + if err != nil { + http.Error(w, "image لازم است", http.StatusBadRequest) + return + } + defer file.Close() + if err := os.MkdirAll(h.carpetsDir, 0o755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + out, err := os.Create(filepath.Join(h.carpetsDir, id+".jpg")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer out.Close() + if _, err := io.Copy(out, io.LimitReader(file, 16<<20)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther) +} + // --- مدیریتِ بسته‌های چت و پیام‌ها --- func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) { @@ -533,6 +619,23 @@ func (h *Handler) chatMessageAdd(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) } +func (h *Handler) chatMessageUpdate(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + id, _ := strconv.Atoi(r.FormValue("id")) + body := r.FormValue("body") + if id == 0 || body == "" { + http.Redirect(w, r, "/admin/chat", http.StatusSeeOther) + return + } + sort, _ := strconv.Atoi(r.FormValue("sort")) + if _, err := h.db.ExecContext(r.Context(), + `UPDATE chat_messages SET body=?, sort=? WHERE id=?`, body, sort, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) +} + func (h *Handler) chatMessageDelete(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() id, _ := strconv.Atoi(r.FormValue("id")) diff --git a/internal/admin/templates/carpets.html b/internal/admin/templates/carpets.html new file mode 100644 index 0000000..19a1ef8 --- /dev/null +++ b/internal/admin/templates/carpets.html @@ -0,0 +1,49 @@ + + +{{template "head" .}} + +{{template "nav" .}} +
+

فرش‌ها

+ {{if .Saved}}
انجام شد ✓
{{end}} + +

+ هر فرش به‌جای میزِ بازی استفاده می‌شود. تصویرِ هر فرش را به‌صورتِ jpg آپلود کنید + (با نامِ شناسه ذخیره می‌شود). شناسهٔ classic یعنی میزِ پیش‌فرضِ بدونِ تصویر. +

+ + + + {{range .Carpets}} + + + + + + + + + + + {{end}} + + + + + + + + + +
شناسهعنوانقیمت (سکه)VIP رایگانترتیبپیش‌نمایشتصویر
{{.id}}— + + + + + + + +
+
+ + diff --git a/internal/admin/templates/chat.html b/internal/admin/templates/chat.html index ab36c4a..e89dfaf 100644 --- a/internal/admin/templates/chat.html +++ b/internal/admin/templates/chat.html @@ -50,12 +50,12 @@ {{range .messages}} - - - + + + {{end}} diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html index 53c97d5..9d4e698 100644 --- a/internal/admin/templates/layout.html +++ b/internal/admin/templates/layout.html @@ -33,6 +33,7 @@ diff --git a/internal/config/config.go b/internal/config/config.go index 168472e..76075dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { AdminUser string // یوزرنیمِ پنل ادمین (Basic Auth) AdminPass string // پسوردِ پنل ادمین (Basic Auth) CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار) + CarpetsDir string // پوشه‌ی دیسکیِ تصاویرِ فرش (روی volume ماندگار) } // Load کانفیگ را از env با مقادیر پیش‌فرض معقول می‌خواند. @@ -39,6 +40,7 @@ func Load() Config { AdminUser: env("ADMIN_PANEL_USER", "admin"), AdminPass: env("ADMIN_PANEL_PASS", "admin"), CardsDir: env("CARDS_DIR", "carddata"), + CarpetsDir: env("CARPETS_DIR", "carpetdata"), } } diff --git a/internal/economy/carpet.go b/internal/economy/carpet.go new file mode 100644 index 0000000..3c07e32 --- /dev/null +++ b/internal/economy/carpet.go @@ -0,0 +1,118 @@ +package economy + +import "context" + +// Carpet یک فرشِ ایرانی که به‌جای میزِ بازی استفاده می‌شود. Owned یعنی قابلِ انتخاب. +type Carpet struct { + ID string `json:"id"` + Title string `json:"title"` + PriceCoins int64 `json:"price_coins"` + VIP bool `json:"vip"` + Owned bool `json:"owned"` +} + +// Carpets فرش‌های فعال را با وضعیتِ مالکیتِ کاربر برمی‌گرداند. +func (s *Service) Carpets(ctx context.Context, userID int64) ([]Carpet, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, title, price_coins, vip FROM carpets WHERE enabled = 1 ORDER BY sort`) + if err != nil { + return nil, err + } + defer rows.Close() + + var carpets []Carpet + for rows.Next() { + var c Carpet + var vip int + if err := rows.Scan(&c.ID, &c.Title, &c.PriceCoins, &vip); err != nil { + return nil, err + } + c.VIP = vip == 1 + carpets = append(carpets, c) + } + if err := rows.Err(); err != nil { + return nil, err + } + + owned, err := s.ownedCarpets(ctx, userID) + if err != nil { + return nil, err + } + isVIP := s.isVIP(ctx, userID) + for i := range carpets { + c := &carpets[i] + c.Owned = (c.PriceCoins == 0 && !c.VIP) || (c.VIP && isVIP) || owned[c.ID] + } + return carpets, nil +} + +func (s *Service) ownedCarpets(ctx context.Context, userID int64) (map[string]bool, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT carpet_id FROM user_carpets WHERE user_id = ?`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + owned := map[string]bool{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + owned[id] = true + } + return owned, rows.Err() +} + +// BuyCarpet یک فرش را با سکه باز می‌کند. +func (s *Service) BuyCarpet(ctx context.Context, userID int64, carpetID string) error { + var price int64 + var vip, enabled int + err := s.db.QueryRowContext(ctx, + `SELECT price_coins, vip, enabled FROM carpets WHERE id = ?`, carpetID). + Scan(&price, &vip, &enabled) + if err != nil || enabled != 1 { + return ErrNotFound + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + var exists int + _ = tx.QueryRowContext(ctx, + `SELECT 1 FROM user_carpets WHERE user_id = ? AND carpet_id = ?`, userID, carpetID).Scan(&exists) + if exists == 1 { + return ErrAlreadyOwned + } + if price > 0 { + if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_carpet", carpetID); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO user_carpets (user_id, carpet_id) VALUES (?, ?)`, userID, carpetID); err != nil { + return err + } + return tx.Commit() +} + +// SelectCarpet فرشِ انتخابیِ کاربر را تنظیم می‌کند (باید مالکش باشد یا رایگان/VIP). +func (s *Service) SelectCarpet(ctx context.Context, userID int64, carpetID string) error { + carpets, err := s.Carpets(ctx, userID) + if err != nil { + return err + } + for _, c := range carpets { + if c.ID == carpetID { + if !c.Owned { + return ErrNotOwned + } + _, err := s.db.ExecContext(ctx, + `UPDATE users SET selected_carpet = ? WHERE id = ?`, carpetID, userID) + return err + } + } + return ErrNotFound +} diff --git a/internal/economy/economy.go b/internal/economy/economy.go index ccfc432..8c79f79 100644 --- a/internal/economy/economy.go +++ b/internal/economy/economy.go @@ -55,7 +55,8 @@ type Profile struct { XPNeed int64 `json:"xp_for_next"` VIP bool `json:"vip"` VIPUntil *string `json:"vip_until"` - SelectedCard string `json:"selected_card"` + SelectedCard string `json:"selected_card"` + SelectedCarpet string `json:"selected_carpet"` RankPoints int64 `json:"rank_points"` RankTier string `json:"rank_tier"` // bronze..king RankIndex int `json:"rank_index"` // ۰..۴ @@ -66,8 +67,8 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error var p Profile var vipUntil sql.NullString err := s.db.QueryRowContext(ctx, - `SELECT coins, tickets, xp, trophies, vip_until, selected_card, rank_points FROM users WHERE id = ?`, userID). - Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.RankPoints) + `SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, rank_points FROM users WHERE id = ?`, userID). + Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } diff --git a/internal/economy/handler.go b/internal/economy/handler.go index 82ad405..287c228 100644 --- a/internal/economy/handler.go +++ b/internal/economy/handler.go @@ -87,6 +87,73 @@ func (h *Handler) BuyCard(w http.ResponseWriter, r *http.Request) { } } +// Carpets — GET /api/carpets (فرش‌ها + مالکیت + فرشِ انتخابی) +func (h *Handler) Carpets(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + carpets, err := h.svc.Carpets(r.Context(), id) + if err != nil { + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + selected := "classic" + if p, _ := h.svc.GetProfile(r.Context(), id); p != nil && p.SelectedCarpet != "" { + selected = p.SelectedCarpet + } + httpx.JSON(w, http.StatusOK, map[string]any{ + "carpets": carpets, + "selected": selected, + }) +} + +// BuyCarpet — POST /api/shop/buy-carpet +func (h *Handler) BuyCarpet(w http.ResponseWriter, r *http.Request) { + h.carpetAction(w, r, false) +} + +// SelectCarpet — POST /api/shop/select-carpet +func (h *Handler) SelectCarpet(w http.ResponseWriter, r *http.Request) { + h.carpetAction(w, r, true) +} + +func (h *Handler) carpetAction(w http.ResponseWriter, r *http.Request, sel bool) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + var req struct { + CarpetID string `json:"carpet_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + var err error + if sel { + err = h.svc.SelectCarpet(r.Context(), id, req.CarpetID) + } else { + err = h.svc.BuyCarpet(r.Context(), id, req.CarpetID) + } + switch { + case err == nil: + httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"}) + case errors.Is(err, ErrNotFound): + httpx.Error(w, http.StatusNotFound, "carpet not found") + case errors.Is(err, ErrAlreadyOwned): + httpx.Error(w, http.StatusConflict, "already owned") + case errors.Is(err, ErrNotOwned): + httpx.Error(w, http.StatusForbidden, "carpet not owned") + case errors.Is(err, ErrInsufficient): + httpx.Error(w, http.StatusPaymentRequired, "insufficient coins") + default: + httpx.Error(w, http.StatusInternalServerError, "server error") + } +} + // ChatPacks — GET /api/chat-packs (بسته‌های پیام/شکلک + مالکیتِ کاربر) func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) { id, ok := uid(r) diff --git a/internal/static/carpets/isfahan.jpg b/internal/static/carpets/isfahan.jpg new file mode 100644 index 0000000..f3efadd Binary files /dev/null and b/internal/static/carpets/isfahan.jpg differ diff --git a/internal/static/carpets/kashan.jpg b/internal/static/carpets/kashan.jpg new file mode 100644 index 0000000..37afcb4 Binary files /dev/null and b/internal/static/carpets/kashan.jpg differ diff --git a/internal/static/carpets/nain.jpg b/internal/static/carpets/nain.jpg new file mode 100644 index 0000000..974613e Binary files /dev/null and b/internal/static/carpets/nain.jpg differ diff --git a/internal/static/carpets/qom.jpg b/internal/static/carpets/qom.jpg new file mode 100644 index 0000000..8c6c6f8 Binary files /dev/null and b/internal/static/carpets/qom.jpg differ diff --git a/internal/static/carpets/royal.jpg b/internal/static/carpets/royal.jpg new file mode 100644 index 0000000..7e30bad Binary files /dev/null and b/internal/static/carpets/royal.jpg differ diff --git a/internal/static/carpets/shiraz.jpg b/internal/static/carpets/shiraz.jpg new file mode 100644 index 0000000..a707c18 Binary files /dev/null and b/internal/static/carpets/shiraz.jpg differ diff --git a/internal/static/carpets/tabriz.jpg b/internal/static/carpets/tabriz.jpg new file mode 100644 index 0000000..9e43095 Binary files /dev/null and b/internal/static/carpets/tabriz.jpg differ diff --git a/internal/static/static.go b/internal/static/static.go index 1b779f0..83fb1d5 100644 --- a/internal/static/static.go +++ b/internal/static/static.go @@ -17,6 +17,9 @@ import ( //go:embed cards var cardsFS embed.FS +//go:embed carpets +var carpetsFS embed.FS + // CardsHandler یک http.Handler برای سرویس‌دهیِ فایل‌های کارت زیر /cards/ است. // diskDir پوشه‌ی اسکین‌های آپلودی است (می‌تواند خالی/ناموجود باشد). // CORS باز است تا نسخهٔ وب (Flutter web / canvaskit) هم بتواند تصاویر را بکشد. @@ -45,3 +48,29 @@ func CardsHandler(diskDir string) http.Handler { embedded.ServeHTTP(w, r) }) } + +// CarpetsHandler تصاویرِ فرش را سرو می‌کند: GET /carpets/.jpg +// ابتدا از پوشه‌ی دیسکی (آپلودِ ادمین)، سپس از فرش‌های embed‌شده‌ی پیش‌فرض. +// اگر هیچ‌کدام نبود ۴۰۴ (اپ به میزِ ترسیمیِ classic برمی‌گردد). +func CarpetsHandler(diskDir string) http.Handler { + sub, err := fs.Sub(carpetsFS, "carpets") + if err != nil { + panic(err) + } + embedded := http.StripPrefix("/carpets/", http.FileServer(http.FS(sub))) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "public, max-age=604800") + // تلاشِ اول: پوشه‌ی دیسکی (آپلودِ ادمین). + if diskDir != "" { + rel := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/carpets/")) + full := filepath.Join(diskDir, rel) + if st, err := os.Stat(full); err == nil && !st.IsDir() { + http.ServeFile(w, r, full) + return + } + } + // تلاشِ دوم: فرش‌های embed‌شده. + embedded.ServeHTTP(w, r) + }) +} diff --git a/internal/store/migrations/009_carpets.sql b/internal/store/migrations/009_carpets.sql new file mode 100644 index 0000000..5683960 --- /dev/null +++ b/internal/store/migrations/009_carpets.sql @@ -0,0 +1,24 @@ +-- فرش‌های ایرانی که به‌جای میزِ بازی استفاده می‌شوند (تصویرِ هر فرش از سرور). +CREATE TABLE IF NOT EXISTS carpets ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + price_coins INTEGER NOT NULL DEFAULT 0, -- ۰ یعنی رایگان (مگر vip) + vip INTEGER NOT NULL DEFAULT 0, -- ۱ یعنی برای VIP رایگان + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS user_carpets ( + user_id INTEGER NOT NULL, + carpet_id TEXT NOT NULL, + PRIMARY KEY (user_id, carpet_id) +); + +-- فرشِ انتخابیِ کاربر؛ 'classic' یعنی میزِ پیش‌فرضِ ترسیمی (بدون تصویر). +ALTER TABLE users ADD COLUMN selected_carpet TEXT NOT NULL DEFAULT 'classic'; + +INSERT OR IGNORE INTO carpets (id, title, price_coins, vip, sort) VALUES + ('classic', 'میزِ کلاسیک', 0, 0, 1), + ('kashan', 'فرشِ کاشان', 8000, 0, 2), + ('tabriz', 'فرشِ تبریز', 15000, 0, 3), + ('royal', 'فرشِ سلطنتی', 0, 1, 4); diff --git a/internal/store/migrations/010_carpets_more.sql b/internal/store/migrations/010_carpets_more.sql new file mode 100644 index 0000000..5cd54fd --- /dev/null +++ b/internal/store/migrations/010_carpets_more.sql @@ -0,0 +1,6 @@ +-- فرش‌های بیشتر (تصاویرِ embed‌شده در سرور). kashan/tabriz/royal در 009 هستند. +INSERT OR IGNORE INTO carpets (id, title, price_coins, vip, sort) VALUES + ('qom', 'فرشِ قم', 18000, 0, 5), + ('nain', 'فرشِ نائین', 12000, 0, 6), + ('isfahan', 'فرشِ اصفهان', 25000, 0, 7), + ('shiraz', 'فرشِ شیراز', 10000, 0, 8);
متن / شکلکترتیب
{{.body}}{{.sort}}
- - + +